diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000000..8264cc6549 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,7 @@ +node_modules +.git +.gitignore +Dockerfile +docker-compose.yml +.env +.env.local diff --git a/.env.example b/.env.example new file mode 100644 index 0000000000..f14b3be611 --- /dev/null +++ b/.env.example @@ -0,0 +1,6 @@ +# Application +VITE_LOG_LEVEL=debug + +# API Keys (replace these with your actual keys) +ANTHROPIC_API_KEY=XXX + diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000000..5f0889ce91 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,11 @@ +# To get started with Dependabot version updates, you'll need to specify which +# package ecosystems to update and where the package manifests are located. +# Please see the documentation for all configuration options: +# https://docs.github.com/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file + +version: 2 +updates: + - package-ecosystem: "npm" # See documentation for possible values + directory: "/" # Location of package manifests + schedule: + interval: "weekly" diff --git a/.gitignore b/.gitignore index 965ef504ae..18bc3e3b2c 100644 --- a/.gitignore +++ b/.gitignore @@ -25,6 +25,7 @@ dist-ssr /.cache /build .env* +!.env.example *.vars .wrangler _worker.bundle diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000000..b4569f80b6 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,30 @@ +FROM node:20.18.1-bookworm-slim + +WORKDIR /app + +# Install pnpm (matches packageManager in package.json) +RUN npm install -g pnpm@9.4.0 + +# Copy package files first +COPY package.json pnpm-lock.yaml ./ + +# Clean install dependencies with legacy peer deps +RUN rm -rf node_modules +RUN pnpm install --no-frozen-lockfile +RUN pnpm rebuild + +# Copy source code +COPY . . + +# Create empty .env.local if it doesn't exist +RUN touch .env.local + +# Expose Vite's default port +EXPOSE 5173 + +# Required for WebContainer API +ENV NODE_ENV=development +ENV VITE_LOG_LEVEL=debug + +# Start Vite directly instead of through Remix +CMD ["pnpm", "exec", "vite", "dev", "--host"] diff --git a/app/components/chat/Artifact.tsx b/app/components/chat/Artifact.tsx index 9de52dd94a..04e603cbe0 100644 --- a/app/components/chat/Artifact.tsx +++ b/app/components/chat/Artifact.tsx @@ -26,7 +26,7 @@ interface ArtifactProps { export const Artifact = memo(({ messageId }: ArtifactProps) => { const userToggledActions = useRef(false); - const [showActions, setShowActions] = useState(false); + const [showActions, setShowActions] = useState(false); const artifacts = useStore(workbenchStore.artifacts); const artifact = artifacts[messageId]; @@ -43,10 +43,10 @@ export const Artifact = memo(({ messageId }: ArtifactProps) => { }; useEffect(() => { - if (actions.length && !showActions && !userToggledActions.current) { + if (actions.length > 0 && !showActions && !userToggledActions.current) { setShowActions(true); } - }, [actions]); + }, [actions, showActions]); return (
@@ -60,7 +60,7 @@ export const Artifact = memo(({ messageId }: ArtifactProps) => { >
{artifact?.title}
-
Click to open Workbench
+
Click to open Workbench
@@ -102,14 +102,14 @@ export const Artifact = memo(({ messageId }: ArtifactProps) => { }); interface ShellCodeBlockProps { - classsName?: string; + className?: string; code: string; } -function ShellCodeBlock({ classsName, code }: ShellCodeBlockProps) { +function ShellCodeBlock({ className, code }: ShellCodeBlockProps) { return (
{
{type === 'shell' && ( | undefined; + textareaRef?: React.RefObject | undefined; messageRef?: RefCallback | undefined; scrollRef?: RefCallback | undefined; showChat?: boolean; @@ -21,16 +25,20 @@ interface BaseChatProps { enhancingPrompt?: boolean; promptEnhanced?: boolean; input?: string; + files?: File[]; + imageDataList?: string[]; handleStop?: () => void; sendMessage?: (event: React.UIEvent, messageInput?: string) => void; handleInputChange?: (event: React.ChangeEvent) => void; enhancePrompt?: () => void; + onFileUpload?: (files: FileList) => void; + onRemoveFile?: (index: number) => void; } const EXAMPLE_PROMPTS = [ { text: 'Build a todo app in React using Tailwind' }, { text: 'Build a simple blog using Astro' }, - { text: 'Create a cookie consent form using Material UI' }, + { text: 'Make a login form using React' }, { text: 'Make a space invaders game' }, { text: 'How do I center a div?' }, ]; @@ -54,6 +62,10 @@ export const BaseChat = React.forwardRef( handleInputChange, enhancePrompt, handleStop, + files, + imageDataList, + onFileUpload, + onRemoveFile, }, ref, ) => { @@ -72,7 +84,7 @@ export const BaseChat = React.forwardRef(
{!chatStarted && ( -
+

Where ideas begin

@@ -173,6 +185,21 @@ export const BaseChat = React.forwardRef( )} + onFileUpload?.(e.target.files!)} + /> + document.getElementById('image-upload')?.click()} + > +
+ + {() => }
{input.length > 3 ? (
@@ -180,27 +207,64 @@ export const BaseChat = React.forwardRef(
) : null}
+ {files && files.length > 0 && ( +
+ +
+ )}
{/* Ghost Element */}
{!chatStarted && ( -
-
- {EXAMPLE_PROMPTS.map((examplePrompt, index) => { - return ( - - ); - })} +
+
+ { + sendMessage?.(new Event('click') as any, description); + messages.forEach((message) => { + sendMessage?.(new Event('click') as any, message.content); + }); + }} + /> + { + sendMessage?.(new Event('click') as any, description); + messages.forEach((message) => { + sendMessage?.(new Event('click') as any, message.content); + }); + }} + /> +
+
+ or start a blank app with your favorite stack +
+
+
+ {EXAMPLE_PROMPTS.map((examplePrompt, index) => { + return ( + + ); + })} +
+
)} diff --git a/app/components/chat/Chat.client.tsx b/app/components/chat/Chat.client.tsx index dff7598e40..6c16407976 100644 --- a/app/components/chat/Chat.client.tsx +++ b/app/components/chat/Chat.client.tsx @@ -4,6 +4,7 @@ import { useChat } from 'ai/react'; import { useAnimate } from 'framer-motion'; import { memo, useEffect, useRef, useState } from 'react'; import { cssTransition, toast, ToastContainer } from 'react-toastify'; +import { BaseChat } from './BaseChat'; import { useMessageParser, usePromptEnhancer, useShortcuts, useSnapScroll } from '~/lib/hooks'; import { useChatHistory } from '~/lib/persistence'; import { chatStore } from '~/lib/stores/chat'; @@ -11,7 +12,6 @@ import { workbenchStore } from '~/lib/stores/workbench'; import { fileModificationsToHTML } from '~/utils/diff'; import { cubicEasingFn } from '~/utils/easings'; import { createScopedLogger, renderLogger } from '~/utils/logger'; -import { BaseChat } from './BaseChat'; const toastAnimation = cssTransition({ enter: 'animated fadeInRight', @@ -68,6 +68,8 @@ export const ChatImpl = memo(({ initialMessages, storeMessageHistory }: ChatProp useShortcuts(); const textareaRef = useRef(null); + const [files, setFiles] = useState([]); + const [imageDataList, setImageDataList] = useState([]); const [chatStarted, setChatStarted] = useState(initialMessages.length > 0); @@ -146,6 +148,25 @@ export const ChatImpl = memo(({ initialMessages, storeMessageHistory }: ChatProp setChatStarted(true); }; + const handleFileUpload = (fileList: FileList) => { + const newFiles = Array.from(fileList); + setFiles((prevFiles) => [...prevFiles, ...newFiles]); + + newFiles.forEach((file) => { + const reader = new FileReader(); + + reader.onloadend = () => { + setImageDataList((prev) => [...prev, reader.result as string]); + }; + reader.readAsDataURL(file); + }); + }; + + const handleRemoveFile = (index: number) => { + setFiles((prevFiles) => prevFiles.filter((_, i) => i !== index)); + setImageDataList((prevList) => prevList.filter((_, i) => i !== index)); + }; + const sendMessage = async (_event: React.UIEvent, messageInput?: string) => { const _input = messageInput || input; @@ -153,13 +174,6 @@ export const ChatImpl = memo(({ initialMessages, storeMessageHistory }: ChatProp return; } - /** - * @note (delm) Usually saving files shouldn't take long but it may take longer if there - * many unsaved files. In that case we need to block user input and show an indicator - * of some kind so the user is aware that something is happening. But I consider the - * happy case to be no unsaved files and I would expect users to save their changes - * before they send another message. - */ await workbenchStore.saveAllFiles(); const fileModifications = workbenchStore.getFileModifcations(); @@ -170,29 +184,28 @@ export const ChatImpl = memo(({ initialMessages, storeMessageHistory }: ChatProp if (fileModifications !== undefined) { const diff = fileModificationsToHTML(fileModifications); - - /** - * If we have file modifications we append a new user message manually since we have to prefix - * the user input with the file modifications and we don't want the new user input to appear - * in the prompt. Using `append` is almost the same as `handleSubmit` except that we have to - * manually reset the input and we'd have to manually pass in file attachments. However, those - * aren't relevant here. - */ append({ role: 'user', content: `${diff}\n\n${_input}` }); - - /** - * After sending a new message we reset all modifications since the model - * should now be aware of all the changes. - */ workbenchStore.resetAllFileModifications(); } else { - append({ role: 'user', content: _input }); + append({ + role: 'user', + content: [ + { + type: 'text', + text: `${_input}`, + }, + ...imageDataList.map((imageData) => ({ + type: 'image', + image: imageData, + })), + ] as any, + }); } setInput(''); - + setFiles([]); + setImageDataList([]); resetEnhancer(); - textareaRef.current?.blur(); }; @@ -229,6 +242,10 @@ export const ChatImpl = memo(({ initialMessages, storeMessageHistory }: ChatProp scrollTextArea(); }); }} + files={files} + imageDataList={imageDataList} + onFileUpload={handleFileUpload} + onRemoveFile={handleRemoveFile} /> ); }); diff --git a/app/components/chat/ChatHistory.client.tsx b/app/components/chat/ChatHistory.client.tsx new file mode 100644 index 0000000000..45ec78ea26 --- /dev/null +++ b/app/components/chat/ChatHistory.client.tsx @@ -0,0 +1,89 @@ +import { useNavigate, useLoaderData } from '@remix-run/react'; +import { useEffect, useState } from 'react'; +import { IconButton } from '~/components/ui/IconButton'; +import { getAll } from '~/lib/persistence/db'; +import type { ChatHistoryItem } from '~/lib/persistence/useChatHistory'; +import { db } from '~/lib/persistence/useChatHistory'; +import { classNames } from '~/utils/classNames'; +import { createScopedLogger } from '~/utils/logger'; + +const logger = createScopedLogger('ChatHistory'); + +export function ChatHistory() { + const [history, setHistory] = useState([]); + const [isOpen, setIsOpen] = useState(false); + const navigate = useNavigate(); + const { id: currentId } = useLoaderData<{ id?: string }>(); + + useEffect(() => { + if (!db) { + return; + } + + const loadHistory = async () => { + try { + const items = await getAll(db!); + + // filter items to only show history for the current chat + const filteredItems = items + .filter((item) => item.urlId === currentId || item.id === currentId) + .sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime()); + setHistory(filteredItems); + } catch (error) { + logger.error('Failed to load chat history', error); + } + }; + + loadHistory(); + }, [currentId]); + + const handleRestore = (item: ChatHistoryItem) => { + navigate(`/chat/${item.id}`); + setIsOpen(false); + }; + + if (!db || history.length === 0) { + return null; + } + + return ( +
+ setIsOpen(!isOpen)} + className={classNames({ + 'text-bolt-elements-item-contentAccent!': isOpen, + })} + > +
+ + + {isOpen && ( + <> +
setIsOpen(false)} /> +
+
+

Chat History

+
+
+ {history.map((item) => ( + + ))} +
+
+ + )} +
+ ); +} diff --git a/app/components/chat/CodeBlock.tsx b/app/components/chat/CodeBlock.tsx index e48913ce55..3a3dd706bb 100644 --- a/app/components/chat/CodeBlock.tsx +++ b/app/components/chat/CodeBlock.tsx @@ -1,10 +1,9 @@ import { memo, useEffect, useState } from 'react'; import { bundledLanguages, codeToHtml, isSpecialLang, type BundledLanguage, type SpecialLanguage } from 'shiki'; +import styles from './CodeBlock.module.scss'; import { classNames } from '~/utils/classNames'; import { createScopedLogger } from '~/utils/logger'; -import styles from './CodeBlock.module.scss'; - const logger = createScopedLogger('CodeBlock'); interface CodeBlockProps { @@ -46,7 +45,7 @@ export const CodeBlock = memo( }; processCode(); - }, [code]); + }, [code, language, theme]); return (
diff --git a/app/components/chat/FilePreview.tsx b/app/components/chat/FilePreview.tsx new file mode 100644 index 0000000000..0500d03b91 --- /dev/null +++ b/app/components/chat/FilePreview.tsx @@ -0,0 +1,35 @@ +import React from 'react'; + +interface FilePreviewProps { + files: File[]; + imageDataList: string[]; + onRemove: (index: number) => void; +} + +const FilePreview: React.FC = ({ files, imageDataList, onRemove }) => { + if (!files || files.length === 0) { + return null; + } + + return ( +
+ {files.map((file, index) => ( +
+ {imageDataList[index] && ( +
+ {file.name} + +
+ )} +
+ ))} +
+ ); +}; + +export default FilePreview; diff --git a/app/components/chat/GitCloneButton.tsx b/app/components/chat/GitCloneButton.tsx new file mode 100644 index 0000000000..587867a6d8 --- /dev/null +++ b/app/components/chat/GitCloneButton.tsx @@ -0,0 +1,80 @@ +import type { Message } from 'ai'; +import ignore from 'ignore'; +import { useState } from 'react'; +import { GitCloneDialog } from '~/components/git/GitCloneDialog'; +import WithTooltip from '~/components/ui/Tooltip'; +import { IGNORE_PATTERNS } from '~/constants/ignorePatterns'; +import { useGit } from '~/lib/hooks/useGit'; + +const ig = ignore().add(IGNORE_PATTERNS); +const generateId = () => Math.random().toString(36).substring(2, 15); + +interface GitCloneButtonProps { + className?: string; + importChat?: (description: string, messages: Message[]) => Promise; +} + +export default function GitCloneButton({ importChat }: GitCloneButtonProps) { + const { ready, gitClone } = useGit(); + const [showDialog, setShowDialog] = useState(false); + + const handleClone = async (repoUrl: string) => { + if (!ready) { + return; + } + + const { workdir, data } = await gitClone(repoUrl); + + if (importChat) { + const filePaths = Object.keys(data).filter((filePath) => !ig.ignores(filePath)); + console.log(filePaths); + + const textDecoder = new TextDecoder('utf-8'); + const message: Message = { + role: 'assistant', + content: `Cloning the repo ${repoUrl} into ${workdir} + + ${filePaths + .map((filePath) => { + const { data: content, encoding } = data[filePath]; + + if (encoding === 'utf8') { + return ` +${content} +`; + } else if (content instanceof Uint8Array) { + return ` +${textDecoder.decode(content)} +`; + } else { + return ''; + } + }) + .join('\n')} +`, + id: generateId(), + createdAt: new Date(), + }; + console.log(JSON.stringify(message)); + + importChat(`Git Project:${repoUrl.split('/').slice(-1)[0]}`, [message]); + } + }; + + return ( + <> + + + + + setShowDialog(false)} onClone={handleClone} /> + + ); +} diff --git a/app/components/chat/ImportFolderButton.tsx b/app/components/chat/ImportFolderButton.tsx new file mode 100644 index 0000000000..67c3bda813 --- /dev/null +++ b/app/components/chat/ImportFolderButton.tsx @@ -0,0 +1,150 @@ +import type { Message } from 'ai'; +import ignore from 'ignore'; +import React from 'react'; +import { toast } from 'react-toastify'; +import WithTooltip from '~/components/ui/Tooltip'; +import { IGNORE_PATTERNS } from '~/constants/ignorePatterns'; + +interface ImportFolderButtonProps { + className?: string; + importChat?: (description: string, messages: Message[]) => Promise; +} + +const ig = ignore().add(IGNORE_PATTERNS); +const generateId = () => Math.random().toString(36).substring(2, 15); + +const isBinaryFile = async (file: File): Promise => { + const chunkSize = 1024; // read the first 1 KB of the file + const buffer = new Uint8Array(await file.slice(0, chunkSize).arrayBuffer()); + + for (let i = 0; i < buffer.length; i++) { + const byte = buffer[i]; + + if (byte === 0 || (byte < 32 && byte !== 9 && byte !== 10 && byte !== 13)) { + return true; // found a binary character + } + } + + return false; +}; + +export const ImportFolderButton: React.FC = ({ className, importChat }) => { + const shouldIncludeFile = (path: string): boolean => { + return !ig.ignores(path); + }; + + const createChatFromFolder = async (files: File[], binaryFiles: string[]) => { + const fileArtifacts = await Promise.all( + files.map(async (file) => { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + + reader.onload = () => { + const content = reader.result as string; + const relativePath = file.webkitRelativePath.split('/').slice(1).join('/'); + resolve( + ` +${content} +`, + ); + }; + reader.onerror = reject; + reader.readAsText(file); + }); + }), + ); + + const binaryFilesMessage = + binaryFiles.length > 0 + ? `\n\nSkipped ${binaryFiles.length} binary files:\n${binaryFiles.map((f) => `- ${f}`).join('\n')}` + : ''; + + const message: Message = { + role: 'assistant', + content: `I'll help you set up these files.${binaryFilesMessage} + + +${fileArtifacts.join('\n\n')} +`, + id: generateId(), + createdAt: new Date(), + }; + + const userMessage: Message = { + role: 'user', + id: generateId(), + content: 'Import my files', + createdAt: new Date(), + }; + + const description = `Folder Import: ${files[0].webkitRelativePath.split('/')[0]}`; + + if (importChat) { + await importChat(description, [userMessage, message]); + } + }; + + return ( + +
+ { + const allFiles = Array.from(e.target.files || []); + const filteredFiles = allFiles.filter((file) => shouldIncludeFile(file.webkitRelativePath)); + + if (filteredFiles.length === 0) { + toast.error('No files found in the selected folder'); + return; + } + + try { + const fileChecks = await Promise.all( + filteredFiles.map(async (file) => ({ + file, + isBinary: await isBinaryFile(file), + })), + ); + + const textFiles = fileChecks.filter((f) => !f.isBinary).map((f) => f.file); + const binaryFilePaths = fileChecks + .filter((f) => f.isBinary) + .map((f) => f.file.webkitRelativePath.split('/').slice(1).join('/')); + + if (textFiles.length === 0) { + toast.error('No text files found in the selected folder'); + return; + } + + if (binaryFilePaths.length > 0) { + toast.info(`Skipping ${binaryFilePaths.length} binary files`); + } + + await createChatFromFolder(textFiles, binaryFilePaths); + } catch (error) { + console.error('Failed to import folder:', error); + toast.error('Failed to import folder'); + } + + e.target.value = ''; // reset file input + }} + {...({} as any)} // if removed, webkitdirectory will throw errors as unknown attribute + /> + +
+
+ ); +}; diff --git a/app/components/chat/Markdown.tsx b/app/components/chat/Markdown.tsx index a91df43d30..4aa421bc25 100644 --- a/app/components/chat/Markdown.tsx +++ b/app/components/chat/Markdown.tsx @@ -1,12 +1,12 @@ import { memo, useMemo } from 'react'; import ReactMarkdown, { type Components } from 'react-markdown'; import type { BundledLanguage } from 'shiki'; -import { createScopedLogger } from '~/utils/logger'; -import { rehypePlugins, remarkPlugins, allowedHTMLElements } from '~/utils/markdown'; import { Artifact } from './Artifact'; import { CodeBlock } from './CodeBlock'; import styles from './Markdown.module.scss'; +import { createScopedLogger } from '~/utils/logger'; +import { rehypePlugins, remarkPlugins, allowedHTMLElements } from '~/utils/markdown'; const logger = createScopedLogger('MarkdownComponent'); diff --git a/app/components/chat/Messages.client.tsx b/app/components/chat/Messages.client.tsx index 2f35f49d46..7a59e9c887 100644 --- a/app/components/chat/Messages.client.tsx +++ b/app/components/chat/Messages.client.tsx @@ -1,8 +1,8 @@ import type { Message } from 'ai'; import React from 'react'; -import { classNames } from '~/utils/classNames'; import { AssistantMessage } from './AssistantMessage'; import { UserMessage } from './UserMessage'; +import { classNames } from '~/utils/classNames'; interface MessagesProps { id?: string; @@ -25,7 +25,7 @@ export const Messages = React.forwardRef((props: return (
(({ template }) => ( + + {template.label} + +)); + +const StarterTemplates = memo(() => { + return ( +
+ or start a blank app with your favorite stack +
+
+ {STARTER_TEMPLATES.map((template) => ( + + ))} +
+
+
+ ); +}); + +export default StarterTemplates; diff --git a/app/components/chat/UserMessage.tsx b/app/components/chat/UserMessage.tsx index 2f4e1d52d0..de154ce666 100644 --- a/app/components/chat/UserMessage.tsx +++ b/app/components/chat/UserMessage.tsx @@ -1,14 +1,42 @@ -import { modificationsRegex } from '~/utils/diff'; import { Markdown } from './Markdown'; +import { modificationsRegex } from '~/utils/diff'; + +interface MessageContent { + type: string; + text?: string; + image?: string; +} interface UserMessageProps { - content: string; + content: string | MessageContent[]; } export function UserMessage({ content }: UserMessageProps) { return (
- {sanitizeUserMessage(content)} + {Array.isArray(content) ? ( +
+ {content.map((item, index) => { + if (item.type === 'text') { + return ( + + {sanitizeUserMessage(item.text || '')} + + ); + } else if (item.type === 'image') { + return ( +
+ User uploaded +
+ ); + } + + return null; + })} +
+ ) : ( + {sanitizeUserMessage(content)} + )}
); } diff --git a/app/components/editor/codemirror/CodeMirrorEditor.tsx b/app/components/editor/codemirror/CodeMirrorEditor.tsx index 8e9f3a3fe9..aedfd09844 100644 --- a/app/components/editor/codemirror/CodeMirrorEditor.tsx +++ b/app/components/editor/codemirror/CodeMirrorEditor.tsx @@ -17,14 +17,14 @@ import { 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'; +import type { Theme } from '~/types/theme'; +import { classNames } from '~/utils/classNames'; +import { debounce } from '~/utils/debounce'; +import { createScopedLogger, renderLogger } from '~/utils/logger'; const logger = createScopedLogger('CodeMirrorEditor'); @@ -135,10 +135,10 @@ export const CodeMirrorEditor = memo( const [languageCompartment] = useState(new Compartment()); const containerRef = useRef(null); - const viewRef = useRef(); - const themeRef = useRef(); - const docRef = useRef(); - const editorStatesRef = useRef(); + const viewRef = useRef(undefined); + const themeRef = useRef(undefined); + const docRef = useRef(undefined); + const editorStatesRef = useRef(undefined); const onScrollRef = useRef(onScroll); const onChangeRef = useRef(onChange); const onSaveRef = useRef(onSave); diff --git a/app/components/editor/codemirror/cm-theme.ts b/app/components/editor/codemirror/cm-theme.ts index 6f3f3639ff..fedda83fcf 100644 --- a/app/components/editor/codemirror/cm-theme.ts +++ b/app/components/editor/codemirror/cm-theme.ts @@ -1,8 +1,8 @@ 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'; +import type { Theme } from '~/types/theme.js'; export const darkTheme = EditorView.theme({}, { dark: true }); export const themeSelection = new Compartment(); diff --git a/app/components/editor/codemirror/languages.ts b/app/components/editor/codemirror/languages.ts index 829221ec7d..8599aa7ae9 100644 --- a/app/components/editor/codemirror/languages.ts +++ b/app/components/editor/codemirror/languages.ts @@ -92,6 +92,62 @@ export const supportedLanguages = [ return import('@codemirror/lang-cpp').then((module) => module.cpp()); }, }), + LanguageDescription.of({ + name: 'Vue', + extensions: ['vue'], + async load() { + return import('@codemirror/lang-html').then((module) => module.html()); + }, + }), + LanguageDescription.of({ + name: 'Rust', + extensions: ['rs'], + async load() { + return import('@codemirror/lang-rust').then((module) => module.rust()); + }, + }), + LanguageDescription.of({ + name: 'Go', + extensions: ['go'], + async load() { + return import('@codemirror/lang-go').then((module) => module.go()); + }, + }), + LanguageDescription.of({ + name: 'PHP', + extensions: ['php'], + async load() { + return import('@codemirror/lang-php').then((module) => module.php()); + }, + }), + LanguageDescription.of({ + name: 'SQL', + extensions: ['sql'], + async load() { + return import('@codemirror/lang-sql').then((module) => module.sql()); + }, + }), + LanguageDescription.of({ + name: 'Java', + extensions: ['java'], + async load() { + return import('@codemirror/lang-java').then((module) => module.java()); + }, + }), + LanguageDescription.of({ + name: 'XML', + extensions: ['xml'], + async load() { + return import('@codemirror/lang-xml').then((module) => module.xml()); + }, + }), + LanguageDescription.of({ + name: 'YAML', + extensions: ['yml', 'yaml'], + async load() { + return import('@codemirror/lang-yaml').then((module) => module.yaml()); + }, + }), ]; export async function getLanguage(fileName: string) { diff --git a/app/components/git/GitCloneDialog.tsx b/app/components/git/GitCloneDialog.tsx new file mode 100644 index 0000000000..2c94a9582c --- /dev/null +++ b/app/components/git/GitCloneDialog.tsx @@ -0,0 +1,99 @@ +import { useState } from 'react'; +import { toast } from 'react-toastify'; +import { Dialog, DialogButton, DialogDescription, DialogRoot, DialogTitle } from '~/components/ui/Dialog'; + +interface GitCloneDialogProps { + isOpen: boolean; + onClose: () => void; + onClone?: (repoUrl: string) => Promise; +} + +export function GitCloneDialog({ isOpen, onClose, onClone }: GitCloneDialogProps) { + const [isPrivate, setIsPrivate] = useState(false); + const [repoUrl, setRepoUrl] = useState(''); + const [token, setToken] = useState(''); + const [isLoading, setIsLoading] = useState(false); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + + setIsLoading(true); + + try { + // if it's a private repo, construct the URL with the token + const cloneUrl = isPrivate ? repoUrl.replace('https://', `https://${token}@`) : repoUrl; + + if (onClone) { + await onClone(cloneUrl); + } + + toast.success('Repository cloned successfully!'); + onClose(); + } catch (error) { + toast.error(error instanceof Error ? error.message : 'Failed to clone repository'); + } finally { + setIsLoading(false); + } + }; + + return ( + + + Clone Repository + +
+
+ + +
+ +
+ +
+ + {isPrivate && ( +
+ +
+ )} + +
+ + Cancel + + + {isLoading ? 'Cloning...' : 'Clone Repository'} + +
+
+
+
+
+ ); +} diff --git a/app/components/git/GitUrlImport.client.tsx b/app/components/git/GitUrlImport.client.tsx new file mode 100644 index 0000000000..5a2ab63994 --- /dev/null +++ b/app/components/git/GitUrlImport.client.tsx @@ -0,0 +1,133 @@ +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, chatId } from '~/lib/persistence'; +import { createCommandsMessage, detectProjectCommands } from '~/utils/projectCommands'; + +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', +]; + +interface GitUrlImportProps { + initialUrl?: string; +} + +function navigateChat(nextId: string) { + window.location.href = `/chat/${nextId}`; +} + +export function GitUrlImport({ initialUrl }: GitUrlImportProps) { + const [searchParams] = useSearchParams(); + const { ready: historyReady, importChat } = useChatHistory(); + const { ready: gitReady, gitClone } = useGit(); + const [imported, setImported] = useState(false); + + const importRepo = async (repoUrl?: string) => { + if (!gitReady && !historyReady) { + return; + } + + if (repoUrl) { + const ig = ignore().add(IGNORE_PATTERNS); + const { workdir, data } = await gitClone(repoUrl); + + if (importChat) { + const filePaths = Object.keys(data).filter((filePath) => !ig.ignores(filePath)); + + const textDecoder = new TextDecoder('utf-8'); + + // convert files to common format for command detection + const fileContents = filePaths + .map((filePath) => { + const { data: content, encoding } = data[filePath]; + return { + path: filePath, + content: encoding === 'utf8' ? content : content instanceof Uint8Array ? textDecoder.decode(content) : '', + }; + }) + .filter((f) => f.content); + + // detect and create commands message + const commands = await detectProjectCommands(fileContents); + const commandsMessage = createCommandsMessage(commands); + + // create files message + const filesMessage: Message = { + role: 'assistant', + content: `Cloning the repo ${repoUrl} into ${workdir} + +${fileContents + .map( + (file) => + ` +${file.content} +`, + ) + .join('\n')} +`, + id: generateId(), + createdAt: new Date(), + }; + + const messages = [filesMessage]; + + if (commandsMessage) { + messages.push(commandsMessage); + } + + await importChat(`Git Project:${repoUrl.split('/').slice(-1)[0]}`, messages); + + // wait for the chat ID to be set + const id = chatId.get(); + + if (id) { + navigateChat(id); + } + } + } + }; + + useEffect(() => { + if (!historyReady || !gitReady || imported) { + return; + } + + // use initialUrl if provided, otherwise fallback to URL parameter + const url = initialUrl || searchParams.get('url'); + + if (!url) { + window.location.href = '/'; + return; + } + + importRepo(url); + setImported(true); + }, [searchParams, historyReady, gitReady, imported, initialUrl]); + + return }>{() => }; +} diff --git a/app/components/header/Header.tsx b/app/components/header/Header.tsx index 15cf4bfbd0..5bbebaeebb 100644 --- a/app/components/header/Header.tsx +++ b/app/components/header/Header.tsx @@ -1,9 +1,9 @@ import { useStore } from '@nanostores/react'; import { ClientOnly } from 'remix-utils/client-only'; -import { chatStore } from '~/lib/stores/chat'; -import { classNames } from '~/utils/classNames'; import { HeaderActionButtons } from './HeaderActionButtons.client'; import { ChatDescription } from '~/lib/persistence/ChatDescription.client'; +import { chatStore } from '~/lib/stores/chat'; +import { classNames } from '~/utils/classNames'; export function Header() { const chat = useStore(chatStore); diff --git a/app/components/header/HeaderActionButtons.client.tsx b/app/components/header/HeaderActionButtons.client.tsx index 6f8e8432a7..506eec8a39 100644 --- a/app/components/header/HeaderActionButtons.client.tsx +++ b/app/components/header/HeaderActionButtons.client.tsx @@ -12,7 +12,7 @@ export function HeaderActionButtons({}: HeaderActionButtonsProps) { const canHideChat = showWorkbench || !showChat; return ( -
+
- )} -
- + +
+ )} +
+ + )}
); } diff --git a/app/components/sidebar/Menu.client.tsx b/app/components/sidebar/Menu.client.tsx index cf6d97812c..786ed09a9e 100644 --- a/app/components/sidebar/Menu.client.tsx +++ b/app/components/sidebar/Menu.client.tsx @@ -1,14 +1,14 @@ +import { saveAs } from 'file-saver'; import { motion, type Variants } from 'framer-motion'; import { useCallback, useEffect, useRef, useState } from 'react'; import { toast } from 'react-toastify'; +import { HistoryItem } from './HistoryItem'; +import { binDates } from './date-binning'; 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'; -import { binDates } from './date-binning'; const menuVariants = { closed: { @@ -68,6 +68,44 @@ export function Menu() { } }, []); + const renameItem = useCallback( + (id: string, newDescription: string) => { + if (db) { + const item = list.find((item) => item.id === id); + + if (item) { + setMessages(db, id, item.messages, item.urlId, newDescription) + .then(() => { + loadEntries(); + toast.success('Chat renamed successfully'); + }) + .catch((error) => { + toast.error('Failed to rename chat'); + logger.error(error); + }); + } + } + }, + [list], + ); + + const exportItem = useCallback((item: ChatHistoryItem) => { + try { + const chatData = { + description: item.description, + messages: item.messages, + timestamp: item.timestamp, + }; + const blob = new Blob([JSON.stringify(chatData, null, 2)], { type: 'application/json' }); + const filename = `${item.description || 'chat'}-${new Date(item.timestamp).toISOString().split('T')[0]}.json`; + saveAs(blob, filename); + toast.success('Chat exported successfully'); + } catch (error) { + toast.error('Failed to export chat'); + logger.error(error); + } + }, []); + const closeDialog = () => { setDialogContent(null); }; @@ -128,7 +166,13 @@ export function Menu() { {category}
{items.map((item) => ( - setDialogContent({ type: 'delete', item })} /> + setDialogContent({ type: 'delete', item })} + onRename={renameItem} + onExport={exportItem} + /> ))}
))} diff --git a/app/components/ui/Dialog.tsx b/app/components/ui/Dialog.tsx index a808c77422..99467acd3a 100644 --- a/app/components/ui/Dialog.tsx +++ b/app/components/ui/Dialog.tsx @@ -1,9 +1,9 @@ import * as RadixDialog from '@radix-ui/react-dialog'; import { motion, type Variants } from 'framer-motion'; import React, { memo, type ReactNode } from 'react'; +import { IconButton } from './IconButton'; import { classNames } from '~/utils/classNames'; import { cubicEasingFn } from '~/utils/easings'; -import { IconButton } from './IconButton'; export { Close as DialogClose, Root as DialogRoot } from '@radix-ui/react-dialog'; @@ -44,13 +44,14 @@ interface DialogButtonProps { type: 'primary' | 'secondary' | 'danger'; children: ReactNode; onClick?: (event: React.UIEvent) => void; + disabled?: boolean; } -export const DialogButton = memo(({ type, children, onClick }: DialogButtonProps) => { +export const DialogButton = memo(({ type, children, onClick, disabled = false }: DialogButtonProps) => { return ( diff --git a/app/components/ui/IconButton.tsx b/app/components/ui/IconButton.tsx index 4eef7fb2f4..8bd6087c90 100644 --- a/app/components/ui/IconButton.tsx +++ b/app/components/ui/IconButton.tsx @@ -1,3 +1,5 @@ +import type { ReactElement } from 'react'; +import React from 'react'; import { memo } from 'react'; import { classNames } from '~/utils/classNames'; @@ -20,7 +22,7 @@ type IconButtonWithoutChildrenProps = { type IconButtonWithChildrenProps = { icon?: undefined; - children: string | JSX.Element | JSX.Element[]; + children: string | ReactElement | ReactElement[]; } & BaseIconButtonProps; type IconButtonProps = IconButtonWithoutChildrenProps | IconButtonWithChildrenProps; @@ -40,7 +42,7 @@ export const IconButton = memo( return ( ); }, ); function getIconSize(size: IconSize) { - if (size === 'sm') { - return 'text-sm'; - } else if (size === 'md') { - return 'text-md'; - } else if (size === 'lg') { - return 'text-lg'; - } else if (size === 'xl') { - return 'text-xl'; - } else { - return 'text-2xl'; + switch (size) { + case 'sm': { + return 'text-sm'; + } + case 'md': { + return 'text-base'; + } + case 'lg': { + return 'text-lg'; + } + case 'xl': { + return 'text-xl'; + } + case 'xxl': { + return 'text-2xl'; + } + default: { + return 'text-base'; + } } } diff --git a/app/components/ui/PanelHeaderButton.tsx b/app/components/ui/PanelHeaderButton.tsx index 9faea1cc45..35ba9d2c99 100644 --- a/app/components/ui/PanelHeaderButton.tsx +++ b/app/components/ui/PanelHeaderButton.tsx @@ -1,3 +1,5 @@ +import type { ReactElement } from 'react'; +import React from 'react'; import { memo } from 'react'; import { classNames } from '~/utils/classNames'; @@ -5,7 +7,7 @@ interface PanelHeaderButtonProps { className?: string; disabledClassName?: string; disabled?: boolean; - children: string | JSX.Element | Array; + children: string | ReactElement | Array; onClick?: (event: React.MouseEvent) => void; } diff --git a/app/components/ui/Slider.tsx b/app/components/ui/Slider.tsx index f332a6af2d..996f9e6caa 100644 --- a/app/components/ui/Slider.tsx +++ b/app/components/ui/Slider.tsx @@ -1,5 +1,7 @@ import { motion } from 'framer-motion'; +import React from 'react'; import { memo } from 'react'; +import type { ReactElement } from 'react'; import { classNames } from '~/utils/classNames'; import { cubicEasingFn } from '~/utils/easings'; import { genericMemo } from '~/utils/react'; @@ -37,7 +39,7 @@ export const Slider = genericMemo(({ selected, options, setSelected }: Slide interface SliderButtonProps { selected: boolean; - children: string | JSX.Element | Array; + children: string | ReactElement | Array; setSelected: () => void; } diff --git a/app/components/ui/ThemeSwitch.tsx b/app/components/ui/ThemeSwitch.tsx index a46da2b2e0..559e539f80 100644 --- a/app/components/ui/ThemeSwitch.tsx +++ b/app/components/ui/ThemeSwitch.tsx @@ -1,7 +1,7 @@ import { useStore } from '@nanostores/react'; import { memo, useEffect, useState } from 'react'; -import { themeStore, toggleTheme } from '~/lib/stores/theme'; import { IconButton } from './IconButton'; +import { themeStore, toggleTheme } from '~/lib/stores/theme'; interface ThemeSwitchProps { className?: string; diff --git a/app/components/ui/Tooltip.tsx b/app/components/ui/Tooltip.tsx new file mode 100644 index 0000000000..6af2716ee1 --- /dev/null +++ b/app/components/ui/Tooltip.tsx @@ -0,0 +1,26 @@ +import * as RadixTooltip from '@radix-ui/react-tooltip'; +import { memo } from 'react'; + +interface WithTooltipProps { + tooltip: string; + children: React.ReactNode; +} + +export default memo(({ tooltip, children }: WithTooltipProps) => { + return ( + + + {children} + + + {tooltip} + + + + + + ); +}); diff --git a/app/components/workbench/EditorPanel.tsx b/app/components/workbench/EditorPanel.tsx index d1a265a660..45d51a416d 100644 --- a/app/components/workbench/EditorPanel.tsx +++ b/app/components/workbench/EditorPanel.tsx @@ -1,6 +1,9 @@ import { useStore } from '@nanostores/react'; import { memo, useEffect, useMemo, useRef, useState } from 'react'; import { Panel, PanelGroup, PanelResizeHandle, type ImperativePanelHandle } from 'react-resizable-panels'; +import { FileBreadcrumb } from './FileBreadcrumb'; +import { FileTree } from './FileTree'; +import { Terminal, type TerminalRef } from './terminal/Terminal'; import { CodeMirrorEditor, type EditorDocument, @@ -20,9 +23,6 @@ import { classNames } from '~/utils/classNames'; import { WORK_DIR } from '~/utils/constants'; import { renderLogger } from '~/utils/logger'; import { isMobile } from '~/utils/mobile'; -import { FileBreadcrumb } from './FileBreadcrumb'; -import { FileTree } from './FileTree'; -import { Terminal, type TerminalRef } from './terminal/Terminal'; interface EditorPanelProps { files?: FileMap; @@ -60,8 +60,9 @@ export const EditorPanel = memo( const theme = useStore(themeStore); const showTerminal = useStore(workbenchStore.showTerminal); + const showBoltTerminal = useStore(workbenchStore.showBoltTerminal); - const terminalRefs = useRef>([]); + const terminalRefs = useRef<(TerminalRef | null)[]>([]); const terminalPanelRef = useRef(null); const terminalToggledByShortcut = useRef(false); @@ -105,15 +106,16 @@ export const EditorPanel = memo( } const isCollapsed = terminal.isCollapsed(); + const shouldShowTerminal = showTerminal || showBoltTerminal; - if (!showTerminal && !isCollapsed) { + if (!shouldShowTerminal && !isCollapsed) { terminal.collapse(); - } else if (showTerminal && isCollapsed) { + } else if (shouldShowTerminal && isCollapsed) { terminal.resize(DEFAULT_TERMINAL_SIZE); } terminalToggledByShortcut.current = false; - }, [showTerminal]); + }, [showTerminal, showBoltTerminal]); const addTerminal = () => { if (terminalCount < MAX_TERMINALS) { @@ -124,7 +126,7 @@ export const EditorPanel = memo( return ( - +
@@ -182,25 +184,38 @@ export const EditorPanel = memo( { - if (!terminalToggledByShortcut.current) { - workbenchStore.toggleTerminal(true); - } - }} onCollapse={() => { if (!terminalToggledByShortcut.current) { workbenchStore.toggleTerminal(false); + workbenchStore.toggleBoltTerminal(false); } }} >
+ {Array.from({ length: terminalCount }, (_, index) => { - const isActive = activeTerminal === index; + const isActive = !showBoltTerminal && activeTerminal === index; return ( + +
+ +
+
+ ); +} diff --git a/app/components/workbench/Preview.tsx b/app/components/workbench/Preview.tsx index 4c1877c5dd..b3f68492fa 100644 --- a/app/components/workbench/Preview.tsx +++ b/app/components/workbench/Preview.tsx @@ -1,14 +1,34 @@ -import { useStore } from '@nanostores/react'; import { memo, useCallback, useEffect, useRef, useState } from 'react'; +import { useStore } from '@nanostores/react'; import { IconButton } from '~/components/ui/IconButton'; import { workbenchStore } from '~/lib/stores/workbench'; import { PortDropdown } from './PortDropdown'; +type ResizeSide = 'left' | 'right' | null; + +interface WindowSize { + name: string; + width: number; + height: number; + icon: string; +} + +const WINDOW_SIZES: WindowSize[] = [ + { name: 'Mobile', width: 375, height: 667, icon: 'i-ph:device-mobile' }, + { name: 'Tablet', width: 768, height: 1024, icon: 'i-ph:device-tablet' }, + { name: 'Laptop', width: 1366, height: 768, icon: 'i-ph:laptop' }, + { name: 'Desktop', width: 1920, height: 1080, icon: 'i-ph:monitor' }, +]; + export const Preview = memo(() => { const iframeRef = useRef(null); + const containerRef = useRef(null); const inputRef = useRef(null); + const [activePreviewIndex, setActivePreviewIndex] = useState(0); const [isPortDropdownOpen, setIsPortDropdownOpen] = useState(false); + const [isFullscreen, setIsFullscreen] = useState(false); + const [isPreviewOnly, setIsPreviewOnly] = useState(false); const hasSelectedPreview = useRef(false); const previews = useStore(workbenchStore.previews); const activePreview = previews[activePreviewIndex]; @@ -16,6 +36,25 @@ export const Preview = memo(() => { const [url, setUrl] = useState(''); const [iframeUrl, setIframeUrl] = useState(); + // toggle between responsive mode and device mode + const [isDeviceModeOn, setIsDeviceModeOn] = useState(false); + + // use percentage for width + const [widthPercent, setWidthPercent] = useState(37.5); + + const resizingState = useRef({ + isResizing: false, + side: null as ResizeSide, + startX: 0, + startWidthPercent: 37.5, + windowWidth: window.innerWidth, + }); + + const SCALING_FACTOR = 2; + + const [isWindowSizeDropdownOpen, setIsWindowSizeDropdownOpen] = useState(false); + const [selectedWindowSize, setSelectedWindowSize] = useState(WINDOW_SIZES[0]); + useEffect(() => { if (!activePreview) { setUrl(''); @@ -25,10 +64,9 @@ export const Preview = memo(() => { } const { baseUrl } = activePreview; - setUrl(baseUrl); setIframeUrl(baseUrl); - }, [activePreview, iframeUrl]); + }, [activePreview]); const validateUrl = useCallback( (value: string) => { @@ -56,14 +94,12 @@ export const Preview = memo(() => { [], ); - // when previews change, display the lowest port if user hasn't selected a preview useEffect(() => { if (previews.length > 1 && !hasSelectedPreview.current) { const minPortIndex = previews.reduce(findMinPortIndex, 0); - setActivePreviewIndex(minPortIndex); } - }, [previews]); + }, [previews, findMinPortIndex]); const reloadPreview = () => { if (iframeRef.current) { @@ -71,18 +107,155 @@ export const Preview = memo(() => { } }; + const toggleFullscreen = async () => { + if (!isFullscreen && containerRef.current) { + await containerRef.current.requestFullscreen(); + } else if (document.fullscreenElement) { + await document.exitFullscreen(); + } + }; + + useEffect(() => { + const handleFullscreenChange = () => { + setIsFullscreen(!!document.fullscreenElement); + }; + + document.addEventListener('fullscreenchange', handleFullscreenChange); + + return () => { + document.removeEventListener('fullscreenchange', handleFullscreenChange); + }; + }, []); + + const toggleDeviceMode = () => { + setIsDeviceModeOn((prev) => !prev); + }; + + const startResizing = (e: React.MouseEvent, side: ResizeSide) => { + if (!isDeviceModeOn) { + return; + } + + document.body.style.userSelect = 'none'; + + resizingState.current.isResizing = true; + resizingState.current.side = side; + resizingState.current.startX = e.clientX; + resizingState.current.startWidthPercent = widthPercent; + resizingState.current.windowWidth = window.innerWidth; + + document.addEventListener('mousemove', onMouseMove); + document.addEventListener('mouseup', onMouseUp); + + e.preventDefault(); + }; + + const onMouseMove = (e: MouseEvent) => { + if (!resizingState.current.isResizing) { + return; + } + + const dx = e.clientX - resizingState.current.startX; + const windowWidth = resizingState.current.windowWidth; + + const dxPercent = (dx / windowWidth) * 100 * SCALING_FACTOR; + + let newWidthPercent = resizingState.current.startWidthPercent; + + if (resizingState.current.side === 'right') { + newWidthPercent = resizingState.current.startWidthPercent + dxPercent; + } else if (resizingState.current.side === 'left') { + newWidthPercent = resizingState.current.startWidthPercent - dxPercent; + } + + newWidthPercent = Math.max(10, Math.min(newWidthPercent, 90)); + + setWidthPercent(newWidthPercent); + }; + + const onMouseUp = () => { + resizingState.current.isResizing = false; + resizingState.current.side = null; + document.removeEventListener('mousemove', onMouseMove); + document.removeEventListener('mouseup', onMouseUp); + + document.body.style.userSelect = ''; + }; + + useEffect(() => { + const handleWindowResize = () => { + // optional: adjust widthPercent if necessary + }; + + window.addEventListener('resize', handleWindowResize); + + return () => { + window.removeEventListener('resize', handleWindowResize); + }; + }, []); + + const GripIcon = () => ( +
+
+ ••• ••• +
+
+ ); + + const openInNewWindow = (size: WindowSize) => { + if (activePreview?.baseUrl) { + const match = activePreview.baseUrl.match(/^https?:\/\/([^.]+)\.local-credentialless\.webcontainer-api\.io/); + + if (match) { + const previewId = match[1]; + const previewUrl = `/webcontainer/preview/${previewId}`; + const newWindow = window.open( + previewUrl, + '_blank', + `noopener,noreferrer,width=${size.width},height=${size.height},menubar=no,toolbar=no,location=no,status=no`, + ); + + if (newWindow) { + newWindow.focus(); + } + } else { + console.warn('[Preview] Invalid WebContainer URL:', activePreview.baseUrl); + } + } + }; + return ( -
+
{isPortDropdownOpen && (
setIsPortDropdownOpen(false)} /> )} -
- -
+
+
+ +
+ +
{ }} />
- {previews.length > 1 && ( - (hasSelectedPreview.current = value)} - setIsDropdownOpen={setIsPortDropdownOpen} - previews={previews} + +
+ {previews.length > 1 && ( + (hasSelectedPreview.current = value)} + setIsDropdownOpen={setIsPortDropdownOpen} + previews={previews} + /> + )} + + + + setIsPreviewOnly(!isPreviewOnly)} + title={isPreviewOnly ? 'Show Full Interface' : 'Show Preview Only'} + /> + + - )} + +
+ openInNewWindow(selectedWindowSize)} + title={`Open Preview in ${selectedWindowSize.name} Window`} + /> + setIsWindowSizeDropdownOpen(!isWindowSizeDropdownOpen)} + className="ml-1" + title="Select Window Size" + /> + + {isWindowSizeDropdownOpen && ( + <> +
setIsWindowSizeDropdownOpen(false)} /> +
+ {WINDOW_SIZES.map((size) => ( + + ))} +
+ + )} +
+
-
- {activePreview ? ( -