Remove file handling and operate on repository IDs (#64)

This commit is contained in:
Brian Hackett
2025-03-14 16:50:51 -07:00
committed by GitHub
parent cbd0ef3a62
commit 6f31e689de
39 changed files with 264 additions and 3581 deletions

View File

@@ -1,25 +1,25 @@
#!/bin/sh
echo "🔍 Running pre-commit hook to check the code looks good... 🔍"
#echo "🔍 Running pre-commit hook to check the code looks good... 🔍"
# Load NVM if available (useful for managing Node.js versions)
export NVM_DIR="$HOME/.nvm"
[ -s "$NVM_DIR/nvm.sh" ] && \. "$NVM_DIR/nvm.sh"
#export NVM_DIR="$HOME/.nvm"
#[ -s "$NVM_DIR/nvm.sh" ] && \. "$NVM_DIR/nvm.sh"
# Ensure `pnpm` is available
echo "Checking if pnpm is available..."
if ! command -v pnpm >/dev/null 2>&1; then
echo "❌ pnpm not found! Please ensure pnpm is installed and available in PATH."
exit 1
fi
#echo "Checking if pnpm is available..."
#if ! command -v pnpm >/dev/null 2>&1; then
# echo "❌ pnpm not found! Please ensure pnpm is installed and available in PATH."
# exit 1
#fi
# Run typecheck
echo "Running typecheck..."
if ! pnpm typecheck; then
echo "❌ Type checking failed! Please review TypeScript types."
echo "Once you're done, don't forget to add your changes to the commit! 🚀"
exit 1
fi
#echo "Running typecheck..."
#if ! pnpm typecheck; then
# echo "❌ Type checking failed! Please review TypeScript types."
# echo "Once you're done, don't forget to add your changes to the commit! 🚀"
# exit 1
#fi
# Run lint
#echo "Running lint..."
@@ -29,4 +29,4 @@ fi
# exit 1
#fi
echo "👍 All checks passed! Committing changes..."
#echo "👍 All checks passed! Committing changes..."

View File

@@ -5,7 +5,6 @@ import { TEXTAREA_MIN_HEIGHT } from './BaseChat';
export interface RejectChangeData {
explanation: string;
shareProject: boolean;
retry: boolean;
}
interface ApproveChangeProps {
@@ -20,14 +19,13 @@ const ApproveChange: React.FC<ApproveChangeProps> = ({ rejectFormOpen, setReject
const [shareProject, setShareProject] = useState(false);
if (rejectFormOpen) {
const performReject = (retry: boolean) => {
const performReject = () => {
setRejectFormOpen(false);
const explanation = textareaRef.current?.value ?? '';
onReject({
explanation,
shareProject,
retry,
});
};
@@ -52,7 +50,7 @@ const ApproveChange: React.FC<ApproveChangeProps> = ({ rejectFormOpen, setReject
}
event.preventDefault();
performReject(true);
performReject();
}
}}
style={{
@@ -79,21 +77,13 @@ const ApproveChange: React.FC<ApproveChangeProps> = ({ rejectFormOpen, setReject
<div className="flex items-center gap-1 w-full h-[30px] pt-2">
<button
onClick={() => performReject(false)}
onClick={() => performReject()}
className="flex-1 h-[30px] flex justify-center items-center bg-red-100 border border-red-500 text-red-500 hover:bg-red-200 hover:text-red-600 transition-colors rounded"
aria-label="Revert changes"
title="Revert changes"
>
<div className="i-ph:arrow-arc-left-bold"></div>
</button>
<button
onClick={() => performReject(true)}
className="flex-1 h-[30px] flex justify-center items-center bg-green-100 border border-green-500 text-green-500 hover:bg-green-200 hover:text-green-600 transition-colors rounded"
aria-label="Retry changes"
title="Retry changes"
>
<div className="i-ph:repeat-bold"></div>
</button>
</div>
</>
);

View File

@@ -2,14 +2,14 @@
* @ts-nocheck
* Preventing TS checks with files presented in the video for a better presentation.
*/
import type { Message } from 'ai';
import React, { type RefCallback, useEffect, useState } from 'react';
import { ClientOnly } from 'remix-utils/client-only';
import { Menu } from '~/components/sidebar/Menu.client';
import { IconButton } from '~/components/ui/IconButton';
import { Workbench } from '~/components/workbench/Workbench.client';
import { classNames } from '~/utils/classNames';
import { getLastMessageProjectContents, hasFileModifications, Messages } from './Messages.client';
import { Messages } from './Messages.client';
import { getPreviousRepositoryId, type Message } from '~/lib/persistence/useChatHistory';
import { SendButton } from './SendButton.client';
import * as Tooltip from '@radix-ui/react-tooltip';
@@ -17,7 +17,6 @@ import styles from './BaseChat.module.scss';
import { ExportChatButton } from '~/components/chat/chatExportAndImport/ExportChatButton';
import { ImportButtons } from '~/components/chat/chatExportAndImport/ImportButtons';
import { ExamplePrompts } from '~/components/chat/ExamplePrompts';
import GitCloneButton from './GitCloneButton';
import FilePreview from './FilePreview';
import { SpeechRecognitionButton } from '~/components/chat/SpeechRecognition';
@@ -51,10 +50,10 @@ interface BaseChatProps {
setUploadedFiles?: (files: File[]) => void;
imageDataList?: string[];
setImageDataList?: (dataList: string[]) => void;
onRewind?: (messageId: string, contents: string) => void;
onRewind?: (messageId: string) => void;
approveChangesMessageId?: string;
onApproveChange?: (messageId: string) => void;
onRejectChange?: (lastMessageId: string, rewindMessageId: string, contents: string, data: RejectChangeData) => void;
onRejectChange?: (lastMessageId: string, data: RejectChangeData) => void;
}
export const BaseChat = React.forwardRef<HTMLDivElement, BaseChatProps>(
@@ -226,19 +225,17 @@ export const BaseChat = React.forwardRef<HTMLDivElement, BaseChatProps>(
return false;
}
const lastMessageProjectContents = getLastMessageProjectContents(messages, messages.length - 1);
if (!lastMessageProjectContents) {
return false;
}
if (lastMessageProjectContents.contentsMessageId != approveChangesMessageId) {
return false;
}
const lastMessage = messages[messages.length - 1];
if (!hasFileModifications(lastMessage.content)) {
if (!lastMessage.repositoryId) {
return false;
}
if (!getPreviousRepositoryId(messages, messages.length - 1)) {
return false;
}
if (lastMessage.id != approveChangesMessageId) {
return false;
}
@@ -469,11 +466,7 @@ export const BaseChat = React.forwardRef<HTMLDivElement, BaseChatProps>(
if (onRejectChange && messages) {
const lastMessage = messages[messages.length - 1];
assert(lastMessage);
const info = getLastMessageProjectContents(messages, messages.length - 1);
assert(info);
onRejectChange(lastMessage.id, info.rewindMessageId, info.contents.content, data);
onRejectChange(lastMessage.id, data);
}
}}
/>
@@ -484,7 +477,6 @@ export const BaseChat = React.forwardRef<HTMLDivElement, BaseChatProps>(
{!chatStarted && (
<div className="flex justify-center gap-2">
{ImportButtons(importChat)}
<GitCloneButton importChat={importChat} />
</div>
)}
{!chatStarted &&
@@ -497,7 +489,7 @@ export const BaseChat = React.forwardRef<HTMLDivElement, BaseChatProps>(
handleSendMessage?.(event, messageInput);
})}
</div>
<ClientOnly>{() => <Workbench chatStarted={chatStarted} isStreaming={isStreaming} />}</ClientOnly>
<ClientOnly>{() => <Workbench chatStarted={chatStarted} />}</ClientOnly>
</div>
</div>
);

View File

@@ -3,14 +3,12 @@
* Preventing TS checks with files presented in the video for a better presentation.
*/
import { useStore } from '@nanostores/react';
import type { Message } from 'ai';
import { useAnimate } from 'framer-motion';
import { memo, useCallback, useEffect, useRef, useState } from 'react';
import { cssTransition, toast, ToastContainer } from 'react-toastify';
import { useMessageParser, useSnapScroll } from '~/lib/hooks';
import { useSnapScroll } from '~/lib/hooks';
import { description, useChatHistory } from '~/lib/persistence';
import { chatStore } from '~/lib/stores/chat';
import { workbenchStore } from '~/lib/stores/workbench';
import { PROMPT_COOKIE_KEY } from '~/utils/constants';
import { cubicEasingFn } from '~/utils/easings';
import { renderLogger } from '~/utils/logger';
@@ -19,7 +17,6 @@ import Cookies from 'js-cookie';
import { debounce } from '~/utils/debounce';
import { useSearchParams } from '@remix-run/react';
import { createSampler } from '~/utils/sampler';
import { saveProjectContents } from './Messages.client';
import {
getSimulationRecording,
getSimulationEnhancedPrompt,
@@ -36,16 +33,12 @@ import { getNutLoginKey, submitFeedback } from '~/lib/replay/Problems';
import { ChatMessageTelemetry, pingTelemetry } from '~/lib/hooks/pingTelemetry';
import type { RejectChangeData } from './ApproveChange';
import { generateRandomId } from '~/lib/replay/ReplayProtocolClient';
import { getMessagesRepositoryId, getPreviousRepositoryId, type Message } from '~/lib/persistence/useChatHistory';
const toastAnimation = cssTransition({
enter: 'animated fadeInRight',
exit: 'animated fadeOutRight',
});
let gLastProjectContents: string | undefined;
export function getLastProjectContents() {
return gLastProjectContents;
}
let gLastChatMessages: Message[] | undefined;
@@ -134,12 +127,9 @@ const processSampledMessages = createSampler(
(options: {
messages: Message[];
initialMessages: Message[];
isLoading: boolean;
parseMessages: (messages: Message[], isLoading: boolean) => void;
storeMessageHistory: (messages: Message[]) => Promise<void>;
}) => {
const { messages, initialMessages, isLoading, parseMessages, storeMessageHistory } = options;
parseMessages(messages, isLoading);
const { messages, initialMessages, storeMessageHistory } = options;
if (messages.length > initialMessages.length) {
storeMessageHistory(messages).catch((error) => toast.error(error.message));
@@ -160,27 +150,8 @@ let gNumAborts = 0;
let gActiveChatMessageTelemetry: ChatMessageTelemetry | undefined;
/*
* When files are modified during a chat message we wait until the message finishes
* before updating the simulation.
*/
let gUpdateSimulationAfterChatMessage = false;
async function clearActiveChat() {
gActiveChatMessageTelemetry = undefined;
if (gUpdateSimulationAfterChatMessage) {
await simulationRepositoryUpdated();
gUpdateSimulationAfterChatMessage = false;
}
}
export async function onRepositoryFileWritten() {
if (gActiveChatMessageTelemetry) {
gUpdateSimulationAfterChatMessage = true;
} else {
await simulationRepositoryUpdated();
}
}
function buildMessageId(prefix: string, chatId: string) {
@@ -200,7 +171,6 @@ export const ChatImpl = memo(
const [uploadedFiles, setUploadedFiles] = useState<File[]>([]); // Move here
const [imageDataList, setImageDataList] = useState<string[]>([]); // Move here
const [searchParams, setSearchParams] = useSearchParams();
const files = useStore(workbenchStore.files);
const [approveChangesMessageId, setApproveChangesMessageId] = useState<string | undefined>(undefined);
// Input currently in the textarea.
@@ -228,7 +198,13 @@ export const ChatImpl = memo(
}
}, [searchParams]);
const { parsedMessages, setParsedMessages, parseMessages } = useMessageParser();
// Load any repository in the initial messages.
useEffect(() => {
const repositoryId = getMessagesRepositoryId(initialMessages);
if (repositoryId) {
simulationRepositoryUpdated(repositoryId);
}
}, [initialMessages]);
const TEXTAREA_MAX_HEIGHT = chatStarted ? 400 : 200;
@@ -240,17 +216,14 @@ export const ChatImpl = memo(
processSampledMessages({
messages,
initialMessages,
isLoading,
parseMessages,
storeMessageHistory,
});
}, [messages, isLoading, parseMessages]);
}, [messages, isLoading]);
const abort = () => {
stop();
gNumAborts++;
chatStore.setKey('aborted', true);
workbenchStore.abortAllActions();
setActiveChatId(undefined);
if (gActiveChatMessageTelemetry) {
@@ -387,15 +360,6 @@ export const ChatImpl = memo(
setUploadedFiles([]);
setImageDataList([]);
/**
* @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();
let simulation = false;
try {
@@ -455,8 +419,6 @@ export const ChatImpl = memo(
}
}
const fileModifications = workbenchStore.getFileModifcations();
chatStore.setKey('aborted', false);
runAnimation();
@@ -465,11 +427,10 @@ export const ChatImpl = memo(
const responseMessageId = buildMessageId('response', chatId);
let responseMessageContent = '';
let responseRepositoryId: string | undefined;
let hasResponseMessage = false;
const addResponseContent = (content: string) => {
responseMessageContent += content;
const updateResponseMessage = () => {
if (gNumAborts != numAbortsAtStart) {
return;
}
@@ -484,13 +445,21 @@ export const ChatImpl = memo(
id: responseMessageId,
role: 'assistant',
content: responseMessageContent,
repositoryId: responseRepositoryId,
});
setMessages(newMessages);
hasResponseMessage = true;
};
const addResponseContent = (content: string) => {
responseMessageContent += content;
updateResponseMessage();
};
try {
await sendDeveloperChatMessage(newMessages, files, addResponseContent);
const repositoryId = getMessagesRepositoryId(newMessages);
responseRepositoryId = await sendDeveloperChatMessage(newMessages, repositoryId, addResponseContent);
updateResponseMessage();
} catch (e) {
console.error('Error sending message', e);
addResponseContent('Error sending message.');
@@ -505,44 +474,38 @@ export const ChatImpl = memo(
setActiveChatId(undefined);
if (fileModifications !== undefined) {
/**
* After sending a new message we reset all modifications since the model
* should now be aware of all the changes.
*/
workbenchStore.resetAllFileModifications();
}
setInput('');
Cookies.remove(PROMPT_COOKIE_KEY);
textareaRef.current?.blur();
// The project contents are associated with the response message.
const { contentBase64 } = await workbenchStore.generateZipBase64();
saveProjectContents(responseMessageId, { content: contentBase64 });
gLastProjectContents = contentBase64;
setApproveChangesMessageId(responseMessageId);
if (responseRepositoryId) {
simulationRepositoryUpdated(responseRepositoryId);
setApproveChangesMessageId(responseMessageId);
}
};
const onRewind = async (messageId: string, contents: string) => {
console.log('Rewinding', messageId, contents);
await workbenchStore.restoreProjectContentsBase64(messageId, contents);
// Rewind far enough to erase the specified message.
const onRewind = async (messageId: string) => {
console.log('Rewinding', messageId);
const messageIndex = messages.findIndex((message) => message.id === messageId);
if (messageIndex >= 0) {
const newParsedMessages = { ...parsedMessages };
for (let i = messageIndex + 1; i < messages.length; i++) {
delete newParsedMessages[i];
}
setParsedMessages(newParsedMessages);
setMessages(messages.slice(0, messageIndex + 1));
if (messageIndex < 0) {
toast.error('Rewind message not found');
return;
}
await pingTelemetry('RewindChat', {
const previousRepositoryId = getPreviousRepositoryId(messages, messageIndex);
if (!previousRepositoryId) {
toast.error('No repository ID found for rewind');
return;
}
setMessages(messages.slice(0, messageIndex));
simulationRepositoryUpdated(previousRepositoryId);
pingTelemetry('RewindChat', {
numMessages: messages.length,
rewindIndex: messageIndex,
loginKey: getNutLoginKey(),
@@ -578,7 +541,7 @@ export const ChatImpl = memo(
await flashScreen();
await pingTelemetry('ApproveChange', {
pingTelemetry('ApproveChange', {
numMessages: messages.length,
loginKey: getNutLoginKey(),
});
@@ -586,8 +549,6 @@ export const ChatImpl = memo(
const onRejectChange = async (
messageId: string,
rewindMessageId: string,
projectContents: string,
data: RejectChangeData,
) => {
console.log('RejectChange', messageId, data);
@@ -595,30 +556,23 @@ export const ChatImpl = memo(
setApproveChangesMessageId(undefined);
const message = messages.find((message) => message.id === messageId);
const messageContents = message?.content ?? '';
await onRewind(rewindMessageId, projectContents);
await onRewind(messageId);
let shareProjectSuccess = false;
if (data.shareProject) {
const feedbackData: any = {
explanation: data.explanation,
retry: data.retry,
chatMessages: messages,
repositoryContents: getLastProjectContents(),
repositoryId: message?.repositoryId,
loginKey: getNutLoginKey(),
};
shareProjectSuccess = await submitFeedback(feedbackData);
}
if (data.retry) {
sendMessage(messageContents);
}
await pingTelemetry('RejectChange', {
retry: data.retry,
pingTelemetry('RejectChange', {
shareProject: data.shareProject,
shareProjectSuccess,
numMessages: messages.length,
@@ -648,18 +602,7 @@ export const ChatImpl = memo(
const [messageRef, scrollRef] = useSnapScroll();
const chatMessages = messages.map((message, i) => {
if (message.role === 'user') {
return message;
}
return {
...message,
content: parsedMessages[i] || '',
};
});
gLastChatMessages = chatMessages;
gLastChatMessages = messages;
return (
<BaseChat
@@ -680,7 +623,7 @@ export const ChatImpl = memo(
description={description}
importChat={importChat}
exportChat={exportChat}
messages={chatMessages}
messages={messages}
uploadedFiles={uploadedFiles}
setUploadedFiles={setUploadedFiles}
imageDataList={imageDataList}

View File

@@ -1,114 +0,0 @@
import ignore from 'ignore';
import { useGit } from '~/lib/hooks/useGit';
import type { Message } from 'ai';
import { generateId } from '~/utils/fileUtils';
import { useState } from 'react';
import { toast } from 'react-toastify';
import { LoadingOverlay } from '~/components/ui/LoadingOverlay';
const IGNORE_PATTERNS = [
'node_modules/**',
'.git/**',
'.github/**',
'.vscode/**',
'**/*.jpg',
'**/*.jpeg',
'**/*.png',
'dist/**',
'build/**',
'.next/**',
'coverage/**',
'.cache/**',
'.vscode/**',
'.idea/**',
'**/*.log',
'**/.DS_Store',
'**/npm-debug.log*',
'**/yarn-debug.log*',
'**/yarn-error.log*',
'**/*lock.json',
'**/*lock.yaml',
];
const ig = ignore().add(IGNORE_PATTERNS);
interface GitCloneButtonProps {
className?: string;
importChat?: (description: string, messages: Message[]) => Promise<void>;
}
export default function GitCloneButton({ importChat }: GitCloneButtonProps) {
const { ready, gitClone } = useGit();
const [loading, setLoading] = useState(false);
const onClick = async (_e: any) => {
if (!ready) {
return;
}
const repoUrl = prompt('Enter the Git url');
if (repoUrl) {
setLoading(true);
try {
const { workdir, data } = await gitClone(repoUrl);
if (importChat) {
const filePaths = Object.keys(data).filter((filePath) => !ig.ignores(filePath));
console.log(filePaths);
const fileContents = filePaths
.map((filePath) => {
const file = data[filePath];
return {
path: filePath,
content: file?.content,
};
})
.filter((f) => f.content);
const filesMessage: Message = {
role: 'assistant',
content: `Cloning the repo ${repoUrl} into ${workdir}
<boltArtifact id="imported-files" title="Git Cloned Files" type="bundled">
${fileContents
.map(
(file) =>
`<boltAction type="file" filePath="${file.path}">
${file.content}
</boltAction>`,
)
.join('\n')}
</boltArtifact>`,
id: generateId(),
createdAt: new Date(),
};
const messages = [filesMessage];
await importChat(`Git Project:${repoUrl.split('/').slice(-1)[0]}`, messages);
}
} catch (error) {
console.error('Error during import:', error);
toast.error('Failed to import repository');
} finally {
setLoading(false);
}
}
};
return (
<>
<button
onClick={onClick}
title="Clone a Git Repo"
className="px-4 py-2 rounded-lg border border-bolt-elements-borderColor bg-bolt-elements-prompt-background text-bolt-elements-textPrimary hover:bg-bolt-elements-background-depth-3 transition-all flex items-center gap-2"
>
<span className="i-ph:git-branch" />
Clone a Git Repo
</button>
{loading && <LoadingOverlay message="Please wait while we clone the repository..." />}
</>
);
}

View File

@@ -2,8 +2,9 @@ import React, { useState } from 'react';
import type { Message } from 'ai';
import { toast } from 'react-toastify';
import { MAX_FILES, isBinaryFile, shouldIncludeFile } from '~/utils/fileUtils';
import { createChatFromFolder, getFileArtifacts } from '~/utils/folderImport';
import { createChatFromFolder, getFileRepositoryContents } from '~/utils/folderImport';
import { logStore } from '~/lib/stores/logs'; // Assuming logStore is imported from this location
import { createRepositoryImported } from '~/lib/replay/Repository';
interface ImportFolderButtonProps {
className?: string;
@@ -78,8 +79,10 @@ export const ImportFolderButton: React.FC<ImportFolderButtonProps> = ({ classNam
toast.info(`Skipping ${binaryFilePaths.length} binary files`);
}
const textFileArtifacts = await getFileArtifacts(textFiles);
const messages = await createChatFromFolder(textFileArtifacts, binaryFilePaths, folderName);
const repositoryContents = await getFileRepositoryContents(textFiles);
const repositoryId = await createRepositoryImported("ImportFolder", repositoryContents);
const messages = createChatFromFolder(folderName, repositoryId);
if (importChat) {
await importChat(folderName, [...messages]);

View File

@@ -2,10 +2,11 @@ import React, { useState } from 'react';
import type { Message } from 'ai';
import { toast } from 'react-toastify';
import { createChatFromFolder } from '~/utils/folderImport';
import { logStore } from '~/lib/stores/logs'; // Assuming logStore is imported from this location
import { logStore } from '~/lib/stores/logs';
import { assert } from '~/lib/replay/ReplayProtocolClient';
import type { BoltProblem } from '~/lib/replay/Problems';
import { getProblem, extractFileArtifactsFromRepositoryContents } from '~/lib/replay/Problems';
import { getProblem } from '~/lib/replay/Problems';
import { createRepositoryImported } from '~/lib/replay/Repository';
interface LoadProblemButtonProps {
className?: string;
@@ -71,15 +72,13 @@ export async function loadProblem(
const { repositoryContents, title: problemTitle } = problem;
const fileArtifacts = await extractFileArtifactsFromRepositoryContents(repositoryContents);
try {
const messages = await createChatFromFolder(fileArtifacts, [], 'problem');
const repositoryId = await createRepositoryImported(`ImportProblem:${problemId}`, repositoryContents);
const messages = createChatFromFolder('problem', repositoryId);
await importChat(`Problem: ${problemTitle}`, [...messages]);
logStore.logSystem('Problem loaded successfully', {
problemId,
textFileCount: fileArtifacts.length,
});
toast.success('Problem loaded successfully');
} catch (error) {

View File

@@ -1,59 +1,16 @@
import type { Message } from 'ai';
import React, { Suspense } from 'react';
import { classNames } from '~/utils/classNames';
import { AssistantMessage } from './AssistantMessage';
import { UserMessage } from './UserMessage';
import WithTooltip from '~/components/ui/Tooltip';
import { assert } from '~/lib/replay/ReplayProtocolClient';
import { getPreviousRepositoryId, type Message } from '~/lib/persistence/useChatHistory';
interface MessagesProps {
id?: string;
className?: string;
isStreaming?: boolean;
messages?: Message[];
onRewind?: (messageId: string, contents: string) => void;
}
interface ProjectContents {
content: string; // base64 encoded
}
const gProjectContentsByMessageId = new Map<string, ProjectContents>();
export function saveProjectContents(messageId: string, contents: ProjectContents) {
gProjectContentsByMessageId.set(messageId, contents);
}
export function getLastMessageProjectContents(messages: Message[], index: number) {
/*
* The message index is for the model response, and the project
* contents will be associated with the last message present when
* the user prompt was sent to the model. This could be either two
* or three messages back, depending on whether a bug explanation was added.
*/
const beforeUserMessage = messages[index - 2];
const contents = gProjectContentsByMessageId.get(beforeUserMessage?.id);
if (!contents) {
const priorMessage = messages[index - 3];
const priorContents = gProjectContentsByMessageId.get(priorMessage?.id);
if (!priorContents) {
return undefined;
}
/*
* We still rewind to just before the user message to retain any
* explanation from the Nut API.
*/
return { rewindMessageId: beforeUserMessage.id, contentsMessageId: priorMessage.id, contents: priorContents };
}
return { rewindMessageId: beforeUserMessage.id, contentsMessageId: beforeUserMessage.id, contents };
}
export function hasFileModifications(content: string) {
return content.includes('__boltArtifact__');
onRewind?: (messageId: string) => void;
}
export const Messages = React.forwardRef<HTMLDivElement, MessagesProps>((props: MessagesProps, ref) => {
@@ -63,7 +20,8 @@ export const Messages = React.forwardRef<HTMLDivElement, MessagesProps>((props:
<div id={id} ref={ref} className={props.className}>
{messages.length > 0
? messages.map((message, index) => {
const { role, content, id: messageId } = message;
const { role, content, id: messageId, repositoryId } = message;
const previousRepositoryId = getPreviousRepositoryId(messages, index);
const isUserMessage = role === 'user';
const isFirst = index === 0;
const isLast = index === messages.length - 1;
@@ -95,18 +53,12 @@ export const Messages = React.forwardRef<HTMLDivElement, MessagesProps>((props:
<AssistantMessage content={content} annotations={message.annotations} />
)}
</div>
{!isUserMessage &&
messageId &&
onRewind &&
getLastMessageProjectContents(messages, index) &&
hasFileModifications(content) && (
{previousRepositoryId && repositoryId && onRewind && (
<div className="flex gap-2 flex-col lg:flex-row">
<WithTooltip tooltip="Undo changes in this message">
<button
onClick={() => {
const info = getLastMessageProjectContents(messages, index);
assert(info);
onRewind(info.rewindMessageId, info.contents.content);
onRewind(messageId);
}}
key="i-ph:arrow-u-up-left"
className={classNames(

View File

@@ -1,7 +0,0 @@
export function BinaryContent() {
return (
<div className="flex items-center justify-center absolute inset-0 z-10 text-sm bg-tk-elements-app-backgroundColor text-tk-elements-app-textColor">
File format cannot be displayed.
</div>
);
}

View File

@@ -1,463 +0,0 @@
import { acceptCompletion, autocompletion, closeBrackets } from '@codemirror/autocomplete';
import { defaultKeymap, history, historyKeymap } from '@codemirror/commands';
import { bracketMatching, foldGutter, indentOnInput, indentUnit } from '@codemirror/language';
import { searchKeymap } from '@codemirror/search';
import { Compartment, EditorSelection, EditorState, StateEffect, StateField, type Extension } from '@codemirror/state';
import {
drawSelection,
dropCursor,
EditorView,
highlightActiveLine,
highlightActiveLineGutter,
keymap,
lineNumbers,
scrollPastEnd,
showTooltip,
tooltips,
type Tooltip,
} from '@codemirror/view';
import { memo, useEffect, useRef, useState, type MutableRefObject } from 'react';
import type { Theme } from '~/types/theme';
import { classNames } from '~/utils/classNames';
import { debounce } from '~/utils/debounce';
import { createScopedLogger, renderLogger } from '~/utils/logger';
import { BinaryContent } from './BinaryContent';
import { getTheme, reconfigureTheme } from './cm-theme';
import { indentKeyBinding } from './indent';
import { getLanguage } from './languages';
const logger = createScopedLogger('CodeMirrorEditor');
export interface EditorDocument {
value: string;
isBinary: boolean;
filePath: string;
scroll?: ScrollPosition;
}
export interface EditorSettings {
fontSize?: string;
gutterFontSize?: string;
tabSize?: number;
}
type TextEditorDocument = EditorDocument & {
value: string;
};
export interface ScrollPosition {
top: number;
left: number;
}
export interface EditorUpdate {
selection: EditorSelection;
content: string;
}
export type OnChangeCallback = (update: EditorUpdate) => void;
export type OnScrollCallback = (position: ScrollPosition) => void;
export type OnSaveCallback = () => void;
interface Props {
theme: Theme;
id?: unknown;
doc?: EditorDocument;
editable?: boolean;
debounceChange?: number;
debounceScroll?: number;
autoFocusOnDocumentChange?: boolean;
onChange?: OnChangeCallback;
onScroll?: OnScrollCallback;
onSave?: OnSaveCallback;
className?: string;
settings?: EditorSettings;
}
type EditorStates = Map<string, EditorState>;
const readOnlyTooltipStateEffect = StateEffect.define<boolean>();
const editableTooltipField = StateField.define<readonly Tooltip[]>({
create: () => [],
update(_tooltips, transaction) {
if (!transaction.state.readOnly) {
return [];
}
for (const effect of transaction.effects) {
if (effect.is(readOnlyTooltipStateEffect) && effect.value) {
return getReadOnlyTooltip(transaction.state);
}
}
return [];
},
provide: (field) => {
return showTooltip.computeN([field], (state) => state.field(field));
},
});
const editableStateEffect = StateEffect.define<boolean>();
const editableStateField = StateField.define<boolean>({
create() {
return true;
},
update(value, transaction) {
for (const effect of transaction.effects) {
if (effect.is(editableStateEffect)) {
return effect.value;
}
}
return value;
},
});
export const CodeMirrorEditor = memo(
({
id,
doc,
debounceScroll = 100,
debounceChange = 150,
autoFocusOnDocumentChange = false,
editable = true,
onScroll,
onChange,
onSave,
theme,
settings,
className = '',
}: Props) => {
renderLogger.trace('CodeMirrorEditor');
const [languageCompartment] = useState(new Compartment());
const containerRef = useRef<HTMLDivElement | null>(null);
const viewRef = useRef<EditorView>();
const themeRef = useRef<Theme>();
const docRef = useRef<EditorDocument>();
const editorStatesRef = useRef<EditorStates>();
const onScrollRef = useRef(onScroll);
const onChangeRef = useRef(onChange);
const onSaveRef = useRef(onSave);
/**
* This effect is used to avoid side effects directly in the render function
* and instead the refs are updated after each render.
*/
useEffect(() => {
onScrollRef.current = onScroll;
onChangeRef.current = onChange;
onSaveRef.current = onSave;
docRef.current = doc;
themeRef.current = theme;
});
useEffect(() => {
const onUpdate = debounce((update: EditorUpdate) => {
onChangeRef.current?.(update);
}, debounceChange);
const view = new EditorView({
parent: containerRef.current!,
dispatchTransactions(transactions) {
const previousSelection = view.state.selection;
view.update(transactions);
const newSelection = view.state.selection;
const selectionChanged =
newSelection !== previousSelection &&
(newSelection === undefined || previousSelection === undefined || !newSelection.eq(previousSelection));
if (docRef.current && (transactions.some((transaction) => transaction.docChanged) || selectionChanged)) {
onUpdate({
selection: view.state.selection,
content: view.state.doc.toString(),
});
editorStatesRef.current!.set(docRef.current.filePath, view.state);
}
},
});
viewRef.current = view;
return () => {
viewRef.current?.destroy();
viewRef.current = undefined;
};
}, []);
useEffect(() => {
if (!viewRef.current) {
return;
}
viewRef.current.dispatch({
effects: [reconfigureTheme(theme)],
});
}, [theme]);
useEffect(() => {
editorStatesRef.current = new Map<string, EditorState>();
}, [id]);
useEffect(() => {
const editorStates = editorStatesRef.current!;
const view = viewRef.current!;
const theme = themeRef.current!;
if (!doc) {
const state = newEditorState('', theme, settings, onScrollRef, debounceScroll, onSaveRef, [
languageCompartment.of([]),
]);
view.setState(state);
setNoDocument(view);
return;
}
if (doc.isBinary) {
return;
}
if (doc.filePath === '') {
logger.warn('File path should not be empty');
}
let state = editorStates.get(doc.filePath);
if (!state) {
state = newEditorState(doc.value, theme, settings, onScrollRef, debounceScroll, onSaveRef, [
languageCompartment.of([]),
]);
editorStates.set(doc.filePath, state);
}
view.setState(state);
setEditorDocument(
view,
theme,
editable,
languageCompartment,
autoFocusOnDocumentChange,
doc as TextEditorDocument,
);
}, [doc?.value, editable, doc?.filePath, autoFocusOnDocumentChange]);
return (
<div className={classNames('relative h-full', className)}>
{doc?.isBinary && <BinaryContent />}
<div className="h-full overflow-hidden" ref={containerRef} />
</div>
);
},
);
export default CodeMirrorEditor;
CodeMirrorEditor.displayName = 'CodeMirrorEditor';
function newEditorState(
content: string,
theme: Theme,
settings: EditorSettings | undefined,
onScrollRef: MutableRefObject<OnScrollCallback | undefined>,
debounceScroll: number,
onFileSaveRef: MutableRefObject<OnSaveCallback | undefined>,
extensions: Extension[],
) {
return EditorState.create({
doc: content,
extensions: [
EditorView.domEventHandlers({
scroll: debounce((event, view) => {
if (event.target !== view.scrollDOM) {
return;
}
onScrollRef.current?.({ left: view.scrollDOM.scrollLeft, top: view.scrollDOM.scrollTop });
}, debounceScroll),
keydown: (event, view) => {
if (view.state.readOnly) {
view.dispatch({
effects: [readOnlyTooltipStateEffect.of(event.key !== 'Escape')],
});
return true;
}
return false;
},
}),
getTheme(theme, settings),
history(),
keymap.of([
...defaultKeymap,
...historyKeymap,
...searchKeymap,
{ key: 'Tab', run: acceptCompletion },
{
key: 'Mod-s',
preventDefault: true,
run: () => {
onFileSaveRef.current?.();
return true;
},
},
indentKeyBinding,
]),
indentUnit.of('\t'),
autocompletion({
closeOnBlur: false,
}),
tooltips({
position: 'absolute',
parent: document.body,
tooltipSpace: (view) => {
const rect = view.dom.getBoundingClientRect();
return {
top: rect.top - 50,
left: rect.left,
bottom: rect.bottom,
right: rect.right + 10,
};
},
}),
closeBrackets(),
lineNumbers(),
scrollPastEnd(),
dropCursor(),
drawSelection(),
bracketMatching(),
EditorState.tabSize.of(settings?.tabSize ?? 2),
indentOnInput(),
editableTooltipField,
editableStateField,
EditorState.readOnly.from(editableStateField, (editable) => !editable),
highlightActiveLineGutter(),
highlightActiveLine(),
foldGutter({
markerDOM: (open) => {
const icon = document.createElement('div');
icon.className = `fold-icon ${open ? 'i-ph-caret-down-bold' : 'i-ph-caret-right-bold'}`;
return icon;
},
}),
...extensions,
],
});
}
function setNoDocument(view: EditorView) {
view.dispatch({
selection: { anchor: 0 },
changes: {
from: 0,
to: view.state.doc.length,
insert: '',
},
});
view.scrollDOM.scrollTo(0, 0);
}
function setEditorDocument(
view: EditorView,
theme: Theme,
editable: boolean,
languageCompartment: Compartment,
autoFocus: boolean,
doc: TextEditorDocument,
) {
const content = doc.value;
if (content !== view.state.doc.toString()) {
view.dispatch({
selection: { anchor: 0 },
changes: {
from: 0,
to: view.state.doc.length,
insert: content,
},
});
}
view.dispatch({
effects: [editableStateEffect.of(editable && !doc.isBinary)],
});
getLanguage(doc.filePath).then((languageSupport) => {
if (!languageSupport) {
return;
}
view.dispatch({
effects: [languageCompartment.reconfigure([languageSupport]), reconfigureTheme(theme)],
});
requestAnimationFrame(() => {
const currentLeft = view.scrollDOM.scrollLeft;
const currentTop = view.scrollDOM.scrollTop;
const newLeft = doc.scroll?.left ?? 0;
const newTop = doc.scroll?.top ?? 0;
const needsScrolling = currentLeft !== newLeft || currentTop !== newTop;
if (autoFocus && editable) {
if (needsScrolling) {
// we have to wait until the scroll position was changed before we can set the focus
view.scrollDOM.addEventListener(
'scroll',
() => {
view.focus();
},
{ once: true },
);
} else {
// if the scroll position is still the same we can focus immediately
view.focus();
}
}
view.scrollDOM.scrollTo(newLeft, newTop);
});
});
}
function getReadOnlyTooltip(state: EditorState) {
if (!state.readOnly) {
return [];
}
return state.selection.ranges
.filter((range) => {
return range.empty;
})
.map((range) => {
return {
pos: range.head,
above: true,
strictSide: true,
arrow: true,
create: () => {
const divElement = document.createElement('div');
divElement.className = 'cm-readonly-tooltip';
divElement.textContent = 'Cannot edit file while AI response is being generated';
return { dom: divElement };
},
};
});
}

View File

@@ -1,192 +0,0 @@
import { Compartment, type Extension } from '@codemirror/state';
import { EditorView } from '@codemirror/view';
import { vscodeDark, vscodeLight } from '@uiw/codemirror-theme-vscode';
import type { Theme } from '~/types/theme.js';
import type { EditorSettings } from './CodeMirrorEditor.js';
export const darkTheme = EditorView.theme({}, { dark: true });
export const themeSelection = new Compartment();
export function getTheme(theme: Theme, settings: EditorSettings = {}): Extension {
return [
getEditorTheme(settings),
theme === 'dark' ? themeSelection.of([getDarkTheme()]) : themeSelection.of([getLightTheme()]),
];
}
export function reconfigureTheme(theme: Theme) {
return themeSelection.reconfigure(theme === 'dark' ? getDarkTheme() : getLightTheme());
}
function getEditorTheme(settings: EditorSettings) {
return EditorView.theme({
'&': {
fontSize: settings.fontSize ?? '12px',
},
'&.cm-editor': {
height: '100%',
background: 'var(--cm-backgroundColor)',
color: 'var(--cm-textColor)',
},
'.cm-cursor': {
borderLeft: 'var(--cm-cursor-width) solid var(--cm-cursor-backgroundColor)',
},
'.cm-scroller': {
lineHeight: '1.5',
'&:focus-visible': {
outline: 'none',
},
},
'.cm-line': {
padding: '0 0 0 4px',
},
'&.cm-focused > .cm-scroller > .cm-selectionLayer .cm-selectionBackground': {
backgroundColor: 'var(--cm-selection-backgroundColorFocused) !important',
opacity: 'var(--cm-selection-backgroundOpacityFocused, 0.3)',
},
'&:not(.cm-focused) > .cm-scroller > .cm-selectionLayer .cm-selectionBackground': {
backgroundColor: 'var(--cm-selection-backgroundColorBlured)',
opacity: 'var(--cm-selection-backgroundOpacityBlured, 0.3)',
},
'&.cm-focused > .cm-scroller .cm-matchingBracket': {
backgroundColor: 'var(--cm-matching-bracket)',
},
'.cm-activeLine': {
background: 'var(--cm-activeLineBackgroundColor)',
},
'.cm-gutters': {
background: 'var(--cm-gutter-backgroundColor)',
borderRight: 0,
color: 'var(--cm-gutter-textColor)',
},
'.cm-gutter': {
'&.cm-lineNumbers': {
fontFamily: 'Roboto Mono, monospace',
fontSize: settings.gutterFontSize ?? settings.fontSize ?? '12px',
minWidth: '40px',
},
'& .cm-activeLineGutter': {
background: 'transparent',
color: 'var(--cm-gutter-activeLineTextColor)',
},
'&.cm-foldGutter .cm-gutterElement > .fold-icon': {
cursor: 'pointer',
color: 'var(--cm-foldGutter-textColor)',
transform: 'translateY(2px)',
'&:hover': {
color: 'var(--cm-foldGutter-textColorHover)',
},
},
},
'.cm-foldGutter .cm-gutterElement': {
padding: '0 4px',
},
'.cm-tooltip-autocomplete > ul > li': {
minHeight: '18px',
},
'.cm-panel.cm-search label': {
marginLeft: '2px',
fontSize: '12px',
},
'.cm-panel.cm-search .cm-button': {
fontSize: '12px',
},
'.cm-panel.cm-search .cm-textfield': {
fontSize: '12px',
},
'.cm-panel.cm-search input[type=checkbox]': {
position: 'relative',
transform: 'translateY(2px)',
marginRight: '4px',
},
'.cm-panels': {
borderColor: 'var(--cm-panels-borderColor)',
},
'.cm-panels-bottom': {
borderTop: '1px solid var(--cm-panels-borderColor)',
backgroundColor: 'transparent',
},
'.cm-panel.cm-search': {
background: 'var(--cm-search-backgroundColor)',
color: 'var(--cm-search-textColor)',
padding: '8px',
},
'.cm-search .cm-button': {
background: 'var(--cm-search-button-backgroundColor)',
borderColor: 'var(--cm-search-button-borderColor)',
color: 'var(--cm-search-button-textColor)',
borderRadius: '4px',
'&:hover': {
color: 'var(--cm-search-button-textColorHover)',
},
'&:focus-visible': {
outline: 'none',
borderColor: 'var(--cm-search-button-borderColorFocused)',
},
'&:hover:not(:focus-visible)': {
background: 'var(--cm-search-button-backgroundColorHover)',
borderColor: 'var(--cm-search-button-borderColorHover)',
},
'&:hover:focus-visible': {
background: 'var(--cm-search-button-backgroundColorHover)',
borderColor: 'var(--cm-search-button-borderColorFocused)',
},
},
'.cm-panel.cm-search [name=close]': {
top: '6px',
right: '6px',
padding: '0 6px',
fontSize: '1rem',
backgroundColor: 'var(--cm-search-closeButton-backgroundColor)',
color: 'var(--cm-search-closeButton-textColor)',
'&:hover': {
'border-radius': '6px',
color: 'var(--cm-search-closeButton-textColorHover)',
backgroundColor: 'var(--cm-search-closeButton-backgroundColorHover)',
},
},
'.cm-search input': {
background: 'var(--cm-search-input-backgroundColor)',
borderColor: 'var(--cm-search-input-borderColor)',
color: 'var(--cm-search-input-textColor)',
outline: 'none',
borderRadius: '4px',
'&:focus-visible': {
borderColor: 'var(--cm-search-input-borderColorFocused)',
},
},
'.cm-tooltip': {
background: 'var(--cm-tooltip-backgroundColor)',
border: '1px solid transparent',
borderColor: 'var(--cm-tooltip-borderColor)',
color: 'var(--cm-tooltip-textColor)',
},
'.cm-tooltip.cm-tooltip-autocomplete ul li[aria-selected]': {
background: 'var(--cm-tooltip-backgroundColorSelected)',
color: 'var(--cm-tooltip-textColorSelected)',
},
'.cm-searchMatch': {
backgroundColor: 'var(--cm-searchMatch-backgroundColor)',
},
'.cm-tooltip.cm-readonly-tooltip': {
padding: '4px',
whiteSpace: 'nowrap',
backgroundColor: 'var(--bolt-elements-bg-depth-2)',
borderColor: 'var(--bolt-elements-borderColorActive)',
'& .cm-tooltip-arrow:before': {
borderTopColor: 'var(--bolt-elements-borderColorActive)',
},
'& .cm-tooltip-arrow:after': {
borderTopColor: 'transparent',
},
},
});
}
function getLightTheme() {
return vscodeLight;
}
function getDarkTheme() {
return vscodeDark;
}

View File

@@ -1,68 +0,0 @@
import { indentLess } from '@codemirror/commands';
import { indentUnit } from '@codemirror/language';
import { EditorSelection, EditorState, Line, type ChangeSpec } from '@codemirror/state';
import { EditorView, type KeyBinding } from '@codemirror/view';
export const indentKeyBinding: KeyBinding = {
key: 'Tab',
run: indentMore,
shift: indentLess,
};
function indentMore({ state, dispatch }: EditorView) {
if (state.readOnly) {
return false;
}
dispatch(
state.update(
changeBySelectedLine(state, (from, to, changes) => {
changes.push({ from, to, insert: state.facet(indentUnit) });
}),
{ userEvent: 'input.indent' },
),
);
return true;
}
function changeBySelectedLine(
state: EditorState,
cb: (from: number, to: number | undefined, changes: ChangeSpec[], line: Line) => void,
) {
return state.changeByRange((range) => {
const changes: ChangeSpec[] = [];
const line = state.doc.lineAt(range.from);
// just insert single indent unit at the current cursor position
if (range.from === range.to) {
cb(range.from, undefined, changes, line);
}
// handle the case when multiple characters are selected in a single line
else if (range.from < range.to && range.to <= line.to) {
cb(range.from, range.to, changes, line);
} else {
let atLine = -1;
// handle the case when selection spans multiple lines
for (let pos = range.from; pos <= range.to; ) {
const line = state.doc.lineAt(pos);
if (line.number > atLine && (range.empty || range.to > line.from)) {
cb(line.from, undefined, changes, line);
atLine = line.number;
}
pos = line.to + 1;
}
}
const changeSet = state.changes(changes);
return {
changes,
range: EditorSelection.range(changeSet.mapPos(range.anchor, 1), changeSet.mapPos(range.head, 1)),
};
});
}

View File

@@ -1,112 +0,0 @@
import { LanguageDescription } from '@codemirror/language';
export const supportedLanguages = [
LanguageDescription.of({
name: 'VUE',
extensions: ['vue'],
async load() {
return import('@codemirror/lang-vue').then((module) => module.vue());
},
}),
LanguageDescription.of({
name: 'TS',
extensions: ['ts'],
async load() {
return import('@codemirror/lang-javascript').then((module) => module.javascript({ typescript: true }));
},
}),
LanguageDescription.of({
name: 'JS',
extensions: ['js', 'mjs', 'cjs'],
async load() {
return import('@codemirror/lang-javascript').then((module) => module.javascript());
},
}),
LanguageDescription.of({
name: 'TSX',
extensions: ['tsx'],
async load() {
return import('@codemirror/lang-javascript').then((module) => module.javascript({ jsx: true, typescript: true }));
},
}),
LanguageDescription.of({
name: 'JSX',
extensions: ['jsx'],
async load() {
return import('@codemirror/lang-javascript').then((module) => module.javascript({ jsx: true }));
},
}),
LanguageDescription.of({
name: 'HTML',
extensions: ['html'],
async load() {
return import('@codemirror/lang-html').then((module) => module.html());
},
}),
LanguageDescription.of({
name: 'CSS',
extensions: ['css'],
async load() {
return import('@codemirror/lang-css').then((module) => module.css());
},
}),
LanguageDescription.of({
name: 'SASS',
extensions: ['sass'],
async load() {
return import('@codemirror/lang-sass').then((module) => module.sass({ indented: true }));
},
}),
LanguageDescription.of({
name: 'SCSS',
extensions: ['scss'],
async load() {
return import('@codemirror/lang-sass').then((module) => module.sass({ indented: false }));
},
}),
LanguageDescription.of({
name: 'JSON',
extensions: ['json'],
async load() {
return import('@codemirror/lang-json').then((module) => module.json());
},
}),
LanguageDescription.of({
name: 'Markdown',
extensions: ['md'],
async load() {
return import('@codemirror/lang-markdown').then((module) => module.markdown());
},
}),
LanguageDescription.of({
name: 'Wasm',
extensions: ['wat'],
async load() {
return import('@codemirror/lang-wast').then((module) => module.wast());
},
}),
LanguageDescription.of({
name: 'Python',
extensions: ['py'],
async load() {
return import('@codemirror/lang-python').then((module) => module.python());
},
}),
LanguageDescription.of({
name: 'C++',
extensions: ['cpp'],
async load() {
return import('@codemirror/lang-cpp').then((module) => module.cpp());
},
}),
];
export async function getLanguage(fileName: string) {
const languageDescription = LanguageDescription.matchFilename(supportedLanguages, fileName);
if (languageDescription) {
return await languageDescription.load();
}
return undefined;
}

View File

@@ -1,131 +0,0 @@
import { useSearchParams } from '@remix-run/react';
import { generateId, type Message } from 'ai';
import ignore from 'ignore';
import { useEffect, useState } from 'react';
import { ClientOnly } from 'remix-utils/client-only';
import { BaseChat } from '~/components/chat/BaseChat';
import { Chat } from '~/components/chat/Chat.client';
import { useGit } from '~/lib/hooks/useGit';
import { useChatHistory } from '~/lib/persistence';
import { LoadingOverlay } from '~/components/ui/LoadingOverlay';
import { toast } from 'react-toastify';
const IGNORE_PATTERNS = [
'node_modules/**',
'.git/**',
'.github/**',
'.vscode/**',
'**/*.jpg',
'**/*.jpeg',
'**/*.png',
'dist/**',
'build/**',
'.next/**',
'coverage/**',
'.cache/**',
'.vscode/**',
'.idea/**',
'**/*.log',
'**/.DS_Store',
'**/npm-debug.log*',
'**/yarn-debug.log*',
'**/yarn-error.log*',
'**/*lock.json',
'**/*lock.yaml',
];
export function GitUrlImport() {
const [searchParams] = useSearchParams();
const { ready: historyReady, importChat } = useChatHistory();
const { ready: gitReady, gitClone } = useGit();
const [imported, setImported] = useState(false);
const [loading, setLoading] = useState(true);
const importRepo = async (repoUrl?: string) => {
if (!gitReady && !historyReady) {
return;
}
if (repoUrl) {
const ig = ignore().add(IGNORE_PATTERNS);
try {
const { workdir, data } = await gitClone(repoUrl);
if (importChat) {
const filePaths = Object.keys(data).filter((filePath) => !ig.ignores(filePath));
const fileContents = filePaths
.map((filePath) => {
const file = data[filePath];
return {
path: filePath,
content: file?.content,
};
})
.filter((f) => f.content);
const filesMessage: Message = {
role: 'assistant',
content: `Cloning the repo ${repoUrl} into ${workdir}
<boltArtifact id="imported-files" title="Git Cloned Files" type="bundled">
${fileContents
.map(
(file) =>
`<boltAction type="file" filePath="${file.path}">
${file.content}
</boltAction>`,
)
.join('\n')}
</boltArtifact>`,
id: generateId(),
createdAt: new Date(),
};
const messages = [filesMessage];
await importChat(`Git Project:${repoUrl.split('/').slice(-1)[0]}`, messages);
}
} catch (error) {
console.error('Error during import:', error);
toast.error('Failed to import repository');
setLoading(false);
window.location.href = '/';
return;
}
}
};
useEffect(() => {
if (!historyReady || !gitReady || imported) {
return;
}
const url = searchParams.get('url');
if (!url) {
window.location.href = '/';
return;
}
importRepo(url).catch((error) => {
console.error('Error importing repo:', error);
toast.error('Failed to import repository');
setLoading(false);
window.location.href = '/';
});
setImported(true);
}, [searchParams, historyReady, gitReady, imported]);
return (
<ClientOnly fallback={<BaseChat />}>
{() => (
<>
<Chat />
{loading && <LoadingOverlay message="Please wait while we clone the repository..." />}
</>
)}
</ClientOnly>
);
}

View File

@@ -2,7 +2,7 @@ import { toast } from 'react-toastify';
import ReactModal from 'react-modal';
import { useState } from 'react';
import { submitFeedback } from '~/lib/replay/Problems';
import { getLastProjectContents, getLastChatMessages } from '~/components/chat/Chat.client';
import { getLastChatMessages } from '~/components/chat/Chat.client';
import { shouldUseSupabase } from '~/lib/supabase/client';
ReactModal.setAppElement('#root');
@@ -56,7 +56,6 @@ export function Feedback() {
};
if (feedbackData.share) {
feedbackData.repositoryContents = getLastProjectContents();
feedbackData.chatMessages = getLastChatMessages();
}

View File

@@ -4,6 +4,7 @@ import { useState } from 'react';
import { workbenchStore } from '~/lib/stores/workbench';
import { getProblemsUsername, submitProblem } from '~/lib/replay/Problems';
import type { BoltProblemInput } from '~/lib/replay/Problems';
import { getRepositoryContents } from '~/lib/replay/Repository';
ReactModal.setAppElement('#root');
@@ -54,16 +55,21 @@ export function SaveProblem() {
console.log('SubmitProblem', formData);
await workbenchStore.saveAllFiles();
const repositoryId = workbenchStore.repositoryId.get();
const { contentBase64 } = await workbenchStore.generateZipBase64();
if (!repositoryId) {
toast.error('No repository ID found');
return;
}
const repositoryContents = await getRepositoryContents(repositoryId);
const problem: BoltProblemInput = {
version: 2,
title: formData.title,
description: formData.description,
username,
repositoryContents: contentBase64,
repositoryContents,
};
const problemId = await submitProblem(problem);

View File

@@ -1,65 +0,0 @@
import { motion } from 'framer-motion';
import { memo } from 'react';
import { classNames } from '~/utils/classNames';
import { cubicEasingFn } from '~/utils/easings';
import { genericMemo } from '~/utils/react';
interface SliderOption<T> {
value: T;
text: string;
}
export interface SliderOptions<T> {
left: SliderOption<T>;
right: SliderOption<T>;
}
interface SliderProps<T> {
selected: T;
options: SliderOptions<T>;
setSelected?: (selected: T) => void;
}
export const Slider = genericMemo(<T,>({ selected, options, setSelected }: SliderProps<T>) => {
const isLeftSelected = selected === options.left.value;
return (
<div className="flex items-center flex-wrap shrink-0 gap-1 bg-bolt-elements-background-depth-1 overflow-hidden rounded-full p-1">
<SliderButton selected={isLeftSelected} setSelected={() => setSelected?.(options.left.value)}>
{options.left.text}
</SliderButton>
<SliderButton selected={!isLeftSelected} setSelected={() => setSelected?.(options.right.value)}>
{options.right.text}
</SliderButton>
</div>
);
});
interface SliderButtonProps {
selected: boolean;
children: string | JSX.Element | Array<JSX.Element | string>;
setSelected: () => void;
}
const SliderButton = memo(({ selected, children, setSelected }: SliderButtonProps) => {
return (
<button
onClick={setSelected}
className={classNames(
'bg-transparent text-sm px-2.5 py-0.5 rounded-full relative',
selected
? 'text-bolt-elements-item-contentAccent'
: 'text-bolt-elements-item-contentDefault hover:text-bolt-elements-item-contentActive',
)}
>
<span className="relative z-10">{children}</span>
{selected && (
<motion.span
layoutId="pill-tab"
transition={{ duration: 0.2, ease: cubicEasingFn }}
className="absolute inset-0 z-0 bg-bolt-elements-item-backgroundAccent rounded-full"
></motion.span>
)}
</button>
);
});

View File

@@ -1,126 +0,0 @@
import { useStore } from '@nanostores/react';
import { memo, useMemo } from 'react';
import { Panel, PanelGroup, PanelResizeHandle } from 'react-resizable-panels';
import {
CodeMirrorEditor,
type EditorDocument,
type EditorSettings,
type OnChangeCallback as OnEditorChange,
type OnSaveCallback as OnEditorSave,
type OnScrollCallback as OnEditorScroll,
} from '~/components/editor/codemirror/CodeMirrorEditor';
import { PanelHeader } from '~/components/ui/PanelHeader';
import { PanelHeaderButton } from '~/components/ui/PanelHeaderButton';
import type { FileMap } from '~/lib/stores/files';
import { themeStore } from '~/lib/stores/theme';
import { renderLogger } from '~/utils/logger';
import { isMobile } from '~/utils/mobile';
import { FileBreadcrumb } from './FileBreadcrumb';
import { FileTree } from './FileTree';
interface EditorPanelProps {
files?: FileMap;
unsavedFiles?: Set<string>;
editorDocument?: EditorDocument;
selectedFile?: string | undefined;
isStreaming?: boolean;
onEditorChange?: OnEditorChange;
onEditorScroll?: OnEditorScroll;
onFileSelect?: (value?: string) => void;
onFileSave?: OnEditorSave;
onFileReset?: () => void;
}
const DEFAULT_EDITOR_SIZE = 100;
const editorSettings: EditorSettings = { tabSize: 2 };
export const EditorPanel = memo(
({
files,
unsavedFiles,
editorDocument,
selectedFile,
isStreaming,
onFileSelect,
onEditorChange,
onEditorScroll,
onFileSave,
onFileReset,
}: EditorPanelProps) => {
renderLogger.trace('EditorPanel');
const theme = useStore(themeStore);
const activeFileSegments = useMemo(() => {
if (!editorDocument) {
return undefined;
}
return editorDocument.filePath.split('/');
}, [editorDocument]);
const activeFileUnsaved = useMemo(() => {
return editorDocument !== undefined && unsavedFiles?.has(editorDocument.filePath);
}, [editorDocument, unsavedFiles]);
return (
<PanelGroup direction="vertical">
<Panel defaultSize={DEFAULT_EDITOR_SIZE} minSize={20}>
<PanelGroup direction="horizontal">
<Panel defaultSize={20} minSize={10} collapsible>
<div className="flex flex-col border-r border-bolt-elements-borderColor h-full">
<PanelHeader>
<div className="i-ph:tree-structure-duotone shrink-0" />
Files
</PanelHeader>
<FileTree
className="h-full"
files={files}
unsavedFiles={unsavedFiles}
selectedFile={selectedFile}
onFileSelect={onFileSelect}
/>
</div>
</Panel>
<PanelResizeHandle />
<Panel className="flex flex-col" defaultSize={80} minSize={20}>
<PanelHeader className="overflow-x-auto">
{activeFileSegments?.length && (
<div className="flex items-center flex-1 text-sm">
<FileBreadcrumb pathSegments={activeFileSegments} files={files} onFileSelect={onFileSelect} />
{activeFileUnsaved && (
<div className="flex gap-1 ml-auto -mr-1.5">
<PanelHeaderButton onClick={onFileSave}>
<div className="i-ph:floppy-disk-duotone" />
Save
</PanelHeaderButton>
<PanelHeaderButton onClick={onFileReset}>
<div className="i-ph:clock-counter-clockwise-duotone" />
Reset
</PanelHeaderButton>
</div>
)}
</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>
</Panel>
</PanelGroup>
</Panel>
<PanelResizeHandle />
</PanelGroup>
);
},
);

View File

@@ -1,142 +0,0 @@
import * as DropdownMenu from '@radix-ui/react-dropdown-menu';
import { AnimatePresence, motion, type Variants } from 'framer-motion';
import { memo, useEffect, useRef, useState } from 'react';
import type { FileMap } from '~/lib/stores/files';
import { classNames } from '~/utils/classNames';
import { cubicEasingFn } from '~/utils/easings';
import { renderLogger } from '~/utils/logger';
import FileTree from './FileTree';
interface FileBreadcrumbProps {
files?: FileMap;
pathSegments?: string[];
onFileSelect?: (filePath: string) => void;
}
const contextMenuVariants = {
open: {
y: 0,
opacity: 1,
transition: {
duration: 0.15,
ease: cubicEasingFn,
},
},
close: {
y: 6,
opacity: 0,
transition: {
duration: 0.15,
ease: cubicEasingFn,
},
},
} satisfies Variants;
export const FileBreadcrumb = memo<FileBreadcrumbProps>(({ files, pathSegments = [], onFileSelect }) => {
renderLogger.trace('FileBreadcrumb');
const [activeIndex, setActiveIndex] = useState<number | null>(null);
const contextMenuRef = useRef<HTMLDivElement | null>(null);
const segmentRefs = useRef<(HTMLSpanElement | null)[]>([]);
const handleSegmentClick = (index: number) => {
setActiveIndex((prevIndex) => (prevIndex === index ? null : index));
};
useEffect(() => {
const handleOutsideClick = (event: MouseEvent) => {
if (
activeIndex !== null &&
!contextMenuRef.current?.contains(event.target as Node) &&
!segmentRefs.current.some((ref) => ref?.contains(event.target as Node))
) {
setActiveIndex(null);
}
};
document.addEventListener('mousedown', handleOutsideClick);
return () => {
document.removeEventListener('mousedown', handleOutsideClick);
};
}, [activeIndex]);
if (files === undefined || pathSegments.length === 0) {
return null;
}
return (
<div className="flex">
{pathSegments.map((segment, index) => {
const isLast = index === pathSegments.length - 1;
const path = pathSegments.slice(0, index).join('/');
const isActive = activeIndex === index;
return (
<div key={index} className="relative flex items-center">
<DropdownMenu.Root open={isActive} modal={false}>
<DropdownMenu.Trigger asChild>
<span
ref={(ref) => {
segmentRefs.current[index] = ref;
return undefined;
}}
className={classNames('flex items-center gap-1.5 cursor-pointer shrink-0', {
'text-bolt-elements-textTertiary hover:text-bolt-elements-textPrimary': !isActive,
'text-bolt-elements-textPrimary underline': isActive,
'pr-4': isLast,
})}
onClick={() => handleSegmentClick(index)}
>
{isLast && <div className="i-ph:file-duotone" />}
{segment}
</span>
</DropdownMenu.Trigger>
{index > 0 && !isLast && <span className="i-ph:caret-right inline-block mx-1" />}
<AnimatePresence>
{isActive && (
<DropdownMenu.Portal>
<DropdownMenu.Content
className="z-file-tree-breadcrumb"
asChild
align="start"
side="bottom"
avoidCollisions={false}
>
<motion.div
ref={contextMenuRef}
initial="close"
animate="open"
exit="close"
variants={contextMenuVariants}
>
<div className="rounded-lg overflow-hidden">
<div className="max-h-[50vh] min-w-[300px] overflow-scroll bg-bolt-elements-background-depth-1 border border-bolt-elements-borderColor shadow-sm rounded-lg">
<FileTree
files={files}
collapsed
allowFolderSelection
selectedFile={`${path}/${segment}`}
onFileSelect={(filePath) => {
setActiveIndex(null);
onFileSelect?.(filePath);
}}
/>
</div>
</div>
<DropdownMenu.Arrow className="fill-bolt-elements-borderColor" />
</motion.div>
</DropdownMenu.Content>
</DropdownMenu.Portal>
)}
</AnimatePresence>
</DropdownMenu.Root>
</div>
);
})}
</div>
);
});

View File

@@ -1,475 +0,0 @@
import { memo, useEffect, useMemo, useState, type ReactNode } from 'react';
import type { FileMap } from '~/lib/stores/files';
import { classNames } from '~/utils/classNames';
import { createScopedLogger, renderLogger } from '~/utils/logger';
import * as ContextMenu from '@radix-ui/react-context-menu';
const logger = createScopedLogger('FileTree');
const NODE_PADDING_LEFT = 8;
const DEFAULT_HIDDEN_FILES = [/\/node_modules\//, /\/\.next/, /\/\.astro/];
interface Props {
files?: FileMap;
selectedFile?: string;
onFileSelect?: (filePath: string) => void;
collapsed?: boolean;
allowFolderSelection?: boolean;
hiddenFiles?: Array<string | RegExp>;
unsavedFiles?: Set<string>;
className?: string;
}
export const FileTree = memo(
({
files = {},
onFileSelect,
selectedFile,
collapsed = false,
allowFolderSelection = false,
hiddenFiles,
className,
unsavedFiles,
}: Props) => {
renderLogger.trace('FileTree');
const computedHiddenFiles = useMemo(() => [...DEFAULT_HIDDEN_FILES, ...(hiddenFiles ?? [])], [hiddenFiles]);
const fileList = useMemo(() => {
return buildFileList(files, computedHiddenFiles);
}, [files, computedHiddenFiles]);
const [collapsedFolders, setCollapsedFolders] = useState(() => {
return collapsed
? new Set(fileList.filter((item) => item.kind === 'folder').map((item) => item.fullPath))
: new Set<string>();
});
useEffect(() => {
if (collapsed) {
setCollapsedFolders(new Set(fileList.filter((item) => item.kind === 'folder').map((item) => item.fullPath)));
return;
}
setCollapsedFolders((prevCollapsed) => {
const newCollapsed = new Set<string>();
for (const folder of fileList) {
if (folder.kind === 'folder' && prevCollapsed.has(folder.fullPath)) {
newCollapsed.add(folder.fullPath);
}
}
return newCollapsed;
});
}, [fileList, collapsed]);
const filteredFileList = useMemo(() => {
const list = [];
let lastDepth = Number.MAX_SAFE_INTEGER;
for (const fileOrFolder of fileList) {
const depth = fileOrFolder.depth;
// if the depth is equal we reached the end of the collaped group
if (lastDepth === depth) {
lastDepth = Number.MAX_SAFE_INTEGER;
}
// ignore collapsed folders
if (collapsedFolders.has(fileOrFolder.fullPath)) {
lastDepth = Math.min(lastDepth, depth);
}
// ignore files and folders below the last collapsed folder
if (lastDepth < depth) {
continue;
}
list.push(fileOrFolder);
}
return list;
}, [fileList, collapsedFolders]);
const toggleCollapseState = (fullPath: string) => {
setCollapsedFolders((prevSet) => {
const newSet = new Set(prevSet);
if (newSet.has(fullPath)) {
newSet.delete(fullPath);
} else {
newSet.add(fullPath);
}
return newSet;
});
};
const onCopyPath = (fileOrFolder: FileNode | FolderNode) => {
try {
navigator.clipboard.writeText(fileOrFolder.fullPath);
} catch (error) {
logger.error(error);
}
};
const onCopyRelativePath = (fileOrFolder: FileNode | FolderNode) => {
try {
navigator.clipboard.writeText(fileOrFolder.fullPath);
} catch (error) {
logger.error(error);
}
};
return (
<div className={classNames('text-sm', className, 'overflow-y-auto')}>
{filteredFileList.map((fileOrFolder) => {
switch (fileOrFolder.kind) {
case 'file': {
return (
<File
key={fileOrFolder.id}
selected={selectedFile === fileOrFolder.fullPath}
file={fileOrFolder}
unsavedChanges={unsavedFiles?.has(fileOrFolder.fullPath)}
onCopyPath={() => {
onCopyPath(fileOrFolder);
}}
onCopyRelativePath={() => {
onCopyRelativePath(fileOrFolder);
}}
onClick={() => {
onFileSelect?.(fileOrFolder.fullPath);
}}
/>
);
}
case 'folder': {
return (
<Folder
key={fileOrFolder.id}
folder={fileOrFolder}
selected={allowFolderSelection && selectedFile === fileOrFolder.fullPath}
collapsed={collapsedFolders.has(fileOrFolder.fullPath)}
onCopyPath={() => {
onCopyPath(fileOrFolder);
}}
onCopyRelativePath={() => {
onCopyRelativePath(fileOrFolder);
}}
onClick={() => {
toggleCollapseState(fileOrFolder.fullPath);
}}
/>
);
}
default: {
return undefined;
}
}
})}
</div>
);
},
);
export default FileTree;
interface FolderProps {
folder: FolderNode;
collapsed: boolean;
selected?: boolean;
onCopyPath: () => void;
onCopyRelativePath: () => void;
onClick: () => void;
}
interface FolderContextMenuProps {
onCopyPath?: () => void;
onCopyRelativePath?: () => void;
children: ReactNode;
}
function ContextMenuItem({ onSelect, children }: { onSelect?: () => void; children: ReactNode }) {
return (
<ContextMenu.Item
onSelect={onSelect}
className="flex items-center gap-2 px-2 py-1.5 outline-0 text-sm text-bolt-elements-textPrimary cursor-pointer ws-nowrap text-bolt-elements-item-contentDefault hover:text-bolt-elements-item-contentActive hover:bg-bolt-elements-item-backgroundActive rounded-md"
>
<span className="size-4 shrink-0"></span>
<span>{children}</span>
</ContextMenu.Item>
);
}
function FileContextMenu({ onCopyPath, onCopyRelativePath, children }: FolderContextMenuProps) {
return (
<ContextMenu.Root>
<ContextMenu.Trigger>{children}</ContextMenu.Trigger>
<ContextMenu.Portal>
<ContextMenu.Content
style={{ zIndex: 998 }}
className="border border-bolt-elements-borderColor rounded-md z-context-menu bg-bolt-elements-background-depth-1 dark:bg-bolt-elements-background-depth-2 data-[state=open]:animate-in animate-duration-100 data-[state=open]:fade-in-0 data-[state=open]:zoom-in-98 w-56"
>
<ContextMenu.Group className="p-1 border-b-px border-solid border-bolt-elements-borderColor">
<ContextMenuItem onSelect={onCopyPath}>Copy path</ContextMenuItem>
<ContextMenuItem onSelect={onCopyRelativePath}>Copy relative path</ContextMenuItem>
</ContextMenu.Group>
</ContextMenu.Content>
</ContextMenu.Portal>
</ContextMenu.Root>
);
}
function Folder({ folder, collapsed, selected = false, onCopyPath, onCopyRelativePath, onClick }: FolderProps) {
return (
<FileContextMenu onCopyPath={onCopyPath} onCopyRelativePath={onCopyRelativePath}>
<NodeButton
className={classNames('group', {
'bg-transparent text-bolt-elements-item-contentDefault hover:text-bolt-elements-item-contentActive hover:bg-bolt-elements-item-backgroundActive':
!selected,
'bg-bolt-elements-item-backgroundAccent text-bolt-elements-item-contentAccent': selected,
})}
depth={folder.depth}
iconClasses={classNames({
'i-ph:caret-right scale-98': collapsed,
'i-ph:caret-down scale-98': !collapsed,
})}
onClick={onClick}
>
{folder.name}
</NodeButton>
</FileContextMenu>
);
}
interface FileProps {
file: FileNode;
selected: boolean;
unsavedChanges?: boolean;
onCopyPath: () => void;
onCopyRelativePath: () => void;
onClick: () => void;
}
function File({
file: { depth, name },
onClick,
onCopyPath,
onCopyRelativePath,
selected,
unsavedChanges = false,
}: FileProps) {
return (
<FileContextMenu onCopyPath={onCopyPath} onCopyRelativePath={onCopyRelativePath}>
<NodeButton
className={classNames('group', {
'bg-transparent hover:bg-bolt-elements-item-backgroundActive text-bolt-elements-item-contentDefault':
!selected,
'bg-bolt-elements-item-backgroundAccent text-bolt-elements-item-contentAccent': selected,
})}
depth={depth}
iconClasses={classNames('i-ph:file-duotone scale-98', {
'group-hover:text-bolt-elements-item-contentActive': !selected,
})}
onClick={onClick}
>
<div
className={classNames('flex items-center', {
'group-hover:text-bolt-elements-item-contentActive': !selected,
})}
>
<div className="flex-1 truncate pr-2">{name}</div>
{unsavedChanges && <span className="i-ph:circle-fill scale-68 shrink-0 text-orange-500" />}
</div>
</NodeButton>
</FileContextMenu>
);
}
interface ButtonProps {
depth: number;
iconClasses: string;
children: ReactNode;
className?: string;
onClick?: () => void;
}
function NodeButton({ depth, iconClasses, onClick, className, children }: ButtonProps) {
return (
<button
className={classNames(
'flex items-center gap-1.5 w-full pr-2 border-2 border-transparent text-faded py-0.5',
className,
)}
style={{ paddingLeft: `${6 + depth * NODE_PADDING_LEFT}px` }}
onClick={() => onClick?.()}
>
<div className={classNames('scale-120 shrink-0', iconClasses)}></div>
<div className="truncate w-full text-left">{children}</div>
</button>
);
}
type Node = FileNode | FolderNode;
interface BaseNode {
id: number;
depth: number;
name: string;
fullPath: string;
}
interface FileNode extends BaseNode {
kind: 'file';
}
interface FolderNode extends BaseNode {
kind: 'folder';
}
function buildFileList(files: FileMap, hiddenFiles: Array<string | RegExp>): Node[] {
const folderPaths = new Set<string>();
const fileList: Node[] = [];
const defaultDepth = 0;
for (const filePath of Object.keys(files)) {
const segments = filePath.split('/').filter((segment) => segment);
const fileName = segments.at(-1);
if (!fileName || isHiddenFile(filePath, fileName, hiddenFiles)) {
continue;
}
let fullPath = '';
let i = 0;
let depth = 0;
while (i < segments.length) {
const name = segments[i];
fullPath += (fullPath.length ? '/' : '') + name;
if (i === segments.length - 1) {
fileList.push({
kind: 'file',
id: fileList.length,
name,
fullPath,
depth: depth + defaultDepth,
});
} else if (!folderPaths.has(fullPath)) {
folderPaths.add(fullPath);
fileList.push({
kind: 'folder',
id: fileList.length,
name,
fullPath,
depth: depth + defaultDepth,
});
}
i++;
depth++;
}
}
return sortFileList(fileList);
}
function isHiddenFile(filePath: string, fileName: string, hiddenFiles: Array<string | RegExp>) {
return hiddenFiles.some((pathOrRegex) => {
if (typeof pathOrRegex === 'string') {
return fileName === pathOrRegex;
}
return pathOrRegex.test(filePath);
});
}
function getParentPath(path: string): string {
const lastSlash = path.lastIndexOf('/');
if (lastSlash === -1) {
return '';
}
return path.slice(0, lastSlash);
}
/**
* Sorts the given list of nodes into a tree structure (still a flat list).
*
* This function organizes the nodes into a hierarchical structure based on their paths,
* with folders appearing before files and all items sorted alphabetically within their level.
*
* @note This function mutates the given `nodeList` array for performance reasons.
*
* @param rootFolder - The path of the root folder to start the sorting from.
* @param nodeList - The list of nodes to be sorted.
*
* @returns A new array of nodes sorted in depth-first order.
*/
function sortFileList(nodeList: Node[]): Node[] {
logger.trace('sortFileList');
const nodeMap = new Map<string, Node>();
const childrenMap = new Map<string, Node[]>();
// pre-sort nodes by name and type
nodeList.sort((a, b) => compareNodes(a, b));
for (const node of nodeList) {
nodeMap.set(node.fullPath, node);
const parentPath = getParentPath(node.fullPath);
if (!childrenMap.has(parentPath)) {
childrenMap.set(parentPath, []);
}
childrenMap.get(parentPath)?.push(node);
}
const sortedList: Node[] = [];
const depthFirstTraversal = (path: string): void => {
const node = nodeMap.get(path);
if (node) {
sortedList.push(node);
}
const children = childrenMap.get(path);
if (children) {
for (const child of children) {
if (child.kind === 'folder') {
depthFirstTraversal(child.fullPath);
} else {
sortedList.push(child);
}
}
}
};
const rootChildren = childrenMap.get('') || [];
for (const child of rootChildren) {
depthFirstTraversal(child.fullPath);
}
return sortedList;
}
function compareNodes(a: Node, b: Node): number {
if (a.kind !== b.kind) {
return a.kind === 'folder' ? -1 : 1;
}
return a.name.localeCompare(b.name, undefined, { numeric: true, sensitivity: 'base' });
}

View File

@@ -27,6 +27,7 @@ export const Preview = memo(() => {
const [selectionPoint, setSelectionPoint] = useState<{ x: number; y: number } | null>(null);
const previewURL = useStore(workbenchStore.previewURL);
const previewError = useStore(workbenchStore.previewError);
// Toggle between responsive mode and device mode
const [isDeviceModeOn, setIsDeviceModeOn] = useState(false);
@@ -277,7 +278,9 @@ export const Preview = memo(() => {
/>
</>
) : (
<div className="flex w-full h-full justify-center items-center bg-white">No preview available</div>
<div className="flex w-full h-full justify-center items-center bg-white">
{previewError ? 'Failed to load preview' : 'Preview loading...'}
</div>
)}
{isDeviceModeOn && (

View File

@@ -1,41 +1,18 @@
import { useStore } from '@nanostores/react';
import { motion, type HTMLMotionProps, type Variants } from 'framer-motion';
import { memo, useCallback, useEffect, useState } from 'react';
import { toast } from 'react-toastify';
import {
type OnChangeCallback as OnEditorChange,
type OnScrollCallback as OnEditorScroll,
} from '~/components/editor/codemirror/CodeMirrorEditor';
import { motion, type Variants } from 'framer-motion';
import { memo } from 'react';
import { IconButton } from '~/components/ui/IconButton';
import { PanelHeaderButton } from '~/components/ui/PanelHeaderButton';
import { Slider, type SliderOptions } from '~/components/ui/Slider';
import { workbenchStore, type WorkbenchViewType } from '~/lib/stores/workbench';
import { workbenchStore } from '~/lib/stores/workbench';
import { classNames } from '~/utils/classNames';
import { cubicEasingFn } from '~/utils/easings';
import { renderLogger } from '~/utils/logger';
import { EditorPanel } from './EditorPanel';
import { Preview } from './Preview';
import useViewport from '~/lib/hooks';
import Cookies from 'js-cookie';
interface WorkspaceProps {
chatStarted?: boolean;
isStreaming?: boolean;
}
const viewTransition = { ease: cubicEasingFn };
const sliderOptions: SliderOptions<WorkbenchViewType> = {
left: {
value: 'code',
text: 'Code',
},
right: {
value: 'preview',
text: 'Preview',
},
};
const workbenchVariants = {
closed: {
width: 0,
@@ -53,118 +30,13 @@ const workbenchVariants = {
},
} satisfies Variants;
export const Workbench = memo(({ chatStarted, isStreaming }: WorkspaceProps) => {
export const Workbench = memo(({ chatStarted }: WorkspaceProps) => {
renderLogger.trace('Workbench');
const [isSyncing, setIsSyncing] = useState(false);
const previewURL = useStore(workbenchStore.previewURL);
const showWorkbench = useStore(workbenchStore.showWorkbench);
const selectedFile = useStore(workbenchStore.selectedFile);
const currentDocument = useStore(workbenchStore.currentDocument);
const unsavedFiles = useStore(workbenchStore.unsavedFiles);
const files = useStore(workbenchStore.files);
const selectedView = useStore(workbenchStore.currentView);
const isSmallViewport = useViewport(1024);
const setSelectedView = (view: WorkbenchViewType) => {
workbenchStore.currentView.set(view);
};
useEffect(() => {
if (previewURL) {
setSelectedView('preview');
}
}, [previewURL]);
useEffect(() => {
workbenchStore.setDocuments(files);
}, [files]);
const onEditorChange = useCallback<OnEditorChange>((update) => {
workbenchStore.setCurrentDocumentContent(update.content);
}, []);
const onEditorScroll = useCallback<OnEditorScroll>((position) => {
workbenchStore.setCurrentDocumentScrollPosition(position);
}, []);
const onFileSelect = useCallback((filePath: string | undefined) => {
workbenchStore.setSelectedFile(filePath);
}, []);
const onFileSave = useCallback(() => {
workbenchStore.saveCurrentDocument().catch(() => {
toast.error('Failed to update file content');
});
}, []);
const onFileReset = useCallback(() => {
workbenchStore.resetCurrentDocument();
}, []);
const handleSyncFiles = useCallback(async () => {
setIsSyncing(true);
try {
const directoryHandle = await window.showDirectoryPicker();
await workbenchStore.syncFiles(directoryHandle);
toast.success('Files synced successfully');
} catch (error) {
console.error('Error syncing files:', error);
toast.error('Failed to sync files');
} finally {
setIsSyncing(false);
}
}, []);
const handleApplyChanges = useCallback(async () => {
try {
// Open file picker and get file handle
const [fileHandle] = await (window as any).showOpenFilePicker({
types: [
{
description: 'Text Files',
accept: {
'text/*': ['.txt', '.js', '.ts', '.jsx', '.tsx', '.json', '.html', '.css'],
},
},
],
});
const changesFile = await fileHandle.getFile();
const changesContent = await changesFile.text();
let path = '';
let contents = '';
async function saveCurrentFile() {
if (path) {
await workbenchStore.saveFileContents('/home/project/src/' + path, contents);
}
}
for (const line of changesContent.split('\n')) {
const match = /^FILE (.*)/.exec(line);
if (match) {
await saveCurrentFile();
path = match[1];
contents = '';
continue;
}
contents += line + '\n';
}
await saveCurrentFile();
} catch (error) {
console.error('Error applying changes:', error);
toast.error('Failed to apply changes');
}
}, []);
return (
chatStarted && (
<motion.div
@@ -187,63 +59,7 @@ export const Workbench = memo(({ chatStarted, isStreaming }: WorkspaceProps) =>
<div className="absolute inset-0 px-2 lg:px-6">
<div className="h-full flex flex-col bg-bolt-elements-background-depth-2 border border-bolt-elements-borderColor shadow-sm rounded-lg overflow-hidden">
<div className="flex items-center px-3 py-2 border-b border-bolt-elements-borderColor">
<Slider selected={selectedView} options={sliderOptions} setSelected={setSelectedView} />
<div className="ml-auto" />
{selectedView === 'code' && (
<div className="flex overflow-y-auto">
<PanelHeaderButton className="mr-1 text-sm" onClick={handleApplyChanges}>
<div className="i-ph:code" />
Apply Changes
</PanelHeaderButton>
<PanelHeaderButton
className="mr-1 text-sm"
onClick={() => {
workbenchStore.downloadZip();
}}
>
<div className="i-ph:code" />
Download Code
</PanelHeaderButton>
<PanelHeaderButton className="mr-1 text-sm" onClick={handleSyncFiles} disabled={isSyncing}>
{isSyncing ? <div className="i-ph:spinner" /> : <div className="i-ph:cloud-arrow-down" />}
{isSyncing ? 'Syncing...' : 'Sync Files'}
</PanelHeaderButton>
<PanelHeaderButton
className="mr-1 text-sm"
onClick={() => {
const repoName = prompt(
'Please enter a name for your new GitHub repository:',
'bolt-generated-project',
);
if (!repoName) {
alert('Repository name is required. Push to GitHub cancelled.');
return;
}
const githubUsername = Cookies.get('githubUsername');
const githubToken = Cookies.get('githubToken');
if (!githubUsername || !githubToken) {
const usernameInput = prompt('Please enter your GitHub username:');
const tokenInput = prompt('Please enter your GitHub personal access token:');
if (!usernameInput || !tokenInput) {
alert('GitHub username and token are required. Push to GitHub cancelled.');
return;
}
workbenchStore.pushToGitHub(repoName, usernameInput, tokenInput);
} else {
workbenchStore.pushToGitHub(repoName, githubUsername, githubToken);
}
}}
>
<div className="i-ph:github-logo" />
Push to GitHub
</PanelHeaderButton>
</div>
)}
<IconButton
icon="i-ph:x-circle"
className="-mr-1"
@@ -254,29 +70,7 @@ export const Workbench = memo(({ chatStarted, isStreaming }: WorkspaceProps) =>
/>
</div>
<div className="relative flex-1 overflow-hidden">
<View
initial={{ x: selectedView === 'code' ? 0 : '-100%' }}
animate={{ x: selectedView === 'code' ? 0 : '-100%' }}
>
<EditorPanel
editorDocument={currentDocument}
isStreaming={isStreaming}
selectedFile={selectedFile}
files={files}
unsavedFiles={unsavedFiles}
onFileSelect={onFileSelect}
onEditorScroll={onEditorScroll}
onEditorChange={onEditorChange}
onFileSave={onFileSave}
onFileReset={onFileReset}
/>
</View>
<View
initial={{ x: selectedView === 'preview' ? 0 : '100%' }}
animate={{ x: selectedView === 'preview' ? 0 : '100%' }}
>
<Preview />
</View>
<Preview />
</div>
</div>
</div>
@@ -285,14 +79,3 @@ export const Workbench = memo(({ chatStarted, isStreaming }: WorkspaceProps) =>
)
);
});
interface ViewProps extends HTMLMotionProps<'div'> {
children: JSX.Element;
}
const View = memo(({ children, ...props }: ViewProps) => {
return (
<motion.div className="absolute inset-0" transition={viewTransition} {...props}>
{children}
</motion.div>
);
});

View File

@@ -1,4 +1,3 @@
export * from './useMessageParser';
export * from './usePromptEnhancer';
export * from './useSnapScroll';
export * from './useEditChatDescription';

View File

@@ -9,7 +9,7 @@ export async function pingTelemetry(event: string, data: any) {
data,
};
await fetch('/api/ping-telemetry', {
fetch('/api/ping-telemetry', {
method: 'POST',
body: JSON.stringify(requestBody),
});

View File

@@ -1,158 +0,0 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import git, { type GitAuth, type PromiseFsClient } from 'isomorphic-git';
import http from 'isomorphic-git/http/web';
import Cookies from 'js-cookie';
import { toast } from 'react-toastify';
import type { ProtocolFile } from '~/lib/replay/SimulationPrompt';
import type { FileMap } from '~/lib/stores/files';
const lookupSavedPassword = (url: string) => {
const domain = url.split('/')[2];
const gitCreds = Cookies.get(`git:${domain}`);
if (!gitCreds) {
return null;
}
try {
const { username, password } = JSON.parse(gitCreds || '{}');
return { username, password };
} catch (error) {
console.log(`Failed to parse Git Cookie ${error}`);
return null;
}
};
const saveGitAuth = (url: string, auth: GitAuth) => {
const domain = url.split('/')[2];
Cookies.set(`git:${domain}`, JSON.stringify(auth));
};
export function useGit() {
const [ready, setReady] = useState(false);
const [fs, setFs] = useState<PromiseFsClient>();
const fileData = useRef<FileMap>({});
useEffect(() => {
setFs(getFs(fileData.current));
setReady(true);
}, []);
const gitClone = useCallback(
async (url: string) => {
if (!fs || !ready) {
throw 'Not initialized';
}
const headers: {
[x: string]: string;
} = {
'User-Agent': 'bolt.diy',
};
const auth = lookupSavedPassword(url);
if (auth) {
headers.Authorization = `Basic ${Buffer.from(`${auth.username}:${auth.password}`).toString('base64')}`;
}
try {
await git.clone({
fs,
http,
dir: '/',
url,
depth: 1,
singleBranch: true,
corsProxy: '/api/git-proxy',
headers,
onAuth: (url) => {
let auth = lookupSavedPassword(url);
if (auth) {
return auth;
}
if (confirm('This repo is password protected. Ready to enter a username & password?')) {
auth = {
username: prompt('Enter username'),
password: prompt('Enter password'),
};
return auth;
} else {
return { cancel: true };
}
},
onAuthFailure: (url, _auth) => {
toast.error(`Error Authenticating with ${url.split('/')[2]}`);
},
onAuthSuccess: (url, auth) => {
saveGitAuth(url, auth);
},
});
return { workdir: '/', data: fileData.current };
} catch (error) {
console.error('Git clone error:', error);
throw error;
}
},
[fs, ready],
);
return { ready, gitClone };
}
function createFileFromEncoding(path: string, data: any, encoding: string | undefined): ProtocolFile {
if (typeof data == 'string') {
return { path, content: data };
}
console.error('CreateFileFromEncodingFailed', { data, encoding });
return { path, content: 'CreateFileFromEncodingFailed' };
}
const getFs = (files: FileMap) => ({
promises: {
readFile: async (path: string, _options: any) => {
try {
const result = files[path]?.content;
return result;
} catch (error) {
throw error;
}
},
writeFile: async (path: string, data: any, options: any) => {
const encoding = options.encoding;
files[path] = createFileFromEncoding(path, data, encoding);
},
mkdir: (_path: string, _options: any) => {},
readdir: async (_path: string, _options: any) => {
throw new Error('NYI');
},
rm: async (_path: string, _options: any) => {
throw new Error('NYI');
},
rmdir: async (_path: string, _options: any) => {
throw new Error('NYI');
},
unlink: async (_path: string) => {
throw new Error('NYI');
},
stat: async (_path: string) => {
throw new Error('NYI');
},
lstat: async (_path: string) => {
throw new Error('NYI');
},
readlink: async (_path: string) => {
throw new Error('NYI');
},
symlink: async (_target: string, _path: string) => {
throw new Error('NYI');
},
chmod: (_path: string, _mode: number) => {},
},
});

View File

@@ -1,60 +0,0 @@
import type { Message } from 'ai';
import { useCallback, useState } from 'react';
import { StreamingMessageParser } from '~/lib/runtime/message-parser';
import { workbenchStore } from '~/lib/stores/workbench';
import { createScopedLogger } from '~/utils/logger';
const logger = createScopedLogger('useMessageParser');
const messageParser = new StreamingMessageParser({
callbacks: {
onArtifactOpen: (data) => {
logger.trace('onArtifactOpen', data);
workbenchStore.showWorkbench.set(true);
workbenchStore.addArtifact(data);
},
onArtifactClose: (data) => {
logger.trace('onArtifactClose');
workbenchStore.updateArtifact(data, { closed: true });
},
onActionOpen: (data) => {
logger.trace('onActionOpen', data.action);
},
onActionClose: (data) => {
logger.trace('onActionClose', data.action);
workbenchStore.runAction(data);
},
onActionStream: (data) => {
logger.trace('onActionStream', data.action);
workbenchStore.runAction(data);
},
},
});
export function useMessageParser() {
const [parsedMessages, setParsedMessages] = useState<{ [key: number]: string }>({});
const parseMessages = useCallback((messages: Message[], isLoading: boolean) => {
let reset = false;
if (import.meta.env.DEV && !isLoading) {
reset = true;
messageParser.reset();
}
for (const [index, message] of messages.entries()) {
if (message.role === 'assistant') {
const newParsedContent = messageParser.parse(message.id, message.content);
setParsedMessages((prevParsed) => ({
...prevParsed,
[index]: !reset ? (prevParsed[index] || '') + newParsedContent : newParsedContent,
}));
}
}
}, []);
return { parsedMessages, setParsedMessages, parseMessages };
}

View File

@@ -1,7 +1,7 @@
import { useLoaderData, useNavigate, useSearchParams } from '@remix-run/react';
import { useState, useEffect } from 'react';
import { atom } from 'nanostores';
import type { Message } from 'ai';
import type { Message as BaseMessage } from 'ai';
import { toast } from 'react-toastify';
import { workbenchStore } from '~/lib/stores/workbench';
import { logStore } from '~/lib/stores/logs'; // Import logStore
@@ -17,6 +17,18 @@ import {
import { loadProblem } from '~/components/chat/LoadProblemButton';
import { createAsyncSuspenseValue } from '~/lib/asyncSuspenseValue';
// Messages in a chat's history. The repository may update in response to changes in the messages.
// Each message which changes the repository state must have a repositoryId.
export interface Message extends BaseMessage {
// Describes the state of the project after changes in this message were applied.
repositoryId?: string;
}
export interface ChatState {
description: string;
messages: Message[];
}
export interface ChatHistoryItem {
id: string;
urlId?: string;
@@ -113,19 +125,6 @@ export function useChatHistory() {
return;
}
const { firstArtifact } = workbenchStore;
if (!urlId && firstArtifact?.id) {
const urlId = await getUrlId(db, firstArtifact.id);
navigateChat(urlId);
setUrlId(urlId);
}
if (!description.get() && firstArtifact?.title) {
description.set(firstArtifact?.title);
}
if (initialMessages.length === 0 && !chatId.get()) {
const nextId = await getNextId(db);
@@ -189,3 +188,19 @@ function navigateChat(nextId: string) {
window.history.replaceState({}, '', url);
}
// Get the repositoryId before any changes in the message at the given index.
export function getPreviousRepositoryId(messages: Message[], index: number): string | undefined {
for (let i = index - 1; i >= 0; i--) {
const message = messages[i];
if (message.repositoryId) {
return message.repositoryId;
}
}
return undefined;
}
// Get the repositoryId after applying some messages.
export function getMessagesRepositoryId(messages: Message[]): string | undefined {
return getPreviousRepositoryId(messages, messages.length);
}

View File

@@ -1,9 +1,8 @@
// Support using the Nut API for the development server.
import { debounce } from '~/utils/debounce';
import { assert, ProtocolClient } from './ReplayProtocolClient';
import { workbenchStore } from '~/lib/stores/workbench';
import { toast } from 'react-toastify';
import { recordingMessageHandlerScript } from './Recording';
class DevelopmentServerManager {
// Empty if this chat has been destroyed.
@@ -33,7 +32,7 @@ class DevelopmentServerManager {
this.client = undefined;
}
async setRepositoryContents(contents: string): Promise<string | undefined> {
async setRepositoryContents(repositoryId: string): Promise<string | undefined> {
assert(this.client, 'Chat has been destroyed');
try {
@@ -42,7 +41,8 @@ class DevelopmentServerManager {
method: 'Nut.startDevelopmentServer',
params: {
chatId,
repositoryContents: contents,
repositoryId,
injectedScript: recordingMessageHandlerScript,
},
})) as { url: string };
@@ -56,21 +56,21 @@ class DevelopmentServerManager {
let gActiveDevelopmentServer: DevelopmentServerManager | undefined;
const debounceSetRepositoryContents = debounce(async (repositoryContents: string) => {
export async function updateDevelopmentServer(repositoryId: string) {
workbenchStore.showWorkbench.set(true);
workbenchStore.repositoryId.set(repositoryId);
workbenchStore.previewURL.set(undefined);
workbenchStore.previewError.set(false);
if (!gActiveDevelopmentServer) {
gActiveDevelopmentServer = new DevelopmentServerManager();
}
const url = await gActiveDevelopmentServer.setRepositoryContents(repositoryContents);
const url = await gActiveDevelopmentServer.setRepositoryContents(repositoryId);
if (!url) {
toast.error('Failed to start development server');
if (url) {
workbenchStore.previewURL.set(url);
} else {
workbenchStore.previewError.set(true);
}
workbenchStore.previewURL.set(url);
}, 500);
export async function updateDevelopmentServer(repositoryContents: string) {
workbenchStore.previewURL.set(undefined);
debounceSetRepositoryContents(repositoryContents);
}

View File

@@ -4,8 +4,6 @@ import { toast } from 'react-toastify';
import { sendCommandDedicatedClient } from './ReplayProtocolClient';
import type { ProtocolMessage } from './SimulationPrompt';
import Cookies from 'js-cookie';
import JSZip from 'jszip';
import type { FileArtifact } from '~/utils/folderImport';
import { shouldUseSupabase } from '~/lib/supabase/client';
import {
supabaseListAllProblems,
@@ -241,26 +239,6 @@ export function saveProblemsUsername(username: string) {
Cookies.set(nutProblemsUsernameCookieName, username);
}
export async function extractFileArtifactsFromRepositoryContents(repositoryContents: string): Promise<FileArtifact[]> {
const zip = new JSZip();
await zip.loadAsync(repositoryContents, { base64: true });
const fileArtifacts: FileArtifact[] = [];
for (const [key, object] of Object.entries(zip.files)) {
if (object.dir) {
continue;
}
fileArtifacts.push({
content: await object.async('text'),
path: key,
});
}
return fileArtifacts;
}
export async function submitFeedback(feedback: any): Promise<boolean> {
if (shouldUseSupabase()) {
return supabaseSubmitFeedback(feedback);

View File

@@ -576,20 +576,9 @@ function addRecordingMessageHandler(_messageHandlerId: string) {
};
}
const recordingMessageHandlerScript = `
export const recordingMessageHandlerScript = `
${assert}
${stringToBase64}
${uint8ArrayToBase64}
(${addRecordingMessageHandler})()
`;
export function doInjectRecordingMessageHandler(content: string) {
const headTag = content.indexOf('<head>');
assert(headTag != -1, 'No <head> tag found');
const headEnd = headTag + 6;
const scriptTag = `<script>${recordingMessageHandlerScript}</script>`;
return content.slice(0, headEnd) + scriptTag + content.slice(headEnd);
}

View File

@@ -0,0 +1,25 @@
import { sendCommandDedicatedClient } from "./ReplayProtocolClient";
// Get the contents of a repository as a base64 string of the zip file.
export async function getRepositoryContents(repositoryId: string): Promise<string> {
const rv = await sendCommandDedicatedClient({
method: 'Nut.getRepository',
params: { repositoryId },
}) as { repositoryContents: string };
return rv.repositoryContents;
}
// Remotely create an imported repository from the given contents.
export async function createRepositoryImported(reason: string, repositoryContents: string): Promise<string> {
const rv = await sendCommandDedicatedClient({
method: 'Nut.createRepository',
params: {
repositoryContents,
origin: {
kind: 'imported',
reason,
},
},
}) as { repositoryId: string };
return rv.repositoryId;
}

View File

@@ -8,13 +8,11 @@ interface SimulationPacketServerURL {
url: string;
}
/*
* Simulation data specifying the contents of the repository to set up a dev server
* for static resources.
*/
interface SimulationPacketRepositoryContents {
kind: 'repositoryContents';
contents: string; // base64 encoded zip of the repository.
// Simulation data specifying a repository ID to set up a dev server
// for static resources and any initial database contents.
interface SimulationPacketRepositoryId {
kind: "repositoryId";
repositoryId: string;
}
// Simulation data specifying the viewport size
@@ -178,7 +176,7 @@ interface SimulationPacketLocalStorage {
type SimulationPacketBase =
| SimulationPacketServerURL
| SimulationPacketRepositoryContents
| SimulationPacketRepositoryId
| SimulationPacketViewport
| SimulationPacketLocationHref
| SimulationPacketDocumentURL

View File

@@ -8,17 +8,14 @@ import type { SimulationData, SimulationPacket } from './SimulationData';
import { simulationDataVersion } from './SimulationData';
import { assert, generateRandomId, ProtocolClient } from './ReplayProtocolClient';
import type { MouseData } from './Recording';
import type { FileMap } from '~/lib/stores/files';
import { shouldIncludeFile } from '~/utils/fileUtils';
import { developerSystemPrompt } from '~/lib/common/prompts/prompts';
import { updateDevelopmentServer } from './DevelopmentServer';
import { workbenchStore } from '~/lib/stores/workbench';
import { isEnhancedPromptMessage } from '~/components/chat/Chat.client';
function createRepositoryContentsPacket(contents: string): SimulationPacket {
function createRepositoryIdPacket(repositoryId: string): SimulationPacket {
return {
kind: 'repositoryContents',
contents,
kind: 'repositoryId',
repositoryId,
time: new Date().toISOString(),
};
}
@@ -39,17 +36,12 @@ type ProtocolMessageImage = {
export type ProtocolMessage = ProtocolMessageText | ProtocolMessageImage;
export type ProtocolFile = {
path: string;
content: string;
base64?: boolean;
};
type ChatResponsePartCallback = (response: string) => void;
type ChatMessageMode = 'recording' | 'static' | 'developer';
interface ChatMessageOptions {
chatOnly?: boolean;
developerFiles?: ProtocolFile[];
baseRepositoryId?: string;
onResponsePart?: ChatResponsePartCallback;
}
@@ -66,8 +58,8 @@ class ChatManager {
// Whether all simulation data has been sent.
simulationFinished?: boolean;
// Any repository contents we sent up for this chat.
repositoryContents?: string;
// Any repository ID we specified for this chat.
repositoryId?: string;
// Simulation data for the page itself and any user interactions.
pageData: SimulationData = [];
@@ -96,11 +88,11 @@ class ChatManager {
this.client = undefined;
}
async setRepositoryContents(contents: string) {
async setRepositoryId(repositoryId: string) {
assert(this.client, 'Chat has been destroyed');
this.repositoryContents = contents;
this.repositoryId = repositoryId;
const packet = createRepositoryContentsPacket(contents);
const packet = createRepositoryIdPacket(repositoryId);
const chatId = await this.chatIdPromise;
await this.client.sendCommand({
@@ -117,7 +109,7 @@ class ChatManager {
async addPageData(data: SimulationData) {
assert(this.client, 'Chat has been destroyed');
assert(this.repositoryContents, 'Expected repository contents');
assert(this.repositoryId, 'Expected repository ID');
this.pageData.push(...data);
@@ -139,7 +131,7 @@ class ChatManager {
finishSimulationData(): SimulationData {
assert(this.client, 'Chat has been destroyed');
assert(!this.simulationFinished, 'Simulation has been finished');
assert(this.repositoryContents, 'Expected repository contents');
assert(this.repositoryId, 'Expected repository ID');
this.recordingIdPromise = (async () => {
assert(this.client, 'Chat has been destroyed');
@@ -155,13 +147,13 @@ class ChatManager {
return recordingId;
})();
const allData = [createRepositoryContentsPacket(this.repositoryContents), ...this.pageData];
const allData = [createRepositoryIdPacket(this.repositoryId), ...this.pageData];
this.simulationFinished = true;
return allData;
}
async sendChatMessage(messages: ProtocolMessage[], options?: ChatMessageOptions) {
async sendChatMessage(mode: ChatMessageMode, messages: ProtocolMessage[], options?: ChatMessageOptions) {
assert(this.client, 'Chat has been destroyed');
const responseId = `response-${generateRandomId()}`;
@@ -179,66 +171,39 @@ class ChatManager {
},
);
const modifiedFiles: ProtocolFile[] = [];
const removeFileListener = this.client.listenForMessage(
'Nut.chatModifiedFile',
({ responseId: eventResponseId, file }: { responseId: string; file: ProtocolFile }) => {
if (responseId == eventResponseId) {
console.log('ChatModifiedFile', file);
modifiedFiles.push(file);
}
},
);
const chatId = await this.chatIdPromise;
console.log(
'ChatSendMessage',
new Date().toISOString(),
chatId,
JSON.stringify({ messages, developerFiles: options?.developerFiles }),
JSON.stringify({ mode, messages, baseRepositoryId: options?.baseRepositoryId }),
);
await this.client.sendCommand({
const { repositoryId } = (await this.client.sendCommand({
method: 'Nut.sendChatMessage',
params: { chatId, responseId, messages, chatOnly: options?.chatOnly, developerFiles: options?.developerFiles },
});
params: { chatId, responseId, mode, messages, baseRepositoryId: options?.baseRepositoryId },
})) as { repositoryId?: string };
/*
* The modified files are added at the end as inserting them in the middle of the
* response can cause weird rendering behavior.
*/
for (const file of modifiedFiles) {
const content = `
<boltArtifact id="modified-file-${generateRandomId()}" title="File Changes">
<boltAction type="file" filePath="${file.path}">${file.content}</boltAction>
</boltArtifact>
`;
response += content;
options?.onResponsePart?.(content);
}
console.log('ChatResponse', chatId, response);
console.log('ChatResponse', chatId, repositoryId, response);
removeResponseListener();
removeFileListener();
return response;
return { response, repositoryId };
}
}
// There is only one chat active at a time.
let gChatManager: ChatManager | undefined;
function startChat(repositoryContents: string, pageData: SimulationData) {
function startChat(repositoryId: string, pageData: SimulationData) {
if (gChatManager) {
gChatManager.destroy();
}
gChatManager = new ChatManager();
gChatManager.setRepositoryContents(repositoryContents);
gChatManager.setRepositoryId(repositoryId);
if (pageData.length) {
gChatManager.addPageData(pageData);
@@ -246,18 +211,12 @@ function startChat(repositoryContents: string, pageData: SimulationData) {
}
/*
* Called when the repository contents have changed. We'll start a new chat
* with the same interaction data as any existing chat. The remote development
* server will also be updated.
* Called when the repository has changed. We'll start a new chat
* and update the remote development server.
*/
export async function simulationRepositoryUpdated() {
const { contentBase64: repositoryContents } = await workbenchStore.generateZipBase64();
startChat(repositoryContents, gChatManager?.pageData ?? []);
const { contentBase64: injectedContents } = await workbenchStore.generateZipBase64(
/* injectRecordingMessageHandler */ true,
);
updateDevelopmentServer(injectedContents);
export function simulationRepositoryUpdated(repositoryId: string) {
startChat(repositoryId, []);
updateDevelopmentServer(repositoryId);
}
/*
@@ -267,10 +226,10 @@ export async function simulationRepositoryUpdated() {
export async function simulationReloaded() {
assert(gChatManager, 'Expected to have an active chat');
const repositoryContents = gChatManager.repositoryContents;
assert(repositoryContents, 'Expected active chat to have repository contents');
const repositoryId = gChatManager.repositoryId;
assert(repositoryId, 'Expected active chat to have repository ID');
startChat(repositoryContents, []);
startChat(repositoryId, []);
}
export async function simulationAddData(data: SimulationData) {
@@ -293,7 +252,7 @@ export async function getSimulationRecording(): Promise<string> {
* The repository contents are part of the problem and excluded from the simulation data
* reported for solutions.
*/
gLastUserSimulationData = simulationData.filter((packet) => packet.kind != 'repositoryContents');
gLastUserSimulationData = simulationData.filter((packet) => packet.kind != 'repositoryId');
console.log('SimulationData', new Date().toISOString(), JSON.stringify(simulationData));
@@ -354,7 +313,9 @@ export async function getSimulationEnhancedPrompt(
gLastSimulationChatMessages = messages;
return await gChatManager.sendChatMessage(messages);
const { response } = await gChatManager.sendChatMessage('recording', messages);
return response;
}
export async function shouldUseSimulation(messageInput: string) {
@@ -391,7 +352,7 @@ Here is the user message you need to evaluate: <user_message>${messageInput}</us
},
];
const response = await gChatManager.sendChatMessage(messages, { chatOnly: true });
const { response } = await gChatManager.sendChatMessage('static', messages);
console.log('UseSimulationResponse', response);
@@ -500,24 +461,13 @@ function messagesHaveEnhancedPrompt(messages: Message[]): boolean {
export async function sendDeveloperChatMessage(
messages: Message[],
files: FileMap,
baseRepositoryId: string | undefined,
onResponsePart: ChatResponsePartCallback,
) {
if (!gChatManager) {
gChatManager = new ChatManager();
}
const developerFiles: ProtocolFile[] = [];
for (const [path, file] of Object.entries(files)) {
if (file && shouldIncludeFile(path)) {
developerFiles.push({
path,
content: file.content,
});
}
}
let systemPrompt = developerSystemPrompt;
if (messagesHaveEnhancedPrompt(messages)) {
@@ -536,5 +486,6 @@ Focus specifically on fixing this bug. Do not guess about other problems.
content: systemPrompt,
});
return gChatManager.sendChatMessage(protocolMessages, { chatOnly: true, developerFiles, onResponsePart });
const { repositoryId } = await gChatManager.sendChatMessage('developer', protocolMessages, { baseRepositoryId, onResponsePart });
return repositoryId;
}

View File

@@ -1,95 +0,0 @@
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';
export type EditorDocuments = Record<string, EditorDocument>;
type SelectedFile = WritableAtom<string | undefined>;
export class EditorStore {
#filesStore: FilesStore;
selectedFile: SelectedFile = import.meta.hot?.data.selectedFile ?? atom<string | undefined>();
documents: MapStore<EditorDocuments> = import.meta.hot?.data.documents ?? map({});
currentDocument = computed([this.documents, this.selectedFile], (documents, selectedFile) => {
if (!selectedFile) {
return undefined;
}
return documents[selectedFile];
});
constructor(filesStore: FilesStore) {
this.#filesStore = filesStore;
if (import.meta.hot) {
import.meta.hot.data.documents = this.documents;
import.meta.hot.data.selectedFile = this.selectedFile;
}
}
setDocuments(files: FileMap) {
const previousDocuments = this.documents.value;
this.documents.set(
Object.fromEntries<EditorDocument>(
Object.entries(files)
.map(([filePath, dirent]) => {
if (dirent === undefined) {
return undefined;
}
const previousDocument = previousDocuments?.[filePath];
return [
filePath,
{
value: dirent.content,
filePath,
scroll: previousDocument?.scroll,
},
] as [string, EditorDocument];
})
.filter(Boolean) as Array<[string, EditorDocument]>,
),
);
}
setSelectedFile(filePath: string | undefined) {
this.selectedFile.set(filePath);
}
updateScrollPosition(filePath: string, position: ScrollPosition) {
const documents = this.documents.get();
const documentState = documents[filePath];
if (!documentState) {
return;
}
this.documents.setKey(filePath, {
...documentState,
scroll: position,
});
}
updateFile(filePath: string, newContent: string) {
const documents = this.documents.get();
const documentState = documents[filePath];
if (!documentState) {
return;
}
const currentContent = documentState.value;
const contentChanged = currentContent !== newContent;
if (contentChanged) {
this.documents.setKey(filePath, {
...documentState,
value: newContent,
});
}
}
}

View File

@@ -1,76 +0,0 @@
import { map, type MapStore } from 'nanostores';
import { computeFileModifications } from '~/utils/diff';
import { createScopedLogger } from '~/utils/logger';
import { unreachable } from '~/utils/unreachable';
import type { ProtocolFile } from '~/lib/replay/SimulationPrompt';
const logger = createScopedLogger('FilesStore');
export type FileMap = Record<string, ProtocolFile | undefined>;
export class FilesStore {
/**
* 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<string, string> = import.meta.hot?.data.modifiedFiles ?? new Map();
/**
* Map of files that matches the state of WebContainer.
*/
files: MapStore<FileMap> = import.meta.hot?.data.files ?? map({});
get filesCount() {
return this.#size;
}
constructor() {
if (import.meta.hot) {
import.meta.hot.data.files = this.files;
import.meta.hot.data.modifiedFiles = this.#modifiedFiles;
}
}
getFile(filePath: string) {
const dirent = this.files.get()[filePath];
return dirent;
}
getFileModifications() {
return computeFileModifications(this.files.get(), this.#modifiedFiles);
}
resetFileModifications() {
this.#modifiedFiles.clear();
}
async saveFile(filePath: string, content: string) {
try {
const oldContent = this.getFile(filePath)?.content;
if (!oldContent) {
console.log('CurrentFiles', JSON.stringify(Object.keys(this.files.get())));
unreachable(`Cannot save unknown file ${filePath}`);
}
if (!this.#modifiedFiles.has(filePath)) {
this.#modifiedFiles.set(filePath, oldContent);
}
// we immediately update the file and don't rely on the `change` event coming from the watcher
this.files.setKey(filePath, { path: filePath, content });
logger.info('File updated');
} catch (error) {
logger.error('Failed to update file content\n\n', error);
throw error;
}
}
}

View File

@@ -1,545 +1,26 @@
import { atom, map, type MapStore, type ReadableAtom, type WritableAtom } from 'nanostores';
import type { EditorDocument, ScrollPosition } from '~/components/editor/codemirror/CodeMirrorEditor';
import type { ActionCallbackData, ArtifactCallbackData } from '~/lib/runtime/message-parser';
import { unreachable } from '~/utils/unreachable';
import { EditorStore } from './editor';
import { FilesStore, type FileMap } from './files';
import JSZip from 'jszip';
import { saveAs } from 'file-saver';
import { Octokit, type RestEndpointMethodTypes } from '@octokit/rest';
import { description } from '~/lib/persistence';
import Cookies from 'js-cookie';
import { uint8ArrayToBase64 } from '~/lib/replay/ReplayProtocolClient';
import type { ActionAlert } from '~/types/actions';
import { extractFileArtifactsFromRepositoryContents } from '~/lib/replay/Problems';
import { onRepositoryFileWritten } from '~/components/chat/Chat.client';
import { doInjectRecordingMessageHandler } from '~/lib/replay/Recording';
export interface ArtifactState {
id: string;
title: string;
type?: string;
closed: boolean;
}
export type ArtifactUpdateState = Pick<ArtifactState, 'title' | 'closed'>;
type Artifacts = MapStore<Record<string, ArtifactState>>;
export type WorkbenchViewType = 'code' | 'preview';
import { atom, type WritableAtom } from 'nanostores';
export class WorkbenchStore {
#filesStore = new FilesStore();
#editorStore = new EditorStore(this.#filesStore);
// The current repository.
repositoryId = atom<string | undefined>(undefined);
// Any available preview URL for the current repository.
previewURL = atom<string | undefined>(undefined);
artifacts: Artifacts = import.meta.hot?.data.artifacts ?? map({});
// Whether there was an error loading the preview.
previewError = atom<boolean>(false);
showWorkbench: WritableAtom<boolean> = import.meta.hot?.data.showWorkbench ?? atom(false);
currentView: WritableAtom<WorkbenchViewType> = import.meta.hot?.data.currentView ?? atom('code');
unsavedFiles: WritableAtom<Set<string>> = import.meta.hot?.data.unsavedFiles ?? atom(new Set<string>());
actionAlert: WritableAtom<ActionAlert | undefined> =
import.meta.hot?.data.unsavedFiles ?? atom<ActionAlert | undefined>(undefined);
modifiedFiles = new Set<string>();
artifactIdList: string[] = [];
#globalExecutionQueue = Promise.resolve();
constructor() {
if (import.meta.hot) {
import.meta.hot.data.artifacts = this.artifacts;
import.meta.hot.data.unsavedFiles = this.unsavedFiles;
import.meta.hot.data.showWorkbench = this.showWorkbench;
import.meta.hot.data.currentView = this.currentView;
import.meta.hot.data.actionAlert = this.actionAlert;
}
}
addToExecutionQueue(callback: () => Promise<void>) {
this.#globalExecutionQueue = this.#globalExecutionQueue.then(() => callback());
}
get files() {
return this.#filesStore.files;
}
get currentDocument(): ReadableAtom<EditorDocument | undefined> {
return this.#editorStore.currentDocument;
}
get selectedFile(): ReadableAtom<string | undefined> {
return this.#editorStore.selectedFile;
}
get firstArtifact(): ArtifactState | undefined {
return this.#getArtifact(this.artifactIdList[0]);
}
get filesCount(): number {
return this.#filesStore.filesCount;
}
get alert() {
return this.actionAlert;
}
clearAlert() {
this.actionAlert.set(undefined);
}
setDocuments(files: FileMap) {
this.#editorStore.setDocuments(files);
if (this.#filesStore.filesCount > 0 && this.currentDocument.get() === undefined) {
// we find the first file and select it
for (const [filePath, dirent] of Object.entries(files)) {
if (dirent) {
this.setSelectedFile(filePath);
break;
}
}
}
}
setShowWorkbench(show: boolean) {
this.showWorkbench.set(show);
}
setCurrentDocumentContent(newContent: string) {
const filePath = this.currentDocument.get()?.filePath;
if (!filePath) {
return;
}
const originalContent = this.#filesStore.getFile(filePath)?.content;
const unsavedChanges = originalContent !== undefined && originalContent !== newContent;
this.#editorStore.updateFile(filePath, newContent);
const currentDocument = this.currentDocument.get();
if (currentDocument) {
const previousUnsavedFiles = this.unsavedFiles.get();
if (unsavedChanges && previousUnsavedFiles.has(currentDocument.filePath)) {
return;
}
const newUnsavedFiles = new Set(previousUnsavedFiles);
if (unsavedChanges) {
newUnsavedFiles.add(currentDocument.filePath);
} else {
newUnsavedFiles.delete(currentDocument.filePath);
}
this.unsavedFiles.set(newUnsavedFiles);
}
}
setCurrentDocumentScrollPosition(position: ScrollPosition) {
const editorDocument = this.currentDocument.get();
if (!editorDocument) {
return;
}
const { filePath } = editorDocument;
this.#editorStore.updateScrollPosition(filePath, position);
}
setSelectedFile(filePath: string | undefined) {
this.#editorStore.setSelectedFile(filePath);
}
async saveFile(filePath: string) {
const documents = this.#editorStore.documents.get();
const document = documents[filePath];
if (document === undefined) {
return;
}
await this.#filesStore.saveFile(filePath, document.value);
const newUnsavedFiles = new Set(this.unsavedFiles.get());
newUnsavedFiles.delete(filePath);
this.unsavedFiles.set(newUnsavedFiles);
}
async saveFileContents(filePath: string, contents: string) {
await this.#filesStore.saveFile(filePath, contents);
}
async saveCurrentDocument() {
const currentDocument = this.currentDocument.get();
if (currentDocument === undefined) {
return;
}
await this.saveFile(currentDocument.filePath);
}
resetCurrentDocument() {
const currentDocument = this.currentDocument.get();
if (currentDocument === undefined) {
return;
}
const { filePath } = currentDocument;
const file = this.#filesStore.getFile(filePath);
if (!file) {
return;
}
this.setCurrentDocumentContent(file.content);
}
async saveAllFiles() {
for (const filePath of this.unsavedFiles.get()) {
await this.saveFile(filePath);
}
}
getFileModifcations() {
return this.#filesStore.getFileModifications();
}
resetAllFileModifications() {
this.#filesStore.resetFileModifications();
}
abortAllActions() {
// TODO: what do we wanna do and how do we wanna recover from this?
}
addArtifact({ messageId, title, id, type }: ArtifactCallbackData) {
const artifact = this.#getArtifact(messageId);
if (artifact) {
return;
}
if (!this.artifactIdList.includes(messageId)) {
this.artifactIdList.push(messageId);
}
this.artifacts.setKey(messageId, {
id,
title,
closed: false,
type,
});
}
updateArtifact({ messageId }: ArtifactCallbackData, state: Partial<ArtifactUpdateState>) {
const artifact = this.#getArtifact(messageId);
if (!artifact) {
return;
}
this.artifacts.setKey(messageId, { ...artifact, ...state });
}
runAction(data: ActionCallbackData) {
this.addToExecutionQueue(() => this._runAction(data));
}
async _runAction(data: ActionCallbackData) {
const { messageId } = data;
const artifact = this.#getArtifact(messageId);
if (!artifact) {
unreachable('Artifact not found');
}
if (data.action.type === 'file') {
const { filePath, content } = data.action;
const existingFiles = this.files.get();
this.files.set({
...existingFiles,
[filePath]: {
path: filePath,
content,
},
});
onRepositoryFileWritten();
if (this.selectedFile.value !== filePath) {
this.setSelectedFile(filePath);
}
if (this.currentView.value !== 'code') {
this.currentView.set('code');
}
this.#editorStore.updateFile(filePath, content);
}
}
#getArtifact(id: string) {
const artifacts = this.artifacts.get();
return artifacts[id];
}
private async _generateZip(injectRecordingMessageHandler = false) {
const zip = new JSZip();
const files = this.files.get();
// Get the project name from the description input, or use a default name
const projectName = (description.value ?? 'project').toLocaleLowerCase().split(' ').join('_');
// Generate a simple 6-character hash based on the current timestamp
const timestampHash = Date.now().toString(36).slice(-6);
const uniqueProjectName = `${projectName}_${timestampHash}`;
for (const [filePath, dirent] of Object.entries(files)) {
if (dirent) {
let content = dirent.content;
if (injectRecordingMessageHandler && filePath == 'index.html') {
content = doInjectRecordingMessageHandler(content);
}
// split the path into segments
const pathSegments = filePath.split('/');
// if there's more than one segment, we need to create folders
if (pathSegments.length > 1) {
let currentFolder = zip;
for (let i = 0; i < pathSegments.length - 1; i++) {
currentFolder = currentFolder.folder(pathSegments[i])!;
}
currentFolder.file(pathSegments[pathSegments.length - 1], content);
} else {
// if there's only one segment, it's a file in the root
zip.file(filePath, content);
}
}
}
// Generate the zip file and save it
const content = await zip.generateAsync({ type: 'blob' });
return { content, uniqueProjectName };
}
async downloadZip() {
const { content, uniqueProjectName } = await this._generateZip();
saveAs(content, `${uniqueProjectName}.zip`);
}
async generateZipBase64(injectRecordingMessageHandler = false) {
const { content, uniqueProjectName } = await this._generateZip(injectRecordingMessageHandler);
const buf = await content.arrayBuffer();
const contentBase64 = uint8ArrayToBase64(new Uint8Array(buf));
return { contentBase64, uniqueProjectName };
}
async restoreProjectContentsBase64(messageId: string, contentBase64: string) {
const fileArtifacts = await extractFileArtifactsFromRepositoryContents(contentBase64);
const modifiedFilePaths = new Set<string>();
// Check if any files we know about have different contents in the artifacts.
const files = this.files.get();
const fileRelativePaths = new Set<string>();
for (const [filePath, dirent] of Object.entries(files)) {
if (dirent) {
fileRelativePaths.add(filePath);
const content = dirent.content;
const artifact = fileArtifacts.find((artifact) => artifact.path === filePath);
const artifactContent = artifact?.content ?? '';
if (content != artifactContent) {
modifiedFilePaths.add(filePath);
}
}
}
// Also create any new files in the artifacts.
for (const artifact of fileArtifacts) {
if (!fileRelativePaths.has(artifact.path)) {
modifiedFilePaths.add(artifact.path);
}
}
const actionArtifactId = `restore-contents-artifact-id-${messageId}`;
for (const filePath of modifiedFilePaths) {
console.log('RestoreModifiedFile', filePath);
const artifact = fileArtifacts.find((artifact) => artifact.path === filePath);
const artifactContent = artifact?.content ?? '';
const actionId = `restore-contents-action-${messageId}-${filePath}-${Math.random().toString()}`;
const data: ActionCallbackData = {
actionId,
messageId,
artifactId: actionArtifactId,
action: {
type: 'file',
filePath,
content: artifactContent,
},
};
this.runAction(data);
}
}
async syncFiles(targetHandle: FileSystemDirectoryHandle) {
const files = this.files.get();
const syncedFiles = [];
for (const [filePath, dirent] of Object.entries(files)) {
if (dirent) {
const pathSegments = filePath.split('/');
let currentHandle = targetHandle;
for (let i = 0; i < pathSegments.length - 1; i++) {
currentHandle = await currentHandle.getDirectoryHandle(pathSegments[i], { create: true });
}
// create or get the file
const fileHandle = await currentHandle.getFileHandle(pathSegments[pathSegments.length - 1], {
create: true,
});
// write the file content
const writable = await fileHandle.createWritable();
await writable.write(dirent.content);
await writable.close();
syncedFiles.push(filePath);
}
}
return syncedFiles;
}
async pushToGitHub(repoName: string, githubUsername?: string, ghToken?: string) {
try {
// Use cookies if username and token are not provided
const githubToken = ghToken || Cookies.get('githubToken');
const owner = githubUsername || Cookies.get('githubUsername');
if (!githubToken || !owner) {
throw new Error('GitHub token or username is not set in cookies or provided.');
}
// Initialize Octokit with the auth token
const octokit = new Octokit({ auth: githubToken });
// Check if the repository already exists before creating it
let repo: RestEndpointMethodTypes['repos']['get']['response']['data'];
try {
const resp = await octokit.repos.get({ owner, repo: repoName });
repo = resp.data;
} catch (error) {
if (error instanceof Error && 'status' in error && error.status === 404) {
// Repository doesn't exist, so create a new one
const { data: newRepo } = await octokit.repos.createForAuthenticatedUser({
name: repoName,
private: false,
auto_init: true,
});
repo = newRepo;
} else {
console.log('cannot create repo!');
throw error; // Some other error occurred
}
}
// Get all files
const files = this.files.get();
if (!files || Object.keys(files).length === 0) {
throw new Error('No files found to push');
}
// Create blobs for each file
const blobs = await Promise.all(
Object.entries(files).map(async ([filePath, dirent]) => {
if (dirent) {
const { data: blob } = await octokit.git.createBlob({
owner: repo.owner.login,
repo: repo.name,
content: Buffer.from(dirent.content).toString('base64'),
encoding: 'base64',
});
return { path: filePath, sha: blob.sha };
}
return null;
}),
);
const validBlobs = blobs.filter(Boolean); // Filter out any undefined blobs
if (validBlobs.length === 0) {
throw new Error('No valid files to push');
}
// 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,
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,
base_tree: latestCommitSha,
tree: validBlobs.map((blob) => ({
path: blob!.path,
mode: '100644',
type: 'blob',
sha: blob!.sha,
})),
});
// Create a new commit
const { data: newCommit } = await octokit.git.createCommit({
owner: repo.owner.login,
repo: repo.name,
message: 'Initial commit from your app',
tree: newTree.sha,
parents: [latestCommitSha],
});
// Update the reference
await octokit.git.updateRef({
owner: repo.owner.login,
repo: repo.name,
ref: `heads/${repo.default_branch || 'main'}`, // Handle dynamic branch
sha: newCommit.sha,
});
alert(`Repository created and code pushed: ${repo.html_url}`);
} catch (error) {
console.error('Error pushing to GitHub:', error);
throw error; // Rethrow the error for further handling
}
}
}
export const workbenchStore = new WorkbenchStore();

View File

@@ -1,25 +0,0 @@
import type { LoaderFunctionArgs } from '~/lib/remix-types';
import { json, type MetaFunction } from '~/lib/remix-types';
import { ClientOnly } from 'remix-utils/client-only';
import { BaseChat } from '~/components/chat/BaseChat';
import { GitUrlImport } from '~/components/git/GitUrlImport.client';
import { Header } from '~/components/header/Header';
import BackgroundRays from '~/components/ui/BackgroundRays';
export const meta: MetaFunction = () => {
return [{ title: 'Bolt' }, { name: 'description', content: 'Talk with Bolt, an AI assistant from StackBlitz' }];
};
export async function loader(args: LoaderFunctionArgs) {
return json({ url: args.params.url });
}
export default function Index() {
return (
<div className="flex flex-col h-full w-full bg-bolt-elements-background-depth-1">
<BackgroundRays />
<Header />
<ClientOnly fallback={<BaseChat />}>{() => <GitUrlImport />}</ClientOnly>
</div>
);
}

View File

@@ -1,108 +0,0 @@
import { createTwoFilesPatch } from 'diff';
import type { FileMap } from '~/lib/stores/files';
import { MODIFICATIONS_TAG_NAME } from './constants';
export const modificationsRegex = new RegExp(
`^<${MODIFICATIONS_TAG_NAME}>[\\s\\S]*?<\\/${MODIFICATIONS_TAG_NAME}>\\s+`,
'g',
);
interface ModifiedFile {
type: 'diff' | 'file';
content: string;
}
type FileModifications = Record<string, ModifiedFile>;
export function computeFileModifications(files: FileMap, modifiedFiles: Map<string, string>) {
const modifications: FileModifications = {};
let hasModifiedFiles = false;
for (const [filePath, originalContent] of modifiedFiles) {
const file = files[filePath];
if (!file) {
continue;
}
const unifiedDiff = diffFiles(filePath, originalContent, file.content);
if (!unifiedDiff) {
// files are identical
continue;
}
hasModifiedFiles = true;
if (unifiedDiff.length > file.content.length) {
// if there are lots of changes we simply grab the current file content since it's smaller than the diff
modifications[filePath] = { type: 'file', content: file.content };
} else {
// otherwise we use the diff since it's smaller
modifications[filePath] = { type: 'diff', content: unifiedDiff };
}
}
if (!hasModifiedFiles) {
return undefined;
}
return modifications;
}
/**
* Computes a diff in the unified format. The only difference is that the header is omitted
* because it will always assume that you're comparing two versions of the same file and
* it allows us to avoid the extra characters we send back to the llm.
*
* @see https://www.gnu.org/software/diffutils/manual/html_node/Unified-Format.html
*/
export function diffFiles(fileName: string, oldFileContent: string, newFileContent: string) {
let unifiedDiff = createTwoFilesPatch(fileName, fileName, oldFileContent, newFileContent);
const patchHeaderEnd = `--- ${fileName}\n+++ ${fileName}\n`;
const headerEndIndex = unifiedDiff.indexOf(patchHeaderEnd);
if (headerEndIndex >= 0) {
unifiedDiff = unifiedDiff.slice(headerEndIndex + patchHeaderEnd.length);
}
if (unifiedDiff === '') {
return undefined;
}
return unifiedDiff;
}
/**
* Converts the unified diff to HTML.
*
* Example:
*
* ```html
* <bolt_file_modifications>
* <diff path="/home/project/index.js">
* - console.log('Hello, World!');
* + console.log('Hello, Bolt!');
* </diff>
* </bolt_file_modifications>
* ```
*/
export function fileModificationsToHTML(modifications: FileModifications) {
const entries = Object.entries(modifications);
if (entries.length === 0) {
return undefined;
}
const result: string[] = [`<${MODIFICATIONS_TAG_NAME}>`];
for (const [filePath, { type, content }] of entries) {
result.push(`<${type} path=${JSON.stringify(filePath)}>`, content, `</${type}>`);
}
result.push(`</${MODIFICATIONS_TAG_NAME}>`);
return result.join('\n');
}

View File

@@ -1,13 +1,14 @@
import type { Message } from 'ai';
import { generateId, shouldIncludeFile } from './fileUtils';
import type { Message } from '~/lib/persistence/useChatHistory';
import { generateId } from './fileUtils';
import JSZip from 'jszip';
export interface FileArtifact {
interface FileArtifact {
content: string;
path: string;
}
export async function getFileArtifacts(files: File[]): Promise<FileArtifact[]> {
return Promise.all(
export async function getFileRepositoryContents(files: File[]): Promise<string> {
const artifacts: FileArtifact[] = await Promise.all(
files.map(async (file) => {
return new Promise<FileArtifact>((resolve, reject) => {
const reader = new FileReader();
@@ -25,34 +26,19 @@ export async function getFileArtifacts(files: File[]): Promise<FileArtifact[]> {
});
}),
);
const zip = new JSZip();
for (const { path, content } of artifacts) {
zip.file(path, content);
}
return await zip.generateAsync({ type: "base64" });
}
export const createChatFromFolder = async (
fileArtifacts: FileArtifact[],
binaryFiles: string[],
export function createChatFromFolder(
folderName: string,
): Promise<Message[]> => {
const binaryFilesMessage =
binaryFiles.length > 0
? `\n\nSkipped ${binaryFiles.length} binary files:\n${binaryFiles.map((f) => `- ${f}`).join('\n')}`
: '';
let filesContent = `I've imported the contents of the "${folderName}" folder.${binaryFilesMessage}`;
filesContent += `<boltArtifact id="imported-files" title="Imported Files">`;
for (const file of fileArtifacts) {
if (shouldIncludeFile(file.path)) {
filesContent += `<boltAction type="file" filePath="${file.path}">${file.content}</boltAction>\n\n`;
}
}
filesContent += `</boltArtifact>`;
const filesMessage: Message = {
role: 'assistant',
content: filesContent,
id: generateId(),
createdAt: new Date(),
};
repositoryId: string
): Message[] {
let filesContent = `I've imported the contents of the "${folderName}" folder.`;
const userMessage: Message = {
role: 'user',
@@ -61,7 +47,15 @@ export const createChatFromFolder = async (
createdAt: new Date(),
};
const filesMessage: Message = {
role: 'assistant',
content: filesContent,
id: generateId(),
createdAt: new Date(),
repositoryId,
};
const messages = [userMessage, filesMessage];
return messages;
};
}