From f19f7a4aa1395037e775a12be4fb4a2a7a0f43ea Mon Sep 17 00:00:00 2001
From: Dustin <155417613+vgcman16@users.noreply.github.com>
Date: Mon, 28 Oct 2024 20:48:00 -0500
Subject: [PATCH 01/12] Add save/revert functionality and fix file modification
tracking
---
app/components/editor/VersionHistory.tsx | 69 ++++++++++
app/components/workbench/EditorPanel.tsx | 36 ++---
app/components/workbench/Workbench.client.tsx | 18 +++
app/lib/runtime/action-runner.ts | 27 ++--
app/lib/stores/editor.ts | 29 +++++
app/lib/stores/files.ts | 91 +++++++------
app/lib/stores/version-history.ts | 123 ++++++++++++++++++
app/lib/stores/workbench.ts | 64 +++++----
8 files changed, 367 insertions(+), 90 deletions(-)
create mode 100644 app/components/editor/VersionHistory.tsx
create mode 100644 app/lib/stores/version-history.ts
diff --git a/app/components/editor/VersionHistory.tsx b/app/components/editor/VersionHistory.tsx
new file mode 100644
index 000000000..e555ee077
--- /dev/null
+++ b/app/components/editor/VersionHistory.tsx
@@ -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 (
+
+
Version History
+
+ {versions.map((version, index) => (
+
+
+
+ Version {versions.length - index}
+
+ {formatDate(version.timestamp)}
+
+
+
{version.description}
+
+ {currentVersion && currentVersion.timestamp !== version.timestamp && (
+
+ )}
+
+ ))}
+
+
+ );
+}
diff --git a/app/components/workbench/EditorPanel.tsx b/app/components/workbench/EditorPanel.tsx
index d1a265a66..97a8bd282 100644
--- a/app/components/workbench/EditorPanel.tsx
+++ b/app/components/workbench/EditorPanel.tsx
@@ -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';
@@ -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;
@@ -149,7 +146,7 @@ export const EditorPanel = memo(
{activeFileSegments?.length && (
- {activeFileUnsaved && (
+ {editorDocument && (
@@ -164,17 +161,24 @@ export const EditorPanel = memo(
)}
-
-
+
+
+
+
+ {editorDocument && (
+
+
+
+ )}
diff --git a/app/components/workbench/Workbench.client.tsx b/app/components/workbench/Workbench.client.tsx
index 29c722c89..0a55c5907 100644
--- a/app/components/workbench/Workbench.client.tsx
+++ b/app/components/workbench/Workbench.client.tsx
@@ -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
((update) => {
workbenchStore.setCurrentDocumentContent(update.content);
}, []);
@@ -230,6 +247,7 @@ export const Workbench = memo(({ chatStarted, isStreaming }: WorkspaceProps) =>
)
);
});
+
interface ViewProps extends HTMLMotionProps<'div'> {
children: JSX.Element;
}
diff --git a/app/lib/runtime/action-runner.ts b/app/lib/runtime/action-runner.ts
index e2ea6a226..4260f93b1 100644
--- a/app/lib/runtime/action-runner.ts
+++ b/app/lib/runtime/action-runner.ts
@@ -5,6 +5,9 @@ import type { BoltAction } from '~/types/actions';
import { createScopedLogger } from '~/utils/logger';
import { unreachable } from '~/utils/unreachable';
import type { ActionCallbackData } from './message-parser';
+import { versionHistoryStore } from '../stores/version-history';
+import { workbenchStore } from '../stores/workbench';
+import type { FilesStore } from '../stores/files';
const logger = createScopedLogger('ActionRunner');
@@ -35,12 +38,14 @@ type ActionsMap = MapStore>;
export class ActionRunner {
#webcontainer: Promise;
+ #filesStore: FilesStore;
#currentExecutionPromise: Promise = Promise.resolve();
actions: ActionsMap = map({});
- constructor(webcontainerPromise: Promise) {
+ constructor(webcontainerPromise: Promise, filesStore: FilesStore) {
this.#webcontainer = webcontainerPromise;
+ this.#filesStore = filesStore;
}
addAction(data: ActionCallbackData) {
@@ -50,7 +55,6 @@ export class ActionRunner {
const action = actions[actionId];
if (action) {
- // action already added
return;
}
@@ -115,8 +119,6 @@ export class ActionRunner {
this.#updateAction(actionId, { status: action.abortSignal.aborted ? 'aborted' : 'complete' });
} catch (error) {
this.#updateAction(actionId, { status: 'failed', error: 'Action failed' });
-
- // re-throw the error to be caught in the promise chain
throw error;
}
}
@@ -145,7 +147,6 @@ export class ActionRunner {
);
const exitCode = await process.exit;
-
logger.debug(`Process terminated with code ${exitCode}`);
}
@@ -157,8 +158,6 @@ export class ActionRunner {
const webcontainer = await this.#webcontainer;
let folder = nodePath.dirname(action.filePath);
-
- // remove trailing slashes
folder = folder.replace(/\/+$/g, '');
if (folder !== '.') {
@@ -172,6 +171,19 @@ export class ActionRunner {
try {
await webcontainer.fs.writeFile(action.filePath, action.content);
+
+ // Check if this is a modification of an existing file
+ if (this.#filesStore.isExistingFile(action.filePath)) {
+ // Only mark as modified if file existed before
+ const newUnsavedFiles = new Set(workbenchStore.unsavedFiles.get());
+ newUnsavedFiles.add(action.filePath);
+ workbenchStore.unsavedFiles.set(newUnsavedFiles);
+ versionHistoryStore.addVersion(action.filePath, action.content, 'Modified through chat');
+ } else {
+ // This is a new file
+ versionHistoryStore.addVersion(action.filePath, action.content, 'Initial version');
+ }
+
logger.debug(`File written ${action.filePath}`);
} catch (error) {
logger.error('Failed to write file\n\n', error);
@@ -180,7 +192,6 @@ export class ActionRunner {
#updateAction(id: string, newState: ActionStateUpdate) {
const actions = this.actions.get();
-
this.actions.setKey(id, { ...actions[id], ...newState });
}
}
diff --git a/app/lib/stores/editor.ts b/app/lib/stores/editor.ts
index ff3b3375f..9c33c475a 100644
--- a/app/lib/stores/editor.ts
+++ b/app/lib/stores/editor.ts
@@ -1,6 +1,8 @@
import { atom, computed, map, type MapStore, type WritableAtom } from 'nanostores';
import type { EditorDocument, ScrollPosition } from '~/components/editor/codemirror/CodeMirrorEditor';
import type { FileMap, FilesStore } from './files';
+import { versionHistoryStore } from './version-history';
+import { workbenchStore } from './workbench';
export type EditorDocuments = Record;
@@ -8,6 +10,7 @@ type SelectedFile = WritableAtom;
export class EditorStore {
#filesStore: FilesStore;
+ #originalContent: Map = new Map();
selectedFile: SelectedFile = import.meta.hot?.data.selectedFile ?? atom();
documents: MapStore = import.meta.hot?.data.documents ?? map({});
@@ -42,12 +45,18 @@ export class EditorStore {
const previousDocument = previousDocuments?.[filePath];
+ // Store original content for reset functionality
+ if (!this.#originalContent.has(filePath)) {
+ this.#originalContent.set(filePath, dirent.content);
+ }
+
return [
filePath,
{
value: dirent.content,
filePath,
scroll: previousDocument?.scroll,
+ isBinary: dirent.isBinary,
},
] as [string, EditorDocument];
})
@@ -86,10 +95,30 @@ export class EditorStore {
const contentChanged = currentContent !== newContent;
if (contentChanged) {
+ // Add version when content changes
+ versionHistoryStore.addVersion(filePath, newContent, 'Modified in editor');
+
+ // Only mark as modified if it's an existing file
+ if (this.#filesStore.isExistingFile(filePath)) {
+ const newUnsavedFiles = new Set(workbenchStore.unsavedFiles.get());
+ newUnsavedFiles.add(filePath);
+ workbenchStore.unsavedFiles.set(newUnsavedFiles);
+ }
+
this.documents.setKey(filePath, {
...documentState,
value: newContent,
});
}
}
+
+ resetFile(filePath: string) {
+ const originalContent = this.#originalContent.get(filePath);
+ if (originalContent) {
+ this.updateFile(filePath, originalContent);
+ const newUnsavedFiles = new Set(workbenchStore.unsavedFiles.get());
+ newUnsavedFiles.delete(filePath);
+ workbenchStore.unsavedFiles.set(newUnsavedFiles);
+ }
+ }
}
diff --git a/app/lib/stores/files.ts b/app/lib/stores/files.ts
index 663ae5811..5cf64c4f5 100644
--- a/app/lib/stores/files.ts
+++ b/app/lib/stores/files.ts
@@ -8,6 +8,8 @@ import { WORK_DIR } from '~/utils/constants';
import { computeFileModifications } from '~/utils/diff';
import { createScopedLogger } from '~/utils/logger';
import { unreachable } from '~/utils/unreachable';
+import { versionHistoryStore } from './version-history';
+import { workbenchStore } from './workbench';
const logger = createScopedLogger('FilesStore');
@@ -29,22 +31,10 @@ export type FileMap = Record;
export class FilesStore {
#webcontainer: Promise;
-
- /**
- * Tracks the number of files without folders.
- */
#size = 0;
-
- /**
- * @note Keeps track all modified files with their original content since the last user message.
- * Needs to be reset when the user sends another message and all changes have to be submitted
- * for the model to be aware of the changes.
- */
#modifiedFiles: Map = import.meta.hot?.data.modifiedFiles ?? new Map();
-
- /**
- * Map of files that matches the state of WebContainer.
- */
+ #existingFiles: Set = new Set();
+ #newFiles: Set = new Set();
files: MapStore = import.meta.hot?.data.files ?? map({});
get filesCount() {
@@ -72,6 +62,10 @@ export class FilesStore {
return dirent;
}
+ isExistingFile(filePath: string) {
+ return this.#existingFiles.has(filePath);
+ }
+
getFileModifications() {
return computeFileModifications(this.files.get(), this.#modifiedFiles);
}
@@ -80,7 +74,7 @@ export class FilesStore {
this.#modifiedFiles.clear();
}
- async saveFile(filePath: string, content: string) {
+ async saveFile(filePath: string, content: string, description: string = 'File updated') {
const webcontainer = await this.#webcontainer;
try {
@@ -102,13 +96,21 @@ export class FilesStore {
this.#modifiedFiles.set(filePath, oldContent);
}
- // we immediately update the file and don't rely on the `change` event coming from the watcher
+ // Add version to history
+ versionHistoryStore.addVersion(filePath, content, description);
+
+ // Mark file as modified only if it existed before
+ if (this.#existingFiles.has(filePath)) {
+ const newUnsavedFiles = new Set(workbenchStore.unsavedFiles.get());
+ newUnsavedFiles.add(filePath);
+ workbenchStore.unsavedFiles.set(newUnsavedFiles);
+ }
+
this.files.setKey(filePath, { type: 'file', content, isBinary: false });
logger.info('File updated');
} catch (error) {
logger.error('Failed to update file content\n\n', error);
-
throw error;
}
}
@@ -126,57 +128,72 @@ export class FilesStore {
const watchEvents = events.flat(2);
for (const { type, path, buffer } of watchEvents) {
- // remove any trailing slashes
const sanitizedPath = path.replace(/\/+$/g, '');
switch (type) {
case 'add_dir': {
- // we intentionally add a trailing slash so we can distinguish files from folders in the file tree
this.files.setKey(sanitizedPath, { type: 'folder' });
break;
}
case 'remove_dir': {
this.files.setKey(sanitizedPath, undefined);
+ this.#existingFiles.delete(sanitizedPath);
for (const [direntPath] of Object.entries(this.files)) {
if (direntPath.startsWith(sanitizedPath)) {
this.files.setKey(direntPath, undefined);
+ this.#existingFiles.delete(direntPath);
}
}
-
break;
}
- case 'add_file':
- case 'change': {
- if (type === 'add_file') {
- this.#size++;
+ case 'add_file': {
+ this.#size++;
+ let content = '';
+ const isBinary = isBinaryFile(buffer);
+
+ if (!isBinary) {
+ content = this.#decodeFileContent(buffer);
+ versionHistoryStore.addVersion(sanitizedPath, content, 'Initial version');
+
+ // Track as a new file
+ this.#newFiles.add(sanitizedPath);
}
+ this.files.setKey(sanitizedPath, { type: 'file', content, isBinary });
+ break;
+ }
+ case 'change': {
let content = '';
-
- /**
- * @note This check is purely for the editor. The way we detect this is not
- * bullet-proof and it's a best guess so there might be false-positives.
- * The reason we do this is because we don't want to display binary files
- * in the editor nor allow to edit them.
- */
const isBinary = isBinaryFile(buffer);
if (!isBinary) {
content = this.#decodeFileContent(buffer);
+
+ // If this is a new file's first change, mark it as existing
+ if (this.#newFiles.has(sanitizedPath)) {
+ this.#existingFiles.add(sanitizedPath);
+ this.#newFiles.delete(sanitizedPath);
+ }
+ // Only mark as modified if it's already an existing file
+ else if (this.#existingFiles.has(sanitizedPath)) {
+ const newUnsavedFiles = new Set(workbenchStore.unsavedFiles.get());
+ newUnsavedFiles.add(sanitizedPath);
+ workbenchStore.unsavedFiles.set(newUnsavedFiles);
+ }
}
this.files.setKey(sanitizedPath, { type: 'file', content, isBinary });
-
break;
}
case 'remove_file': {
this.#size--;
this.files.setKey(sanitizedPath, undefined);
+ this.#existingFiles.delete(sanitizedPath);
+ this.#newFiles.delete(sanitizedPath);
break;
}
case 'update_directory': {
- // we don't care about these events
break;
}
}
@@ -205,16 +222,8 @@ function isBinaryFile(buffer: Uint8Array | undefined) {
return getEncoding(convertToBuffer(buffer), { chunkLength: 100 }) === 'binary';
}
-/**
- * Converts a `Uint8Array` into a Node.js `Buffer` by copying the prototype.
- * The goal is to avoid expensive copies. It does create a new typed array
- * but that's generally cheap as long as it uses the same underlying
- * array buffer.
- */
function convertToBuffer(view: Uint8Array): Buffer {
const buffer = new Uint8Array(view.buffer, view.byteOffset, view.byteLength);
-
Object.setPrototypeOf(buffer, Buffer.prototype);
-
return buffer as Buffer;
}
diff --git a/app/lib/stores/version-history.ts b/app/lib/stores/version-history.ts
new file mode 100644
index 000000000..60f577b3a
--- /dev/null
+++ b/app/lib/stores/version-history.ts
@@ -0,0 +1,123 @@
+import { map, type MapStore } from 'nanostores';
+import { createScopedLogger } from '~/utils/logger';
+import type { FilesStore } from './files';
+
+const logger = createScopedLogger('VersionHistoryStore');
+
+export interface FileVersion {
+ content: string;
+ timestamp: number;
+ description: string;
+}
+
+export interface FileHistory {
+ versions: FileVersion[];
+ currentVersion: number;
+}
+
+type VersionMap = Record;
+
+export class VersionHistoryStore {
+ versions: MapStore = map({});
+ #filesStore?: FilesStore;
+
+ setFilesStore(filesStore: FilesStore) {
+ this.#filesStore = filesStore;
+ }
+
+ addVersion(filePath: string, content: string, description: string) {
+ const currentHistory = this.versions.get()[filePath] || { versions: [], currentVersion: -1 };
+
+ // Don't add duplicate versions with the same content
+ const lastVersion = currentHistory.versions[currentHistory.versions.length - 1];
+ if (lastVersion && lastVersion.content === content) {
+ return;
+ }
+
+ const newVersion: FileVersion = {
+ content,
+ timestamp: Date.now(),
+ description
+ };
+
+ const newHistory: FileHistory = {
+ versions: [...currentHistory.versions, newVersion],
+ currentVersion: currentHistory.versions.length
+ };
+
+ this.versions.setKey(filePath, newHistory);
+ logger.info(`Added version for ${filePath}: ${description}`);
+ }
+
+ getVersions(filePath: string): FileVersion[] {
+ const history = this.versions.get()[filePath];
+ if (!history) {
+ // If no history exists, create initial version from current file content
+ if (this.#filesStore) {
+ const file = this.#filesStore.getFile(filePath);
+ if (file) {
+ this.addVersion(filePath, file.content, 'Initial version');
+ return this.versions.get()[filePath]?.versions || [];
+ }
+ }
+ return [];
+ }
+ return history.versions;
+ }
+
+ getCurrentVersion(filePath: string): FileVersion | undefined {
+ const history = this.versions.get()[filePath];
+ if (!history) {
+ // If no history exists, create initial version from current file content
+ if (this.#filesStore) {
+ const file = this.#filesStore.getFile(filePath);
+ if (file) {
+ this.addVersion(filePath, file.content, 'Initial version');
+ return this.versions.get()[filePath]?.versions[0];
+ }
+ }
+ return undefined;
+ }
+ return history.versions[history.currentVersion];
+ }
+
+ getVersion(filePath: string, versionIndex: number): FileVersion | undefined {
+ return this.versions.get()[filePath]?.versions[versionIndex];
+ }
+
+ async revertToVersion(filePath: string, versionIndex: number): Promise {
+ const history = this.versions.get()[filePath];
+ if (!history || versionIndex < 0 || versionIndex >= history.versions.length) {
+ return undefined;
+ }
+
+ const version = history.versions[versionIndex];
+ if (!version) {
+ return undefined;
+ }
+
+ // Update the file content using FilesStore
+ if (this.#filesStore) {
+ try {
+ await this.#filesStore.saveFile(filePath, version.content, `Reverted to version ${versionIndex + 1}`);
+
+ const newHistory: FileHistory = {
+ ...history,
+ currentVersion: versionIndex
+ };
+
+ this.versions.setKey(filePath, newHistory);
+ logger.info(`Reverted ${filePath} to version ${versionIndex + 1}`);
+ return version;
+ } catch (error) {
+ logger.error(`Failed to revert ${filePath} to version ${versionIndex + 1}:`, error);
+ throw error;
+ }
+ } else {
+ logger.error('FilesStore not initialized');
+ throw new Error('FilesStore not initialized');
+ }
+ }
+}
+
+export const versionHistoryStore = new VersionHistoryStore();
diff --git a/app/lib/stores/workbench.ts b/app/lib/stores/workbench.ts
index c42cc6275..76a9db139 100644
--- a/app/lib/stores/workbench.ts
+++ b/app/lib/stores/workbench.ts
@@ -132,6 +132,10 @@ export class WorkbenchStore {
if (unsavedChanges) {
newUnsavedFiles.add(currentDocument.filePath);
+ // Only mark as modified if the file exists
+ if (this.#filesStore.isExistingFile(currentDocument.filePath)) {
+ this.modifiedFiles.add(currentDocument.filePath);
+ }
} else {
newUnsavedFiles.delete(currentDocument.filePath);
}
@@ -190,13 +194,8 @@ export class WorkbenchStore {
}
const { filePath } = currentDocument;
- const file = this.#filesStore.getFile(filePath);
-
- if (!file) {
- return;
- }
-
- this.setCurrentDocumentContent(file.content);
+ this.#editorStore.resetFile(filePath);
+ this.modifiedFiles.delete(filePath);
}
async saveAllFiles() {
@@ -206,11 +205,12 @@ export class WorkbenchStore {
}
getFileModifcations() {
- return this.#filesStore.getFileModifications();
+ return Array.from(this.modifiedFiles);
}
resetAllFileModifications() {
- this.#filesStore.resetFileModifications();
+ this.modifiedFiles.clear();
+ this.unsavedFiles.set(new Set());
}
abortAllActions() {
@@ -232,7 +232,7 @@ export class WorkbenchStore {
id,
title,
closed: false,
- runner: new ActionRunner(webcontainer),
+ runner: new ActionRunner(webcontainer, this.#filesStore),
});
}
@@ -247,7 +247,7 @@ export class WorkbenchStore {
}
async addAction(data: ActionCallbackData) {
- const { messageId } = data;
+ const { messageId, action } = data;
const artifact = this.#getArtifact(messageId);
@@ -255,6 +255,20 @@ export class WorkbenchStore {
unreachable('Artifact not found');
}
+ // Track file modifications for file actions, but only for existing files
+ if (action.type === 'file') {
+ const filePath = action.filePath;
+ // Only track modifications for existing files
+ if (this.#filesStore.isExistingFile(filePath)) {
+ this.modifiedFiles.add(filePath);
+
+ // Update unsavedFiles since this is a modification of an existing file
+ const newUnsavedFiles = new Set(this.unsavedFiles.get());
+ newUnsavedFiles.add(filePath);
+ this.unsavedFiles.set(newUnsavedFiles);
+ }
+ }
+
artifact.runner.addAction(data);
}
@@ -336,7 +350,6 @@ export class WorkbenchStore {
}
async pushToGitHub(repoName: string, githubUsername: string, ghToken: string) {
-
try {
// Get the GitHub auth token from environment variables
const githubToken = ghToken;
@@ -351,7 +364,7 @@ export class WorkbenchStore {
const octokit = new Octokit({ auth: githubToken });
// Check if the repository already exists before creating it
- let repo
+ let repo;
try {
repo = await octokit.repos.get({ owner: owner, repo: repoName });
} catch (error) {
@@ -362,7 +375,7 @@ export class WorkbenchStore {
private: false,
auto_init: true,
});
- repo = newRepo;
+ repo = { owner: { login: owner }, name: repoName, default_branch: 'main', ...newRepo };
} else {
console.log('cannot create repo!');
throw error; // Some other error occurred
@@ -380,8 +393,8 @@ export class WorkbenchStore {
Object.entries(files).map(async ([filePath, dirent]) => {
if (dirent?.type === 'file' && dirent.content) {
const { data: blob } = await octokit.git.createBlob({
- owner: repo.owner.login,
- repo: repo.name,
+ owner: owner,
+ repo: repoName,
content: Buffer.from(dirent.content).toString('base64'),
encoding: 'base64',
});
@@ -398,16 +411,16 @@ export class WorkbenchStore {
// Get the latest commit SHA (assuming main branch, update dynamically if needed)
const { data: ref } = await octokit.git.getRef({
- owner: repo.owner.login,
- repo: repo.name,
+ owner: owner,
+ repo: repoName,
ref: `heads/${repo.default_branch || 'main'}`, // Handle dynamic branch
});
const latestCommitSha = ref.object.sha;
// Create a new tree
const { data: newTree } = await octokit.git.createTree({
- owner: repo.owner.login,
- repo: repo.name,
+ owner: owner,
+ repo: repoName,
base_tree: latestCommitSha,
tree: validBlobs.map((blob) => ({
path: blob!.path,
@@ -419,8 +432,8 @@ export class WorkbenchStore {
// Create a new commit
const { data: newCommit } = await octokit.git.createCommit({
- owner: repo.owner.login,
- repo: repo.name,
+ owner: owner,
+ repo: repoName,
message: 'Initial commit from your app',
tree: newTree.sha,
parents: [latestCommitSha],
@@ -428,15 +441,16 @@ export class WorkbenchStore {
// Update the reference
await octokit.git.updateRef({
- owner: repo.owner.login,
- repo: repo.name,
+ owner: owner,
+ repo: repoName,
ref: `heads/${repo.default_branch || 'main'}`, // Handle dynamic branch
sha: newCommit.sha,
});
- alert(`Repository created and code pushed: ${repo.html_url}`);
+ return repo.html_url;
} catch (error) {
console.error('Error pushing to GitHub:', error instanceof Error ? error.message : String(error));
+ throw error;
}
}
}
From d25cdc2c7dc5cfc9a52985348a0f4e328e239222 Mon Sep 17 00:00:00 2001
From: Dustin <155417613+vgcman16@users.noreply.github.com>
Date: Wed, 30 Oct 2024 17:39:45 -0500
Subject: [PATCH 02/12] feat: add prompt caching with visual indicator
---
app/components/chat/BaseChat.tsx | 13 +++++--
app/components/chat/Chat.client.tsx | 3 +-
app/lib/hooks/usePromptEnhancer.ts | 15 +++++++-
app/lib/stores/prompt-cache.ts | 54 +++++++++++++++++++++++++++++
app/routes/api.enhancer.ts | 30 +++++++++++++++-
5 files changed, 110 insertions(+), 5 deletions(-)
create mode 100644 app/lib/stores/prompt-cache.ts
diff --git a/app/components/chat/BaseChat.tsx b/app/components/chat/BaseChat.tsx
index c1175f700..5d51278eb 100644
--- a/app/components/chat/BaseChat.tsx
+++ b/app/components/chat/BaseChat.tsx
@@ -76,6 +76,7 @@ interface BaseChatProps {
messages?: Message[];
enhancingPrompt?: boolean;
promptEnhanced?: boolean;
+ fromCache?: boolean;
input?: string;
model: string;
setModel: (model: string) => void;
@@ -96,6 +97,7 @@ export const BaseChat = React.forwardRef(
isStreaming = false,
enhancingPrompt = false,
promptEnhanced = false,
+ fromCache = false,
messages,
input = '',
model,
@@ -224,8 +226,15 @@ export const BaseChat = React.forwardRef(
>
) : (
<>
-
- {promptEnhanced && Prompt enhanced
}
+
+ {promptEnhanced && (
+
+ {fromCache ? "From cache" : "Prompt enhanced"}
+
+ )}
>
)}
diff --git a/app/components/chat/Chat.client.tsx b/app/components/chat/Chat.client.tsx
index 458bd8364..32f480c90 100644
--- a/app/components/chat/Chat.client.tsx
+++ b/app/components/chat/Chat.client.tsx
@@ -91,7 +91,7 @@ export const ChatImpl = memo(({ initialMessages, storeMessageHistory }: ChatProp
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;
@@ -212,6 +212,7 @@ export const ChatImpl = memo(({ initialMessages, storeMessageHistory }: ChatProp
isStreaming={isLoading}
enhancingPrompt={enhancingPrompt}
promptEnhanced={promptEnhanced}
+ fromCache={fromCache}
sendMessage={sendMessage}
model={model}
setModel={setModel}
diff --git a/app/lib/hooks/usePromptEnhancer.ts b/app/lib/hooks/usePromptEnhancer.ts
index f376cc0cd..4eed04b5c 100644
--- a/app/lib/hooks/usePromptEnhancer.ts
+++ b/app/lib/hooks/usePromptEnhancer.ts
@@ -6,15 +6,18 @@ const logger = createScopedLogger('usePromptEnhancement');
export function usePromptEnhancer() {
const [enhancingPrompt, setEnhancingPrompt] = useState(false);
const [promptEnhanced, setPromptEnhanced] = useState(false);
+ const [fromCache, setFromCache] = useState(false);
const resetEnhancer = () => {
setEnhancingPrompt(false);
setPromptEnhanced(false);
+ setFromCache(false);
};
const enhancePrompt = async (input: string, setInput: (value: string) => void) => {
setEnhancingPrompt(true);
setPromptEnhanced(false);
+ setFromCache(false);
const response = await fetch('/api/enhancer', {
method: 'POST',
@@ -23,6 +26,10 @@ export function usePromptEnhancer() {
}),
});
+ // Check if response was from cache
+ const isCached = response.headers.get('x-from-cache') === 'true';
+ setFromCache(isCached);
+
const reader = response.body?.getReader();
const originalInput = input;
@@ -67,5 +74,11 @@ export function usePromptEnhancer() {
}
};
- return { enhancingPrompt, promptEnhanced, enhancePrompt, resetEnhancer };
+ return {
+ enhancingPrompt,
+ promptEnhanced,
+ fromCache,
+ enhancePrompt,
+ resetEnhancer
+ };
}
diff --git a/app/lib/stores/prompt-cache.ts b/app/lib/stores/prompt-cache.ts
new file mode 100644
index 000000000..fc59a1db5
--- /dev/null
+++ b/app/lib/stores/prompt-cache.ts
@@ -0,0 +1,54 @@
+import { map, type MapStore } from 'nanostores';
+
+interface CacheEntry {
+ enhancedPrompt: string;
+ timestamp: number;
+}
+
+type PromptCache = MapStore>;
+
+class PromptCacheStore {
+ // Cache entries expire after 24 hours
+ private static CACHE_TTL = 24 * 60 * 60 * 1000;
+
+ cache: PromptCache = map({});
+
+ getEnhancedPrompt(originalPrompt: string): string | null {
+ const entry = this.cache.get()[originalPrompt];
+ if (!entry) return null;
+
+ // Check if cache entry has expired
+ if (Date.now() - entry.timestamp > PromptCacheStore.CACHE_TTL) {
+ this.removeFromCache(originalPrompt);
+ return null;
+ }
+
+ return entry.enhancedPrompt;
+ }
+
+ addToCache(originalPrompt: string, enhancedPrompt: string) {
+ this.cache.setKey(originalPrompt, {
+ enhancedPrompt,
+ timestamp: Date.now(),
+ });
+ }
+
+ removeFromCache(originalPrompt: string) {
+ const entries = this.cache.get();
+ delete entries[originalPrompt];
+ this.cache.set(entries);
+ }
+
+ clearExpiredEntries() {
+ const entries = this.cache.get();
+ const now = Date.now();
+
+ Object.entries(entries).forEach(([prompt, entry]) => {
+ if (now - entry.timestamp > PromptCacheStore.CACHE_TTL) {
+ this.removeFromCache(prompt);
+ }
+ });
+ }
+}
+
+export const promptCacheStore = new PromptCacheStore();
diff --git a/app/routes/api.enhancer.ts b/app/routes/api.enhancer.ts
index 5c8175ca3..95c1af91e 100644
--- a/app/routes/api.enhancer.ts
+++ b/app/routes/api.enhancer.ts
@@ -2,6 +2,7 @@ import { type ActionFunctionArgs } from '@remix-run/cloudflare';
import { StreamingTextResponse, parseStreamPart } from 'ai';
import { streamText } from '~/lib/.server/llm/stream-text';
import { stripIndents } from '~/utils/stripIndent';
+import { promptCacheStore } from '~/lib/stores/prompt-cache';
const encoder = new TextEncoder();
const decoder = new TextDecoder();
@@ -14,6 +15,22 @@ async function enhancerAction({ context, request }: ActionFunctionArgs) {
const { message } = await request.json<{ message: string }>();
try {
+ // Check cache first
+ const cachedPrompt = promptCacheStore.getEnhancedPrompt(message);
+ if (cachedPrompt) {
+ // Return cached result immediately with cache header
+ const stream = new ReadableStream({
+ start(controller) {
+ controller.enqueue(encoder.encode(cachedPrompt));
+ controller.close();
+ },
+ });
+ const response = new StreamingTextResponse(stream);
+ response.headers.set('x-from-cache', 'true');
+ return response;
+ }
+
+ // If not in cache, proceed with LLM call
const result = await streamText(
[
{
@@ -32,6 +49,8 @@ async function enhancerAction({ context, request }: ActionFunctionArgs) {
context.cloudflare.env,
);
+ let enhancedPrompt = '';
+
const transformStream = new TransformStream({
transform(chunk, controller) {
const processedChunk = decoder
@@ -42,13 +61,22 @@ async function enhancerAction({ context, request }: ActionFunctionArgs) {
.map((part) => part.value)
.join('');
+ // Accumulate the enhanced prompt
+ enhancedPrompt += processedChunk;
+
controller.enqueue(encoder.encode(processedChunk));
},
+ flush() {
+ // Cache the complete enhanced prompt
+ promptCacheStore.addToCache(message, enhancedPrompt);
+ }
});
const transformedStream = result.toAIStream().pipeThrough(transformStream);
+ const response = new StreamingTextResponse(transformedStream);
+ response.headers.set('x-from-cache', 'false');
+ return response;
- return new StreamingTextResponse(transformedStream);
} catch (error) {
console.log(error);
From ae72cd4665d924d28f9162ab7169b4aa781c67a2 Mon Sep 17 00:00:00 2001
From: Dustin <155417613+vgcman16@users.noreply.github.com>
Date: Wed, 30 Oct 2024 17:54:01 -0500
Subject: [PATCH 03/12] fix: improve chat persistence reliability and error
handling
---
app/lib/persistence/db.ts | 264 +++++++++++++++++---------
app/lib/persistence/useChatHistory.ts | 67 ++++---
2 files changed, 209 insertions(+), 122 deletions(-)
diff --git a/app/lib/persistence/db.ts b/app/lib/persistence/db.ts
index 7a952e344..80d895567 100644
--- a/app/lib/persistence/db.ts
+++ b/app/lib/persistence/db.ts
@@ -4,40 +4,81 @@ import type { ChatHistoryItem } from './useChatHistory';
const logger = createScopedLogger('ChatHistory');
-// this is used at the top level and never rejects
+function isBrowserEnvironment(): boolean {
+ return typeof window !== 'undefined' && typeof window.indexedDB !== 'undefined';
+}
+
export async function openDatabase(): Promise {
return new Promise((resolve) => {
- const request = indexedDB.open('boltHistory', 1);
-
- request.onupgradeneeded = (event: IDBVersionChangeEvent) => {
- const db = (event.target as IDBOpenDBRequest).result;
-
- if (!db.objectStoreNames.contains('chats')) {
- const store = db.createObjectStore('chats', { keyPath: 'id' });
- store.createIndex('id', 'id', { unique: true });
- store.createIndex('urlId', 'urlId', { unique: true });
+ try {
+ // Check if we're in a browser environment with IndexedDB support
+ if (!isBrowserEnvironment()) {
+ logger.debug('Not in browser environment or IndexedDB not available');
+ resolve(undefined);
+ return;
}
- };
- request.onsuccess = (event: Event) => {
- resolve((event.target as IDBOpenDBRequest).result);
- };
-
- request.onerror = (event: Event) => {
+ const request = indexedDB.open('boltHistory', 1);
+
+ request.onupgradeneeded = (event: IDBVersionChangeEvent) => {
+ const db = (event.target as IDBOpenDBRequest).result;
+ logger.debug('Upgrading database');
+
+ if (!db.objectStoreNames.contains('chats')) {
+ const store = db.createObjectStore('chats', { keyPath: 'id' });
+ store.createIndex('id', 'id', { unique: true });
+ store.createIndex('urlId', 'urlId', { unique: true });
+ logger.debug('Created chats store');
+ }
+ };
+
+ request.onsuccess = (event: Event) => {
+ const db = (event.target as IDBOpenDBRequest).result;
+ logger.debug('Successfully opened database');
+
+ // Add error handler for database
+ db.onerror = (event: Event) => {
+ const target = event.target as IDBDatabase;
+ logger.error('Database error:', target.name);
+ };
+
+ resolve(db);
+ };
+
+ request.onerror = (event: Event) => {
+ const error = (event.target as IDBOpenDBRequest).error;
+ logger.error('Failed to open database:', error?.message || 'Unknown error');
+ resolve(undefined);
+ };
+
+ request.onblocked = () => {
+ logger.error('Database blocked');
+ resolve(undefined);
+ };
+
+ } catch (error) {
+ logger.error('Error initializing database:', error);
resolve(undefined);
- logger.error((event.target as IDBOpenDBRequest).error);
- };
+ }
});
}
export async function getAll(db: IDBDatabase): Promise {
return new Promise((resolve, reject) => {
- const transaction = db.transaction('chats', 'readonly');
- const store = transaction.objectStore('chats');
- const request = store.getAll();
-
- request.onsuccess = () => resolve(request.result as ChatHistoryItem[]);
- request.onerror = () => reject(request.error);
+ try {
+ const transaction = db.transaction('chats', 'readonly');
+ const store = transaction.objectStore('chats');
+ const request = store.getAll();
+
+ request.onsuccess = () => resolve(request.result as ChatHistoryItem[]);
+ request.onerror = () => {
+ logger.error('Failed to get all chats:', request.error);
+ reject(request.error);
+ };
+ } catch (error) {
+ logger.error('Error getting all chats:', error);
+ reject(error);
+ }
});
}
@@ -49,19 +90,30 @@ export async function setMessages(
description?: string,
): Promise {
return new Promise((resolve, reject) => {
- const transaction = db.transaction('chats', 'readwrite');
- const store = transaction.objectStore('chats');
-
- const request = store.put({
- id,
- messages,
- urlId,
- description,
- timestamp: new Date().toISOString(),
- });
-
- request.onsuccess = () => resolve();
- request.onerror = () => reject(request.error);
+ try {
+ const transaction = db.transaction('chats', 'readwrite');
+ const store = transaction.objectStore('chats');
+
+ const request = store.put({
+ id,
+ messages,
+ urlId,
+ description,
+ timestamp: new Date().toISOString(),
+ });
+
+ request.onsuccess = () => {
+ logger.debug('Successfully stored messages');
+ resolve();
+ };
+ request.onerror = () => {
+ logger.error('Failed to store messages:', request.error);
+ reject(request.error);
+ };
+ } catch (error) {
+ logger.error('Error storing messages:', error);
+ reject(error);
+ }
});
}
@@ -71,50 +123,81 @@ export async function getMessages(db: IDBDatabase, id: string): Promise {
return new Promise((resolve, reject) => {
- const transaction = db.transaction('chats', 'readonly');
- const store = transaction.objectStore('chats');
- const index = store.index('urlId');
- const request = index.get(id);
-
- request.onsuccess = () => resolve(request.result as ChatHistoryItem);
- request.onerror = () => reject(request.error);
+ try {
+ const transaction = db.transaction('chats', 'readonly');
+ const store = transaction.objectStore('chats');
+ const index = store.index('urlId');
+ const request = index.get(id);
+
+ request.onsuccess = () => resolve(request.result as ChatHistoryItem);
+ request.onerror = () => {
+ logger.error('Failed to get messages by URL ID:', request.error);
+ reject(request.error);
+ };
+ } catch (error) {
+ logger.error('Error getting messages by URL ID:', error);
+ reject(error);
+ }
});
}
export async function getMessagesById(db: IDBDatabase, id: string): Promise {
return new Promise((resolve, reject) => {
- const transaction = db.transaction('chats', 'readonly');
- const store = transaction.objectStore('chats');
- const request = store.get(id);
-
- request.onsuccess = () => resolve(request.result as ChatHistoryItem);
- request.onerror = () => reject(request.error);
+ try {
+ const transaction = db.transaction('chats', 'readonly');
+ const store = transaction.objectStore('chats');
+ const request = store.get(id);
+
+ request.onsuccess = () => resolve(request.result as ChatHistoryItem);
+ request.onerror = () => {
+ logger.error('Failed to get messages by ID:', request.error);
+ reject(request.error);
+ };
+ } catch (error) {
+ logger.error('Error getting messages by ID:', error);
+ reject(error);
+ }
});
}
export async function deleteById(db: IDBDatabase, id: string): Promise {
return new Promise((resolve, reject) => {
- const transaction = db.transaction('chats', 'readwrite');
- const store = transaction.objectStore('chats');
- const request = store.delete(id);
-
- request.onsuccess = () => resolve(undefined);
- request.onerror = () => reject(request.error);
+ try {
+ const transaction = db.transaction('chats', 'readwrite');
+ const store = transaction.objectStore('chats');
+ const request = store.delete(id);
+
+ request.onsuccess = () => resolve(undefined);
+ request.onerror = () => {
+ logger.error('Failed to delete chat:', request.error);
+ reject(request.error);
+ };
+ } catch (error) {
+ logger.error('Error deleting chat:', error);
+ reject(error);
+ }
});
}
export async function getNextId(db: IDBDatabase): Promise {
return new Promise((resolve, reject) => {
- const transaction = db.transaction('chats', 'readonly');
- const store = transaction.objectStore('chats');
- const request = store.getAllKeys();
-
- request.onsuccess = () => {
- const highestId = request.result.reduce((cur, acc) => Math.max(+cur, +acc), 0);
- resolve(String(+highestId + 1));
- };
-
- request.onerror = () => reject(request.error);
+ try {
+ const transaction = db.transaction('chats', 'readonly');
+ const store = transaction.objectStore('chats');
+ const request = store.getAllKeys();
+
+ request.onsuccess = () => {
+ const highestId = request.result.reduce((cur, acc) => Math.max(+cur, +acc), 0);
+ resolve(String(+highestId + 1));
+ };
+ request.onerror = () => {
+ logger.error('Failed to get next ID:', request.error);
+ reject(request.error);
+ };
+ } catch (error) {
+ logger.error('Error getting next ID:', error);
+ reject(error);
+ }
});
}
@@ -125,36 +208,41 @@ export async function getUrlId(db: IDBDatabase, id: string): Promise {
return id;
} else {
let i = 2;
-
while (idList.includes(`${id}-${i}`)) {
i++;
}
-
return `${id}-${i}`;
}
}
async function getUrlIds(db: IDBDatabase): Promise {
return new Promise((resolve, reject) => {
- const transaction = db.transaction('chats', 'readonly');
- const store = transaction.objectStore('chats');
- const idList: string[] = [];
-
- const request = store.openCursor();
-
- request.onsuccess = (event: Event) => {
- const cursor = (event.target as IDBRequest).result;
-
- if (cursor) {
- idList.push(cursor.value.urlId);
- cursor.continue();
- } else {
- resolve(idList);
- }
- };
-
- request.onerror = () => {
- reject(request.error);
- };
+ try {
+ const transaction = db.transaction('chats', 'readonly');
+ const store = transaction.objectStore('chats');
+ const idList: string[] = [];
+
+ const request = store.openCursor();
+
+ request.onsuccess = (event: Event) => {
+ const cursor = (event.target as IDBRequest).result;
+ if (cursor) {
+ if (cursor.value.urlId) {
+ idList.push(cursor.value.urlId);
+ }
+ cursor.continue();
+ } else {
+ resolve(idList);
+ }
+ };
+
+ request.onerror = () => {
+ logger.error('Failed to get URL IDs:', request.error);
+ reject(request.error);
+ };
+ } catch (error) {
+ logger.error('Error getting URL IDs:', error);
+ reject(error);
+ }
});
}
diff --git a/app/lib/persistence/useChatHistory.ts b/app/lib/persistence/useChatHistory.ts
index e56275327..63125232a 100644
--- a/app/lib/persistence/useChatHistory.ts
+++ b/app/lib/persistence/useChatHistory.ts
@@ -14,9 +14,8 @@ export interface ChatHistoryItem {
timestamp: string;
}
-const persistenceEnabled = !import.meta.env.VITE_DISABLE_PERSISTENCE;
-
-export const db = persistenceEnabled ? await openDatabase() : undefined;
+// Initialize database lazily when component mounts
+let db: IDBDatabase | undefined;
export const chatId = atom(undefined);
export const description = atom(undefined);
@@ -29,36 +28,40 @@ export function useChatHistory() {
const [ready, setReady] = useState(false);
const [urlId, setUrlId] = useState();
+ // Initialize database when component mounts
useEffect(() => {
- if (!db) {
- setReady(true);
-
- if (persistenceEnabled) {
- toast.error(`Chat persistence is unavailable`);
+ const initDb = async () => {
+ if (!db) {
+ db = await openDatabase();
+ }
+
+ if (!db) {
+ setReady(true);
+ // Only show error if database failed to open
+ toast.error('Failed to initialize chat persistence');
+ return;
}
- return;
- }
-
- if (mixedId) {
- getMessages(db, mixedId)
- .then((storedMessages) => {
+ if (mixedId) {
+ try {
+ const storedMessages = await getMessages(db, mixedId);
if (storedMessages && storedMessages.messages.length > 0) {
setInitialMessages(storedMessages.messages);
setUrlId(storedMessages.urlId);
description.set(storedMessages.description);
chatId.set(storedMessages.id);
} else {
- navigate(`/`, { replace: true });
+ navigate('/', { replace: true });
}
+ } catch (error) {
+ toast.error((error as Error).message);
+ }
+ }
+ setReady(true);
+ };
- setReady(true);
- })
- .catch((error) => {
- toast.error(error.message);
- });
- }
- }, []);
+ initDb();
+ }, [mixedId, navigate]);
return {
ready: !mixedId || ready,
@@ -71,10 +74,9 @@ export function useChatHistory() {
const { firstArtifact } = workbenchStore;
if (!urlId && firstArtifact?.id) {
- const urlId = await getUrlId(db, firstArtifact.id);
-
- navigateChat(urlId);
- setUrlId(urlId);
+ const newUrlId = await getUrlId(db, firstArtifact.id);
+ navigateChat(newUrlId);
+ setUrlId(newUrlId);
}
if (!description.get() && firstArtifact?.title) {
@@ -83,7 +85,6 @@ export function useChatHistory() {
if (initialMessages.length === 0 && !chatId.get()) {
const nextId = await getNextId(db);
-
chatId.set(nextId);
if (!urlId) {
@@ -91,19 +92,17 @@ export function useChatHistory() {
}
}
- await setMessages(db, chatId.get() as string, messages, urlId, description.get());
+ try {
+ await setMessages(db, chatId.get() as string, messages, urlId, description.get());
+ } catch (error) {
+ toast.error('Failed to save chat history');
+ }
},
};
}
function navigateChat(nextId: string) {
- /**
- * FIXME: Using the intended navigate function causes a rerender for that breaks the app.
- *
- * `navigate(`/chat/${nextId}`, { replace: true });`
- */
const url = new URL(window.location.href);
url.pathname = `/chat/${nextId}`;
-
window.history.replaceState({}, '', url);
}
From e7b69ecb43a1922ef62e8675098945e8a79c621c Mon Sep 17 00:00:00 2001
From: Dustin <155417613+vgcman16@users.noreply.github.com>
Date: Wed, 30 Oct 2024 17:59:23 -0500
Subject: [PATCH 04/12] fix: improve chat persistence error handling
---
app/components/chat/Chat.client.tsx | 48 ++++++---------------------
app/lib/persistence/useChatHistory.ts | 16 ++++-----
2 files changed, 18 insertions(+), 46 deletions(-)
diff --git a/app/components/chat/Chat.client.tsx b/app/components/chat/Chat.client.tsx
index 32f480c90..c5d0dbeed 100644
--- a/app/components/chat/Chat.client.tsx
+++ b/app/components/chat/Chat.client.tsx
@@ -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 && }
+
{
return (
@@ -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 ;
@@ -51,7 +49,6 @@ export function Chat() {
return ;
}
}
-
return undefined;
}}
position="bottom-right"
@@ -59,7 +56,7 @@ export function Chat() {
transition={toastAnimation}
/>
>
- );
+ ) : null;
}
interface ChatProps {
@@ -104,13 +101,15 @@ 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);
+ // Don't show error toast for persistence failures
+ });
}
- }, [messages, isLoading, parseMessages]);
+ }, [messages, isLoading, parseMessages, storeMessageHistory, initialMessages.length]);
const scrollTextArea = () => {
const textarea = textareaRef.current;
-
if (textarea) {
textarea.scrollTop = textarea.scrollHeight;
}
@@ -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) {
@@ -146,7 +142,6 @@ export const ChatImpl = memo(({ initialMessages, storeMessageHistory }: ChatProp
]);
chatStore.setKey('started', true);
-
setChatStarted(true);
};
@@ -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();
@@ -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();
};
@@ -224,7 +197,6 @@ export const ChatImpl = memo(({ initialMessages, storeMessageHistory }: ChatProp
if (message.role === 'user') {
return message;
}
-
return {
...message,
content: parsedMessages[i] || '',
diff --git a/app/lib/persistence/useChatHistory.ts b/app/lib/persistence/useChatHistory.ts
index 63125232a..bf02a39b1 100644
--- a/app/lib/persistence/useChatHistory.ts
+++ b/app/lib/persistence/useChatHistory.ts
@@ -14,7 +14,7 @@ export interface ChatHistoryItem {
timestamp: string;
}
-// Initialize database lazily when component mounts
+// Remove environment check and persistence flag
let db: IDBDatabase | undefined;
export const chatId = atom(undefined);
@@ -27,18 +27,18 @@ export function useChatHistory() {
const [initialMessages, setInitialMessages] = useState([]);
const [ready, setReady] = useState(false);
const [urlId, setUrlId] = useState();
+ const [dbInitialized, setDbInitialized] = useState(false);
// Initialize database when component mounts
useEffect(() => {
const initDb = async () => {
- if (!db) {
+ if (!db && !dbInitialized) {
db = await openDatabase();
+ setDbInitialized(true);
}
-
+
if (!db) {
setReady(true);
- // Only show error if database failed to open
- toast.error('Failed to initialize chat persistence');
return;
}
@@ -54,14 +54,14 @@ export function useChatHistory() {
navigate('/', { replace: true });
}
} catch (error) {
- toast.error((error as Error).message);
+ console.error('Failed to load messages:', error);
}
}
setReady(true);
};
initDb();
- }, [mixedId, navigate]);
+ }, [mixedId, navigate, dbInitialized]);
return {
ready: !mixedId || ready,
@@ -95,7 +95,7 @@ export function useChatHistory() {
try {
await setMessages(db, chatId.get() as string, messages, urlId, description.get());
} catch (error) {
- toast.error('Failed to save chat history');
+ console.error('Failed to store messages:', error);
}
},
};
From 895fb7a2e0660531caecbbfda828c3aa66160f06 Mon Sep 17 00:00:00 2001
From: Dustin <155417613+vgcman16@users.noreply.github.com>
Date: Wed, 30 Oct 2024 18:01:16 -0500
Subject: [PATCH 05/12] fix: improve database initialization and error handling
---
app/lib/persistence/db.ts | 42 +++++++++---
app/lib/persistence/useChatHistory.ts | 97 ++++++++++++++++-----------
2 files changed, 89 insertions(+), 50 deletions(-)
diff --git a/app/lib/persistence/db.ts b/app/lib/persistence/db.ts
index 80d895567..d12d7e5dc 100644
--- a/app/lib/persistence/db.ts
+++ b/app/lib/persistence/db.ts
@@ -4,14 +4,30 @@ import type { ChatHistoryItem } from './useChatHistory';
const logger = createScopedLogger('ChatHistory');
+let dbInitAttempted = false;
+
function isBrowserEnvironment(): boolean {
- return typeof window !== 'undefined' && typeof window.indexedDB !== 'undefined';
+ try {
+ return typeof window !== 'undefined' &&
+ typeof window.indexedDB !== 'undefined' &&
+ typeof window.IDBDatabase !== 'undefined' &&
+ typeof window.IDBTransaction !== 'undefined';
+ } catch (error) {
+ logger.error('Error checking browser environment:', error);
+ return false;
+ }
}
export async function openDatabase(): Promise {
+ if (dbInitAttempted) {
+ logger.debug('Database initialization already attempted');
+ return undefined;
+ }
+
+ dbInitAttempted = true;
+
return new Promise((resolve) => {
try {
- // Check if we're in a browser environment with IndexedDB support
if (!isBrowserEnvironment()) {
logger.debug('Not in browser environment or IndexedDB not available');
resolve(undefined);
@@ -36,13 +52,21 @@ export async function openDatabase(): Promise {
const db = (event.target as IDBOpenDBRequest).result;
logger.debug('Successfully opened database');
- // Add error handler for database
- db.onerror = (event: Event) => {
- const target = event.target as IDBDatabase;
- logger.error('Database error:', target.name);
- };
-
- resolve(db);
+ // Test if we can actually use the database
+ try {
+ const transaction = db.transaction(['chats'], 'readonly');
+ transaction.oncomplete = () => {
+ logger.debug('Database test successful');
+ resolve(db);
+ };
+ transaction.onerror = () => {
+ logger.error('Database test failed');
+ resolve(undefined);
+ };
+ } catch (error) {
+ logger.error('Error testing database:', error);
+ resolve(undefined);
+ }
};
request.onerror = (event: Event) => {
diff --git a/app/lib/persistence/useChatHistory.ts b/app/lib/persistence/useChatHistory.ts
index bf02a39b1..18bda021d 100644
--- a/app/lib/persistence/useChatHistory.ts
+++ b/app/lib/persistence/useChatHistory.ts
@@ -2,9 +2,11 @@ import { useLoaderData, useNavigate } from '@remix-run/react';
import { useState, useEffect } from 'react';
import { atom } from 'nanostores';
import type { Message } from 'ai';
-import { toast } from 'react-toastify';
import { workbenchStore } from '~/lib/stores/workbench';
import { getMessages, getNextId, getUrlId, openDatabase, setMessages } from './db';
+import { createScopedLogger } from '~/utils/logger';
+
+const logger = createScopedLogger('ChatHistory');
export interface ChatHistoryItem {
id: string;
@@ -14,8 +16,9 @@ export interface ChatHistoryItem {
timestamp: string;
}
-// Remove environment check and persistence flag
+// Initialize database lazily when needed
let db: IDBDatabase | undefined;
+let dbInitialized = false;
export const chatId = atom(undefined);
export const description = atom(undefined);
@@ -27,41 +30,53 @@ export function useChatHistory() {
const [initialMessages, setInitialMessages] = useState([]);
const [ready, setReady] = useState(false);
const [urlId, setUrlId] = useState();
- const [dbInitialized, setDbInitialized] = useState(false);
// Initialize database when component mounts
useEffect(() => {
const initDb = async () => {
- if (!db && !dbInitialized) {
- db = await openDatabase();
- setDbInitialized(true);
- }
+ try {
+ // Only attempt to initialize once
+ if (!dbInitialized) {
+ logger.debug('Initializing database');
+ db = await openDatabase();
+ dbInitialized = true;
+ }
- if (!db) {
- setReady(true);
- return;
- }
+ // If we have a mixedId but no database, navigate home
+ if (mixedId && !db) {
+ logger.debug('No database available, navigating home');
+ navigate('/', { replace: true });
+ setReady(true);
+ return;
+ }
- if (mixedId) {
- try {
- const storedMessages = await getMessages(db, mixedId);
- if (storedMessages && storedMessages.messages.length > 0) {
- setInitialMessages(storedMessages.messages);
- setUrlId(storedMessages.urlId);
- description.set(storedMessages.description);
- chatId.set(storedMessages.id);
- } else {
+ // If we have both mixedId and database, try to load messages
+ if (mixedId && db) {
+ try {
+ const storedMessages = await getMessages(db, mixedId);
+ if (storedMessages && storedMessages.messages.length > 0) {
+ setInitialMessages(storedMessages.messages);
+ setUrlId(storedMessages.urlId);
+ description.set(storedMessages.description);
+ chatId.set(storedMessages.id);
+ } else {
+ navigate('/', { replace: true });
+ }
+ } catch (error) {
+ logger.error('Failed to load messages:', error);
navigate('/', { replace: true });
}
- } catch (error) {
- console.error('Failed to load messages:', error);
}
+
+ setReady(true);
+ } catch (error) {
+ logger.error('Failed to initialize:', error);
+ setReady(true);
}
- setReady(true);
};
initDb();
- }, [mixedId, navigate, dbInitialized]);
+ }, [mixedId, navigate]);
return {
ready: !mixedId || ready,
@@ -71,31 +86,31 @@ export function useChatHistory() {
return;
}
- const { firstArtifact } = workbenchStore;
+ try {
+ const { firstArtifact } = workbenchStore;
- if (!urlId && firstArtifact?.id) {
- const newUrlId = await getUrlId(db, firstArtifact.id);
- navigateChat(newUrlId);
- setUrlId(newUrlId);
- }
+ if (!urlId && firstArtifact?.id) {
+ const newUrlId = await getUrlId(db, firstArtifact.id);
+ navigateChat(newUrlId);
+ setUrlId(newUrlId);
+ }
- if (!description.get() && firstArtifact?.title) {
- description.set(firstArtifact?.title);
- }
+ if (!description.get() && firstArtifact?.title) {
+ description.set(firstArtifact?.title);
+ }
- if (initialMessages.length === 0 && !chatId.get()) {
- const nextId = await getNextId(db);
- chatId.set(nextId);
+ if (initialMessages.length === 0 && !chatId.get()) {
+ const nextId = await getNextId(db);
+ chatId.set(nextId);
- if (!urlId) {
- navigateChat(nextId);
+ if (!urlId) {
+ navigateChat(nextId);
+ }
}
- }
- try {
await setMessages(db, chatId.get() as string, messages, urlId, description.get());
} catch (error) {
- console.error('Failed to store messages:', error);
+ logger.error('Failed to store messages:', error);
}
},
};
From 02030cb87594517b4eb53d6efb6cf97440541f1d Mon Sep 17 00:00:00 2001
From: Dustin <155417613+vgcman16@users.noreply.github.com>
Date: Wed, 30 Oct 2024 18:01:57 -0500
Subject: [PATCH 06/12] fix: remove persistence error toast and improve error
handling
---
app/components/chat/Chat.client.tsx | 4 +---
1 file changed, 1 insertion(+), 3 deletions(-)
diff --git a/app/components/chat/Chat.client.tsx b/app/components/chat/Chat.client.tsx
index c5d0dbeed..a5d2ca786 100644
--- a/app/components/chat/Chat.client.tsx
+++ b/app/components/chat/Chat.client.tsx
@@ -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';
@@ -80,7 +80,6 @@ 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');
@@ -103,7 +102,6 @@ export const ChatImpl = memo(({ initialMessages, storeMessageHistory }: ChatProp
if (messages.length > initialMessages.length) {
storeMessageHistory(messages).catch((error) => {
logger.error('Failed to store message history:', error);
- // Don't show error toast for persistence failures
});
}
}, [messages, isLoading, parseMessages, storeMessageHistory, initialMessages.length]);
From fb156fa7beeb190a749f152933b3f9027e6d38de Mon Sep 17 00:00:00 2001
From: Dustin <155417613+vgcman16@users.noreply.github.com>
Date: Wed, 30 Oct 2024 18:06:46 -0500
Subject: [PATCH 07/12] fix: improve database initialization and error handling
---
app/lib/persistence/db.ts | 21 +++++++--
app/lib/persistence/useChatHistory.ts | 66 +++++++++++++--------------
2 files changed, 50 insertions(+), 37 deletions(-)
diff --git a/app/lib/persistence/db.ts b/app/lib/persistence/db.ts
index d12d7e5dc..1199ff7b8 100644
--- a/app/lib/persistence/db.ts
+++ b/app/lib/persistence/db.ts
@@ -5,6 +5,7 @@ import type { ChatHistoryItem } from './useChatHistory';
const logger = createScopedLogger('ChatHistory');
let dbInitAttempted = false;
+let dbInitializing = false;
function isBrowserEnvironment(): boolean {
try {
@@ -19,17 +20,19 @@ function isBrowserEnvironment(): boolean {
}
export async function openDatabase(): Promise {
- if (dbInitAttempted) {
- logger.debug('Database initialization already attempted');
+ if (dbInitAttempted || dbInitializing) {
+ logger.debug('Database initialization already attempted or in progress');
return undefined;
}
- dbInitAttempted = true;
+ dbInitializing = true;
return new Promise((resolve) => {
try {
if (!isBrowserEnvironment()) {
logger.debug('Not in browser environment or IndexedDB not available');
+ dbInitAttempted = true;
+ dbInitializing = false;
resolve(undefined);
return;
}
@@ -57,14 +60,20 @@ export async function openDatabase(): Promise {
const transaction = db.transaction(['chats'], 'readonly');
transaction.oncomplete = () => {
logger.debug('Database test successful');
+ dbInitAttempted = true;
+ dbInitializing = false;
resolve(db);
};
transaction.onerror = () => {
logger.error('Database test failed');
+ dbInitAttempted = true;
+ dbInitializing = false;
resolve(undefined);
};
} catch (error) {
logger.error('Error testing database:', error);
+ dbInitAttempted = true;
+ dbInitializing = false;
resolve(undefined);
}
};
@@ -72,16 +81,22 @@ export async function openDatabase(): Promise {
request.onerror = (event: Event) => {
const error = (event.target as IDBOpenDBRequest).error;
logger.error('Failed to open database:', error?.message || 'Unknown error');
+ dbInitAttempted = true;
+ dbInitializing = false;
resolve(undefined);
};
request.onblocked = () => {
logger.error('Database blocked');
+ dbInitAttempted = true;
+ dbInitializing = false;
resolve(undefined);
};
} catch (error) {
logger.error('Error initializing database:', error);
+ dbInitAttempted = true;
+ dbInitializing = false;
resolve(undefined);
}
});
diff --git a/app/lib/persistence/useChatHistory.ts b/app/lib/persistence/useChatHistory.ts
index 18bda021d..eddf17509 100644
--- a/app/lib/persistence/useChatHistory.ts
+++ b/app/lib/persistence/useChatHistory.ts
@@ -19,10 +19,29 @@ export interface ChatHistoryItem {
// Initialize database lazily when needed
let db: IDBDatabase | undefined;
let dbInitialized = false;
+let dbInitializing = false;
export const chatId = atom(undefined);
export const description = atom(undefined);
+async function initializeDb() {
+ if (dbInitialized || dbInitializing) {
+ return db;
+ }
+
+ dbInitializing = true;
+ try {
+ db = await openDatabase();
+ dbInitialized = true;
+ logger.debug('Database initialized successfully');
+ } catch (error) {
+ logger.error('Failed to initialize database:', error);
+ } finally {
+ dbInitializing = false;
+ }
+ return db;
+}
+
export function useChatHistory() {
const navigate = useNavigate();
const { id: mixedId } = useLoaderData<{ id?: string }>();
@@ -33,49 +52,28 @@ export function useChatHistory() {
// Initialize database when component mounts
useEffect(() => {
- const initDb = async () => {
+ const init = async () => {
try {
- // Only attempt to initialize once
- if (!dbInitialized) {
- logger.debug('Initializing database');
- db = await openDatabase();
- dbInitialized = true;
- }
-
- // If we have a mixedId but no database, navigate home
- if (mixedId && !db) {
- logger.debug('No database available, navigating home');
- navigate('/', { replace: true });
- setReady(true);
- return;
- }
-
- // If we have both mixedId and database, try to load messages
- if (mixedId && db) {
- try {
- const storedMessages = await getMessages(db, mixedId);
- if (storedMessages && storedMessages.messages.length > 0) {
- setInitialMessages(storedMessages.messages);
- setUrlId(storedMessages.urlId);
- description.set(storedMessages.description);
- chatId.set(storedMessages.id);
- } else {
- navigate('/', { replace: true });
- }
- } catch (error) {
- logger.error('Failed to load messages:', error);
+ const database = await initializeDb();
+
+ if (mixedId && database) {
+ const storedMessages = await getMessages(database, mixedId);
+ if (storedMessages && storedMessages.messages.length > 0) {
+ setInitialMessages(storedMessages.messages);
+ setUrlId(storedMessages.urlId);
+ description.set(storedMessages.description);
+ chatId.set(storedMessages.id);
+ } else {
navigate('/', { replace: true });
}
}
-
- setReady(true);
} catch (error) {
logger.error('Failed to initialize:', error);
- setReady(true);
}
+ setReady(true);
};
- initDb();
+ init();
}, [mixedId, navigate]);
return {
From 9b50acbbd6a344008b9e9a25ed9cd28d88b187cd Mon Sep 17 00:00:00 2001
From: Dustin <155417613+vgcman16@users.noreply.github.com>
Date: Wed, 30 Oct 2024 18:07:46 -0500
Subject: [PATCH 08/12] fix: remove persistence error toast and improve toast
handling
---
app/components/chat/Chat.client.tsx | 2 ++
1 file changed, 2 insertions(+)
diff --git a/app/components/chat/Chat.client.tsx b/app/components/chat/Chat.client.tsx
index a5d2ca786..008971819 100644
--- a/app/components/chat/Chat.client.tsx
+++ b/app/components/chat/Chat.client.tsx
@@ -54,6 +54,8 @@ export function Chat() {
position="bottom-right"
pauseOnFocusLoss
transition={toastAnimation}
+ hideProgressBar
+ autoClose={false}
/>
>
) : null;
From 12521cb855ae77e00f14f5e9ed647c6de4670455 Mon Sep 17 00:00:00 2001
From: Dustin <155417613+vgcman16@users.noreply.github.com>
Date: Wed, 30 Oct 2024 18:09:00 -0500
Subject: [PATCH 09/12] fix: initialize IndexedDB early to prevent persistence
errors
---
app/root.tsx | 7 +++++++
1 file changed, 7 insertions(+)
diff --git a/app/root.tsx b/app/root.tsx
index 31eb387e0..7d51ebc1f 100644
--- a/app/root.tsx
+++ b/app/root.tsx
@@ -50,6 +50,13 @@ const inlineThemeCode = stripIndents`
document.querySelector('html')?.setAttribute('data-theme', theme);
}
+
+ // Initialize IndexedDB early
+ if (typeof window !== 'undefined' && window.indexedDB) {
+ const request = window.indexedDB.open('boltHistory', 1);
+ request.onerror = () => console.error('Failed to initialize IndexedDB');
+ request.onsuccess = () => console.debug('IndexedDB initialized');
+ }
`;
export const Head = createHead(() => (
From 9555816d73fd0c9ea10dcc41c578ba45f8c08a46 Mon Sep 17 00:00:00 2001
From: Dustin <155417613+vgcman16@users.noreply.github.com>
Date: Wed, 30 Oct 2024 18:09:21 -0500
Subject: [PATCH 10/12] fix: ensure IndexedDB is initialized before app
hydration
---
app/entry.client.tsx | 33 +++++++++++++++++++++++++++++++--
1 file changed, 31 insertions(+), 2 deletions(-)
diff --git a/app/entry.client.tsx b/app/entry.client.tsx
index 62917e70d..9aa809020 100644
--- a/app/entry.client.tsx
+++ b/app/entry.client.tsx
@@ -2,6 +2,35 @@ import { RemixBrowser } from '@remix-run/react';
import { startTransition } from 'react';
import { hydrateRoot } from 'react-dom/client';
-startTransition(() => {
- hydrateRoot(document.getElementById('root')!, );
+// Initialize IndexedDB before hydration
+async function initIndexedDB() {
+ if (typeof window !== 'undefined' && window.indexedDB) {
+ return new Promise((resolve) => {
+ const request = window.indexedDB.open('boltHistory', 1);
+ request.onerror = () => {
+ console.error('Failed to initialize IndexedDB');
+ resolve(false);
+ };
+ request.onsuccess = () => {
+ console.debug('IndexedDB initialized');
+ resolve(true);
+ };
+ request.onupgradeneeded = (event) => {
+ const db = (event.target as IDBOpenDBRequest).result;
+ if (!db.objectStoreNames.contains('chats')) {
+ const store = db.createObjectStore('chats', { keyPath: 'id' });
+ store.createIndex('id', 'id', { unique: true });
+ store.createIndex('urlId', 'urlId', { unique: true });
+ }
+ };
+ });
+ }
+ return Promise.resolve(false);
+}
+
+// Initialize IndexedDB before hydrating the app
+initIndexedDB().then(() => {
+ startTransition(() => {
+ hydrateRoot(document.getElementById('root')!, );
+ });
});
From 0810433f23711c5f854c7b24238e8fddf05c0945 Mon Sep 17 00:00:00 2001
From: Dustin <155417613+vgcman16@users.noreply.github.com>
Date: Wed, 30 Oct 2024 18:13:12 -0500
Subject: [PATCH 11/12] fix: improve database initialization and error handling
---
app/lib/persistence/db.ts | 106 +++++++++++++++-----------
app/lib/persistence/useChatHistory.ts | 18 ++++-
2 files changed, 77 insertions(+), 47 deletions(-)
diff --git a/app/lib/persistence/db.ts b/app/lib/persistence/db.ts
index 1199ff7b8..bfbb6fcd9 100644
--- a/app/lib/persistence/db.ts
+++ b/app/lib/persistence/db.ts
@@ -37,62 +37,78 @@ export async function openDatabase(): Promise {
return;
}
- const request = indexedDB.open('boltHistory', 1);
+ // Test if we can actually open IndexedDB
+ const testRequest = window.indexedDB.open('test');
+ testRequest.onerror = () => {
+ logger.error('IndexedDB test failed');
+ dbInitAttempted = true;
+ dbInitializing = false;
+ resolve(undefined);
+ };
- request.onupgradeneeded = (event: IDBVersionChangeEvent) => {
- const db = (event.target as IDBOpenDBRequest).result;
- logger.debug('Upgrading database');
+ testRequest.onsuccess = () => {
+ // Close and delete test database
+ const db = testRequest.result;
+ db.close();
+ window.indexedDB.deleteDatabase('test');
- if (!db.objectStoreNames.contains('chats')) {
- const store = db.createObjectStore('chats', { keyPath: 'id' });
- store.createIndex('id', 'id', { unique: true });
- store.createIndex('urlId', 'urlId', { unique: true });
- logger.debug('Created chats store');
- }
- };
+ // Now open the actual database
+ const request = window.indexedDB.open('boltHistory', 1);
- request.onsuccess = (event: Event) => {
- const db = (event.target as IDBOpenDBRequest).result;
- logger.debug('Successfully opened database');
-
- // Test if we can actually use the database
- try {
- const transaction = db.transaction(['chats'], 'readonly');
- transaction.oncomplete = () => {
- logger.debug('Database test successful');
- dbInitAttempted = true;
- dbInitializing = false;
- resolve(db);
- };
- transaction.onerror = () => {
- logger.error('Database test failed');
+ request.onupgradeneeded = (event: IDBVersionChangeEvent) => {
+ const db = (event.target as IDBOpenDBRequest).result;
+ logger.debug('Upgrading database');
+
+ if (!db.objectStoreNames.contains('chats')) {
+ const store = db.createObjectStore('chats', { keyPath: 'id' });
+ store.createIndex('id', 'id', { unique: true });
+ store.createIndex('urlId', 'urlId', { unique: true });
+ logger.debug('Created chats store');
+ }
+ };
+
+ request.onsuccess = (event: Event) => {
+ const db = (event.target as IDBOpenDBRequest).result;
+ logger.debug('Successfully opened database');
+
+ // Test if we can actually use the database
+ try {
+ const transaction = db.transaction(['chats'], 'readonly');
+ transaction.oncomplete = () => {
+ logger.debug('Database test successful');
+ dbInitAttempted = true;
+ dbInitializing = false;
+ resolve(db);
+ };
+ transaction.onerror = () => {
+ logger.error('Database test failed');
+ dbInitAttempted = true;
+ dbInitializing = false;
+ resolve(undefined);
+ };
+ } catch (error) {
+ logger.error('Error testing database:', error);
dbInitAttempted = true;
dbInitializing = false;
resolve(undefined);
- };
- } catch (error) {
- logger.error('Error testing database:', error);
+ }
+ };
+
+ request.onerror = (event: Event) => {
+ const error = (event.target as IDBOpenDBRequest).error;
+ logger.error('Failed to open database:', error?.message || 'Unknown error');
dbInitAttempted = true;
dbInitializing = false;
resolve(undefined);
- }
- };
+ };
- request.onerror = (event: Event) => {
- const error = (event.target as IDBOpenDBRequest).error;
- logger.error('Failed to open database:', error?.message || 'Unknown error');
- dbInitAttempted = true;
- dbInitializing = false;
- resolve(undefined);
- };
-
- request.onblocked = () => {
- logger.error('Database blocked');
- dbInitAttempted = true;
- dbInitializing = false;
- resolve(undefined);
+ request.onblocked = () => {
+ logger.error('Database blocked');
+ dbInitAttempted = true;
+ dbInitializing = false;
+ resolve(undefined);
+ };
};
-
} catch (error) {
logger.error('Error initializing database:', error);
dbInitAttempted = true;
diff --git a/app/lib/persistence/useChatHistory.ts b/app/lib/persistence/useChatHistory.ts
index eddf17509..bf0a3244f 100644
--- a/app/lib/persistence/useChatHistory.ts
+++ b/app/lib/persistence/useChatHistory.ts
@@ -31,9 +31,23 @@ async function initializeDb() {
dbInitializing = true;
try {
+ // Check if we're in a browser environment
+ if (typeof window === 'undefined') {
+ logger.debug('Not in browser environment');
+ return undefined;
+ }
+
+ // Check if IndexedDB is available
+ if (!window.indexedDB) {
+ logger.debug('IndexedDB not available');
+ return undefined;
+ }
+
db = await openDatabase();
- dbInitialized = true;
- logger.debug('Database initialized successfully');
+ if (db) {
+ dbInitialized = true;
+ logger.debug('Database initialized successfully');
+ }
} catch (error) {
logger.error('Failed to initialize database:', error);
} finally {
From 9cc2cde57480141afb0f13a178f88e8b9f594fde Mon Sep 17 00:00:00 2001
From: Dustin <155417613+vgcman16@users.noreply.github.com>
Date: Wed, 30 Oct 2024 18:35:08 -0500
Subject: [PATCH 12/12] feat: improve IndexedDB persistence handling
- Add better browser environment detection
- Add database verification and testing
- Improve error handling and logging
- Add proper cleanup process
- Make persistence work reliably in Safari
- Handle Chrome-specific issues gracefully
- Add better state tracking and management
Note: For the best experience with persistence features, use Safari browser.
---
app/entry.client.tsx | 140 ++++++++++++++++++++++----
app/lib/persistence/useChatHistory.ts | 45 +++++++--
app/root.tsx | 85 +++++++++++++++-
3 files changed, 235 insertions(+), 35 deletions(-)
diff --git a/app/entry.client.tsx b/app/entry.client.tsx
index 9aa809020..36a3d6023 100644
--- a/app/entry.client.tsx
+++ b/app/entry.client.tsx
@@ -1,36 +1,134 @@
import { RemixBrowser } from '@remix-run/react';
import { startTransition } from 'react';
import { hydrateRoot } from 'react-dom/client';
+import { createScopedLogger } from '~/utils/logger';
+
+const logger = createScopedLogger('Client');
+
+function isChrome(): boolean {
+ return /Chrome/.test(navigator.userAgent) && /Google Inc/.test(navigator.vendor);
+}
// Initialize IndexedDB before hydration
async function initIndexedDB() {
- if (typeof window !== 'undefined' && window.indexedDB) {
- return new Promise((resolve) => {
- const request = window.indexedDB.open('boltHistory', 1);
- request.onerror = () => {
- console.error('Failed to initialize IndexedDB');
- resolve(false);
- };
- request.onsuccess = () => {
- console.debug('IndexedDB initialized');
- resolve(true);
- };
- request.onupgradeneeded = (event) => {
- const db = (event.target as IDBOpenDBRequest).result;
- if (!db.objectStoreNames.contains('chats')) {
- const store = db.createObjectStore('chats', { keyPath: 'id' });
- store.createIndex('id', 'id', { unique: true });
- store.createIndex('urlId', 'urlId', { unique: true });
- }
- };
- });
+ if (typeof window === 'undefined' || !window.indexedDB) {
+ logger.debug('IndexedDB not available');
+ window.__BOLT_PERSISTENCE_AVAILABLE__ = false;
+ return false;
}
- return Promise.resolve(false);
+
+ return new Promise((resolve) => {
+ try {
+ // For Chrome, we need to be more careful with initialization
+ if (isChrome()) {
+ // First, try to open a test database
+ const testRequest = window.indexedDB.open('test', 1);
+ testRequest.onerror = () => {
+ logger.error('Test database failed');
+ window.__BOLT_PERSISTENCE_AVAILABLE__ = false;
+ resolve(false);
+ };
+
+ testRequest.onsuccess = () => {
+ // Close and delete test database
+ const testDb = testRequest.result;
+ testDb.close();
+ const deleteRequest = window.indexedDB.deleteDatabase('test');
+
+ deleteRequest.onsuccess = () => {
+ // Now try to open the actual database
+ const request = window.indexedDB.open('boltHistory', 1);
+
+ request.onerror = () => {
+ logger.error('Failed to open database');
+ window.__BOLT_PERSISTENCE_AVAILABLE__ = false;
+ resolve(false);
+ };
+
+ request.onupgradeneeded = (event) => {
+ const db = (event.target as IDBOpenDBRequest).result;
+ if (!db.objectStoreNames.contains('chats')) {
+ const store = db.createObjectStore('chats', { keyPath: 'id' });
+ store.createIndex('id', 'id', { unique: true });
+ store.createIndex('urlId', 'urlId', { unique: true });
+ }
+ };
+
+ request.onsuccess = (event) => {
+ const db = (event.target as IDBOpenDBRequest).result;
+
+ // Test if we can actually use the database
+ try {
+ const transaction = db.transaction(['chats'], 'readonly');
+ transaction.oncomplete = () => {
+ logger.debug('Database test successful');
+ window.__BOLT_PERSISTENCE_AVAILABLE__ = true;
+ resolve(true);
+ };
+ transaction.onerror = () => {
+ logger.error('Database test failed');
+ window.__BOLT_PERSISTENCE_AVAILABLE__ = false;
+ resolve(false);
+ };
+ } catch (error) {
+ logger.error('Error testing database:', error);
+ window.__BOLT_PERSISTENCE_AVAILABLE__ = false;
+ resolve(false);
+ }
+ };
+ };
+
+ deleteRequest.onerror = () => {
+ logger.error('Failed to delete test database');
+ window.__BOLT_PERSISTENCE_AVAILABLE__ = false;
+ resolve(false);
+ };
+ };
+ } else {
+ // For other browsers, use the standard approach
+ const request = window.indexedDB.open('boltHistory', 1);
+ request.onerror = () => {
+ logger.error('Failed to open database');
+ window.__BOLT_PERSISTENCE_AVAILABLE__ = false;
+ resolve(false);
+ };
+
+ request.onupgradeneeded = (event) => {
+ const db = (event.target as IDBOpenDBRequest).result;
+ if (!db.objectStoreNames.contains('chats')) {
+ const store = db.createObjectStore('chats', { keyPath: 'id' });
+ store.createIndex('id', 'id', { unique: true });
+ store.createIndex('urlId', 'urlId', { unique: true });
+ }
+ };
+
+ request.onsuccess = () => {
+ logger.debug('Database initialized');
+ window.__BOLT_PERSISTENCE_AVAILABLE__ = true;
+ resolve(true);
+ };
+ }
+ } catch (error) {
+ logger.error('Error initializing database:', error);
+ window.__BOLT_PERSISTENCE_AVAILABLE__ = false;
+ resolve(false);
+ }
+ });
}
+// Set initial persistence state
+window.__BOLT_PERSISTENCE_AVAILABLE__ = false;
+
// Initialize IndexedDB before hydrating the app
initIndexedDB().then(() => {
startTransition(() => {
hydrateRoot(document.getElementById('root')!, );
});
});
+
+// Add type declaration
+declare global {
+ interface Window {
+ __BOLT_PERSISTENCE_AVAILABLE__: boolean;
+ }
+}
diff --git a/app/lib/persistence/useChatHistory.ts b/app/lib/persistence/useChatHistory.ts
index bf0a3244f..b3ac5e6cf 100644
--- a/app/lib/persistence/useChatHistory.ts
+++ b/app/lib/persistence/useChatHistory.ts
@@ -37,9 +37,9 @@ async function initializeDb() {
return undefined;
}
- // Check if IndexedDB is available
- if (!window.indexedDB) {
- logger.debug('IndexedDB not available');
+ // Check if persistence is available
+ if (!window.__BOLT_PERSISTENCE_AVAILABLE__) {
+ logger.debug('Persistence not available');
return undefined;
}
@@ -68,23 +68,39 @@ export function useChatHistory() {
useEffect(() => {
const init = async () => {
try {
+ // Always try to initialize the database
const database = await initializeDb();
+
+ // If we have a mixedId but no database, navigate home silently
+ if (mixedId && !database) {
+ navigate('/', { replace: true });
+ setReady(true);
+ return;
+ }
+ // If we have both mixedId and database, try to load messages
if (mixedId && database) {
- const storedMessages = await getMessages(database, mixedId);
- if (storedMessages && storedMessages.messages.length > 0) {
- setInitialMessages(storedMessages.messages);
- setUrlId(storedMessages.urlId);
- description.set(storedMessages.description);
- chatId.set(storedMessages.id);
- } else {
+ try {
+ const storedMessages = await getMessages(database, mixedId);
+ if (storedMessages && storedMessages.messages.length > 0) {
+ setInitialMessages(storedMessages.messages);
+ setUrlId(storedMessages.urlId);
+ description.set(storedMessages.description);
+ chatId.set(storedMessages.id);
+ } else {
+ navigate('/', { replace: true });
+ }
+ } catch (error) {
+ logger.error('Failed to load messages:', error);
navigate('/', { replace: true });
}
}
+
+ setReady(true);
} catch (error) {
logger.error('Failed to initialize:', error);
+ setReady(true);
}
- setReady(true);
};
init();
@@ -133,3 +149,10 @@ function navigateChat(nextId: string) {
url.pathname = `/chat/${nextId}`;
window.history.replaceState({}, '', url);
}
+
+// Add type declaration
+declare global {
+ interface Window {
+ __BOLT_PERSISTENCE_AVAILABLE__: boolean;
+ }
+}
diff --git a/app/root.tsx b/app/root.tsx
index 7d51ebc1f..c653d358c 100644
--- a/app/root.tsx
+++ b/app/root.tsx
@@ -53,9 +53,81 @@ const inlineThemeCode = stripIndents`
// Initialize IndexedDB early
if (typeof window !== 'undefined' && window.indexedDB) {
- const request = window.indexedDB.open('boltHistory', 1);
- request.onerror = () => console.error('Failed to initialize IndexedDB');
- request.onsuccess = () => console.debug('IndexedDB initialized');
+ window.__BOLT_PERSISTENCE_AVAILABLE__ = false;
+
+ // Check if we're in Chrome
+ const isChrome = /Chrome/.test(navigator.userAgent) && /Google Inc/.test(navigator.vendor);
+
+ if (isChrome) {
+ // For Chrome, we need to be more careful with initialization
+ const testRequest = window.indexedDB.open('test', 1);
+ testRequest.onerror = () => {
+ window.__BOLT_PERSISTENCE_AVAILABLE__ = false;
+ };
+
+ testRequest.onsuccess = () => {
+ // Close and delete test database
+ const testDb = testRequest.result;
+ testDb.close();
+ const deleteRequest = window.indexedDB.deleteDatabase('test');
+
+ deleteRequest.onsuccess = () => {
+ // Now try to open the actual database
+ const request = window.indexedDB.open('boltHistory', 1);
+
+ request.onupgradeneeded = (event) => {
+ const db = event.target.result;
+ if (!db.objectStoreNames.contains('chats')) {
+ const store = db.createObjectStore('chats', { keyPath: 'id' });
+ store.createIndex('id', 'id', { unique: true });
+ store.createIndex('urlId', 'urlId', { unique: true });
+ }
+ };
+
+ request.onsuccess = (event) => {
+ const db = event.target.result;
+
+ // Test if we can actually use the database
+ try {
+ const transaction = db.transaction(['chats'], 'readonly');
+ transaction.oncomplete = () => {
+ window.__BOLT_PERSISTENCE_AVAILABLE__ = true;
+ };
+ transaction.onerror = () => {
+ window.__BOLT_PERSISTENCE_AVAILABLE__ = false;
+ };
+ } catch (error) {
+ window.__BOLT_PERSISTENCE_AVAILABLE__ = false;
+ }
+ };
+
+ request.onerror = () => {
+ window.__BOLT_PERSISTENCE_AVAILABLE__ = false;
+ };
+ };
+
+ deleteRequest.onerror = () => {
+ window.__BOLT_PERSISTENCE_AVAILABLE__ = false;
+ };
+ };
+ } else {
+ // For other browsers, use the standard approach
+ const request = window.indexedDB.open('boltHistory', 1);
+ request.onupgradeneeded = (event) => {
+ const db = event.target.result;
+ if (!db.objectStoreNames.contains('chats')) {
+ const store = db.createObjectStore('chats', { keyPath: 'id' });
+ store.createIndex('id', 'id', { unique: true });
+ store.createIndex('urlId', 'urlId', { unique: true });
+ }
+ };
+ request.onsuccess = () => {
+ window.__BOLT_PERSISTENCE_AVAILABLE__ = true;
+ };
+ request.onerror = () => {
+ window.__BOLT_PERSISTENCE_AVAILABLE__ = false;
+ };
+ }
}
`;
@@ -88,3 +160,10 @@ export function Layout({ children }: { children: React.ReactNode }) {
export default function App() {
return ;
}
+
+// Add type declaration
+declare global {
+ interface Window {
+ __BOLT_PERSISTENCE_AVAILABLE__: boolean;
+ }
+}