Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
# Application
VITE_LOG_LEVEL=debug

# API Keys (replace these with your actual keys)
ANTHROPIC_API_KEY=XXX

1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ dist-ssr
/.cache
/build
.env*
!.env.example
*.vars
.wrangler
_worker.bundle
2 changes: 1 addition & 1 deletion app/components/chat/BaseChat.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,7 @@ export const BaseChat = React.forwardRef<HTMLDivElement, BaseChatProps>(
<div ref={scrollRef} className="flex overflow-y-auto w-full h-full">
<div className={classNames(styles.Chat, 'flex flex-col flex-grow min-w-[var(--chat-min-width)] h-full')}>
{!chatStarted && (
<div id="intro" className="mt-[26vh] max-w-chat mx-auto">
<div id="intro" className="mt-[20vh] max-w-chat mx-auto">
<h1 className="text-5xl text-center font-bold text-bolt-elements-textPrimary mb-2">
Where ideas begin
</h1>
Expand Down
29 changes: 23 additions & 6 deletions app/components/sidebar/HistoryItem.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,11 @@ import { type ChatHistoryItem } from '~/lib/persistence';
interface HistoryItemProps {
item: ChatHistoryItem;
onDelete?: (event: React.UIEvent) => void;
onRename?: (event: React.UIEvent) => void;
onExport?: (event: React.UIEvent) => void;
}

export function HistoryItem({ item, onDelete }: HistoryItemProps) {
export function HistoryItem({ item, onDelete, onRename, onExport }: HistoryItemProps) {
const [hovering, setHovering] = useState(false);
const hoverRef = useRef<HTMLDivElement>(null);

Expand All @@ -16,7 +18,6 @@ export function HistoryItem({ item, onDelete }: HistoryItemProps) {

function mouseEnter() {
setHovering(true);

if (timeout) {
clearTimeout(timeout);
}
Expand All @@ -42,17 +43,33 @@ export function HistoryItem({ item, onDelete }: HistoryItemProps) {
>
<a href={`/chat/${item.urlId}`} className="flex w-full relative truncate block">
{item.description}
<div className="absolute right-0 z-1 top-0 bottom-0 bg-gradient-to-l from-bolt-elements-background-depth-2 group-hover:from-bolt-elements-background-depth-3 to-transparent w-10 flex justify-end group-hover:w-15 group-hover:from-45%">
<div className="absolute right-0 z-1 top-0 bottom-0 bg-gradient-to-l from-bolt-elements-background-depth-2 group-hover:from-bolt-elements-background-depth-3 to-transparent w-10 flex justify-end group-hover:w-32 group-hover:from-45%">
{hovering && (
<div className="flex items-center p-1 text-bolt-elements-textSecondary hover:text-bolt-elements-item-contentDanger">
<div className="flex items-center gap-1 p-1 text-bolt-elements-textSecondary">
<button
className="i-ph:pencil-simple scale-110 hover:text-bolt-elements-textPrimary"
onClick={(event) => {
event.preventDefault();
onRename?.(event);
}}
title="Rename"
/>
<button
className="i-ph:export scale-110 hover:text-bolt-elements-textPrimary"
onClick={(event) => {
event.preventDefault();
onExport?.(event);
}}
title="Export as JSON"
/>
<Dialog.Trigger asChild>
<button
className="i-ph:trash scale-110"
className="i-ph:trash scale-110 hover:text-bolt-elements-item-contentDanger"
onClick={(event) => {
// we prevent the default so we don't trigger the anchor above
event.preventDefault();
onDelete?.(event);
}}
title="Delete"
/>
</Dialog.Trigger>
</div>
Expand Down
117 changes: 96 additions & 21 deletions app/components/sidebar/Menu.client.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { toast } from 'react-toastify';
import { Dialog, DialogButton, DialogDescription, DialogRoot, DialogTitle } from '~/components/ui/Dialog';
import { IconButton } from '~/components/ui/IconButton';
import { ThemeSwitch } from '~/components/ui/ThemeSwitch';
import { db, deleteById, getAll, chatId, type ChatHistoryItem } from '~/lib/persistence';
import { db, deleteById, getAll, chatId, type ChatHistoryItem, setMessages } from '~/lib/persistence';
import { cubicEasingFn } from '~/utils/easings';
import { logger } from '~/utils/logger';
import { HistoryItem } from './HistoryItem';
Expand All @@ -31,13 +31,17 @@ const menuVariants = {
},
} satisfies Variants;

type DialogContent = { type: 'delete'; item: ChatHistoryItem } | null;
type DialogContent =
| { type: 'delete'; item: ChatHistoryItem }
| { type: 'rename'; item: ChatHistoryItem }
| null;

export function Menu() {
const menuRef = useRef<HTMLDivElement>(null);
const [list, setList] = useState<ChatHistoryItem[]>([]);
const [open, setOpen] = useState(false);
const [dialogContent, setDialogContent] = useState<DialogContent>(null);
const [newName, setNewName] = useState('');

const loadEntries = useCallback(() => {
if (db) {
Expand Down Expand Up @@ -68,6 +72,43 @@ export function Menu() {
}
}, []);

const renameItem = useCallback(async (event: React.UIEvent, item: ChatHistoryItem, newDescription: string) => {
event.preventDefault();

if (db) {
try {
await setMessages(db, item.id, item.messages, item.urlId, newDescription);
loadEntries();
toast.success('Chat renamed successfully');
} catch (error) {
toast.error('Failed to rename chat');
logger.error(error);
}
}
}, []);

const exportItem = useCallback((event: React.UIEvent, item: ChatHistoryItem) => {
event.preventDefault();

const exportData = {
description: item.description,
messages: item.messages,
timestamp: item.timestamp
};

const blob = new Blob([JSON.stringify(exportData, null, 2)], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `chat-${item.description || 'export'}.json`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);

toast.success('Chat exported successfully');
}, []);

const closeDialog = () => {
setDialogContent(null);
};
Expand Down Expand Up @@ -102,24 +143,16 @@ export function Menu() {
return (
<motion.div
ref={menuRef}
initial="closed"
animate={open ? 'open' : 'closed'}
className="fixed top-0 bottom-0 w-[300px] bg-bolt-elements-background-depth-2 border-r border-bolt-elements-borderColor z-sidebar"
variants={menuVariants}
className="flex flex-col side-menu fixed top-0 w-[350px] h-full bg-bolt-elements-background-depth-2 border-r rounded-r-3xl border-bolt-elements-borderColor z-sidebar shadow-xl shadow-bolt-elements-sidebar-dropdownShadow text-sm"
animate={open ? 'open' : 'closed'}
initial="closed"
>
<div className="flex items-center h-[var(--header-height)]">{/* Placeholder */}</div>
<div className="flex-1 flex flex-col h-full w-full overflow-hidden">
<div className="p-4">
<a
href="/"
className="flex gap-2 items-center bg-bolt-elements-sidebar-buttonBackgroundDefault text-bolt-elements-sidebar-buttonText hover:bg-bolt-elements-sidebar-buttonBackgroundHover rounded-md p-2 transition-theme"
>
<span className="inline-block i-bolt:chat scale-110" />
Start new chat
</a>
<div className="h-full flex flex-col">
<div className="sticky top-0 z-1 bg-bolt-elements-background-depth-2 p-4 pt-12 flex justify-between items-center border-b border-bolt-elements-borderColor">
<div className="text-bolt-elements-textPrimary font-medium">History</div>
</div>
<div className="text-bolt-elements-textPrimary font-medium pl-6 pr-5 my-2">Your Chats</div>
<div className="flex-1 overflow-scroll pl-4 pr-5 pb-5">
<div className="flex-1 overflow-y-auto p-2 pb-16">
{list.length === 0 && <div className="pl-2 text-bolt-elements-textTertiary">No previous conversations</div>}
<DialogRoot open={dialogContent !== null}>
{binDates(list).map(({ category, items }) => (
Expand All @@ -128,7 +161,16 @@ export function Menu() {
{category}
</div>
{items.map((item) => (
<HistoryItem key={item.id} item={item} onDelete={() => setDialogContent({ type: 'delete', item })} />
<HistoryItem
key={item.id}
item={item}
onDelete={() => setDialogContent({ type: 'delete', item })}
onRename={() => {
setNewName(item.description || '');
setDialogContent({ type: 'rename', item });
}}
onExport={(event) => exportItem(event, item)}
/>
))}
</div>
))}
Expand Down Expand Up @@ -160,12 +202,45 @@ export function Menu() {
</div>
</>
)}
{dialogContent?.type === 'rename' && (
<>
<DialogTitle>Rename Chat</DialogTitle>
<DialogDescription asChild>
<div>
<input
type="text"
value={newName}
onChange={(e) => setNewName(e.target.value)}
className="w-full p-2 mt-2 text-bolt-elements-textPrimary bg-bolt-elements-background-depth-1 border border-bolt-elements-borderColor rounded-md focus:outline-none focus:border-bolt-elements-borderColorFocus"
placeholder="Enter new name"
autoFocus
/>
</div>
</DialogDescription>
<div className="px-5 pb-4 bg-bolt-elements-background-depth-2 flex gap-2 justify-end">
<DialogButton type="secondary" onClick={closeDialog}>
Cancel
</DialogButton>
<DialogButton
type="primary"
onClick={(event) => {
if (newName.trim()) {
renameItem(event, dialogContent.item, newName.trim());
closeDialog();
}
}}
>
Rename
</DialogButton>
</div>
</>
)}
</Dialog>
</DialogRoot>
</div>
<div className="flex items-center border-t border-bolt-elements-borderColor p-4">
<ThemeSwitch className="ml-auto" />
</div>
</div>
<div className="absolute bottom-4 right-4">
<ThemeSwitch />
</div>
</motion.div>
);
Expand Down
73 changes: 61 additions & 12 deletions app/components/workbench/Workbench.client.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,19 +3,24 @@ import { motion, type HTMLMotionProps, type Variants } from 'framer-motion';
import { computed } from 'nanostores';
import { memo, useCallback, useEffect } from 'react';
import { toast } from 'react-toastify';
import JSZip from 'jszip';
import type { FileSystemAPI } from '@webcontainer/api';
import {
type OnChangeCallback as OnEditorChange,
type OnScrollCallback as OnEditorScroll,
type ScrollPosition,
} from '~/components/editor/codemirror/CodeMirrorEditor';
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 { webcontainer } from '~/lib/webcontainer';
import { classNames } from '~/utils/classNames';
import { cubicEasingFn } from '~/utils/easings';
import { renderLogger } from '~/utils/logger';
import { EditorPanel } from './EditorPanel';
import { Preview } from './Preview';
import type { PreviewInfo } from '~/lib/stores/previews';

interface WorkspaceProps {
chatStarted?: boolean;
Expand Down Expand Up @@ -55,7 +60,7 @@ const workbenchVariants = {
export const Workbench = memo(({ chatStarted, isStreaming }: WorkspaceProps) => {
renderLogger.trace('Workbench');

const hasPreview = useStore(computed(workbenchStore.previews, (previews) => previews.length > 0));
const hasPreview = useStore(computed(workbenchStore.previews, (previews: PreviewInfo[]) => previews.length > 0));
const showWorkbench = useStore(workbenchStore.showWorkbench);
const selectedFile = useStore(workbenchStore.selectedFile);
const currentDocument = useStore(workbenchStore.currentDocument);
Expand All @@ -77,11 +82,11 @@ export const Workbench = memo(({ chatStarted, isStreaming }: WorkspaceProps) =>
workbenchStore.setDocuments(files);
}, [files]);

const onEditorChange = useCallback<OnEditorChange>((update) => {
const onEditorChange = useCallback<OnEditorChange>((update: { content: string }) => {
workbenchStore.setCurrentDocumentContent(update.content);
}, []);

const onEditorScroll = useCallback<OnEditorScroll>((position) => {
const onEditorScroll = useCallback<OnEditorScroll>((position: ScrollPosition) => {
workbenchStore.setCurrentDocumentScrollPosition(position);
}, []);

Expand Down Expand Up @@ -122,15 +127,59 @@ export const Workbench = memo(({ chatStarted, isStreaming }: WorkspaceProps) =>
<Slider selected={selectedView} options={sliderOptions} setSelected={setSelectedView} />
<div className="ml-auto" />
{selectedView === 'code' && (
<PanelHeaderButton
className="mr-1 text-sm"
onClick={() => {
workbenchStore.toggleTerminal(!workbenchStore.showTerminal.get());
}}
>
<div className="i-ph:terminal" />
Toggle Terminal
</PanelHeaderButton>
<>
<PanelHeaderButton
className="mr-1 text-sm"
onClick={() => {
workbenchStore.toggleTerminal(!workbenchStore.showTerminal.get());
}}
>
<div className="i-ph:terminal" />
Toggle Terminal
</PanelHeaderButton>
<PanelHeaderButton
className="mr-1 text-sm"
onClick={async () => {
try {
const webcontainerInstance = await webcontainer;
const files = await webcontainerInstance.fs.readdir('/', { withFileTypes: true });
const zip = new JSZip();

const processDirectory = async (dirPath: string, entries: Awaited<ReturnType<FileSystemAPI['readdir']>>) => {
for (const entry of entries) {
const fullPath = `${dirPath}/${entry.name}`;
if (entry.isFile()) {
const content = await webcontainerInstance.fs.readFile(fullPath);
zip.file(fullPath.slice(1), content); // Remove leading slash
} else if (entry.isDirectory() && entry.name !== 'node_modules') {
const subEntries = await webcontainerInstance.fs.readdir(fullPath, { withFileTypes: true });
await processDirectory(fullPath, subEntries);
}
}
};

await processDirectory('', files);

const blob = await zip.generateAsync({ type: 'blob' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'project.zip';
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
toast.success('Project downloaded successfully');
} catch (error) {
console.error('Failed to download project:', error);
toast.error('Failed to download project');
}
}}
>
<div className="i-ph:download" />
Download Project
</PanelHeaderButton>
</>
)}
<IconButton
icon="i-ph:x-circle"
Expand Down
2 changes: 1 addition & 1 deletion app/lib/.server/llm/model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,5 +5,5 @@ export function getAnthropicModel(apiKey: string) {
apiKey,
});

return anthropic('claude-3-5-sonnet-20240620');
return anthropic('claude-3-5-sonnet-20241022');
}
Loading
Loading