diff --git a/app/components/@settings/core/ControlPanel.tsx b/app/components/@settings/core/ControlPanel.tsx index 925bce1..df32755 100644 --- a/app/components/@settings/core/ControlPanel.tsx +++ b/app/components/@settings/core/ControlPanel.tsx @@ -413,7 +413,7 @@ export const ControlPanel = ({ open, onClose }: ControlPanelProps) => { return ( -
+
-
+
Netlify Connection
diff --git a/app/components/@settings/tabs/connections/GithubConnection.tsx b/app/components/@settings/tabs/connections/GithubConnection.tsx index 25c498c..e378d40 100644 --- a/app/components/@settings/tabs/connections/GithubConnection.tsx +++ b/app/components/@settings/tabs/connections/GithubConnection.tsx @@ -688,7 +688,7 @@ export default function GitHubConnection() { onClick={() => window.open('https://github.com/dashboard', '_blank', 'noopener,noreferrer')} className="flex items-center gap-2 hover:bg-bolt-elements-item-backgroundActive/10 hover:text-bolt-elements-textPrimary dark:hover:text-bolt-elements-textPrimary transition-colors" > -
+
Dashboard + {artifact.type !== 'bundled' &&
} + + {actions.length && artifact.type !== 'bundled' && ( + +
+
+
+
+ )} +
+
+ {artifact.type === 'bundled' && ( +
+
+ {allActionFinished ? ( +
+ ) : ( +
+ )} +
+
+ {/* This status text remains the same */} + {allActionFinished + ? artifact.id === 'restored-project-setup' + ? 'Restore files from snapshot' + : 'Initial files created' + : 'Creating initial files'} +
- -
+ )} - {actions.length && artifact.type !== 'bundled' && ( - 0 && ( + -
-
+
+ +
+
- + )}
- - {artifact.type !== 'bundled' && showActions && actions.length > 0 && ( - -
- -
- -
- - )} - -
+ ); }); diff --git a/app/components/chat/AssistantMessage.tsx b/app/components/chat/AssistantMessage.tsx index 20e2656..aa831a4 100644 --- a/app/components/chat/AssistantMessage.tsx +++ b/app/components/chat/AssistantMessage.tsx @@ -4,10 +4,14 @@ import type { JSONValue } from 'ai'; import Popover from '~/components/ui/Popover'; import { workbenchStore } from '~/lib/stores/workbench'; import { WORK_DIR } from '~/utils/constants'; +import WithTooltip from '~/components/ui/Tooltip'; interface AssistantMessageProps { content: string; annotations?: JSONValue[]; + messageId?: string; + onRewind?: (messageId: string) => void; + onFork?: (messageId: string) => void; } function openArtifactInWorkbench(filePath: string) { @@ -34,7 +38,7 @@ function normalizedFilePath(path: string) { return normalizedPath; } -export const AssistantMessage = memo(({ content, annotations }: AssistantMessageProps) => { +export const AssistantMessage = memo(({ content, annotations, messageId, onRewind, onFork }: AssistantMessageProps) => { const filteredAnnotations = (annotations?.filter( (annotation: JSONValue) => annotation && typeof annotation === 'object' && Object.keys(annotation).includes('type'), ) || []) as { type: string; value: any } & { [key: string]: any }[]; @@ -100,11 +104,35 @@ export const AssistantMessage = memo(({ content, annotations }: AssistantMessage
)} - {usage && ( -
- Tokens: {usage.totalTokens} (prompt: {usage.promptTokens}, completion: {usage.completionTokens}) -
- )} +
+ {usage && ( +
+ Tokens: {usage.totalTokens} (prompt: {usage.promptTokens}, completion: {usage.completionTokens}) +
+ )} + {(onRewind || onFork) && messageId && ( +
+ {onRewind && ( + +
+ )} +
{content} diff --git a/app/components/chat/BaseChat.tsx b/app/components/chat/BaseChat.tsx index a33058c..c5b50c0 100644 --- a/app/components/chat/BaseChat.tsx +++ b/app/components/chat/BaseChat.tsx @@ -39,6 +39,10 @@ import type { ActionRunner } from '~/lib/runtime/action-runner'; import { LOCAL_PROVIDERS } from '~/lib/stores/settings'; import { SupabaseChatAlert } from '~/components/chat/SupabaseAlert'; import { SupabaseConnection } from './SupabaseConnection'; +import { ExpoQrModal } from '~/components/workbench/ExpoQrModal'; +import { expoUrlAtom } from '~/lib/stores/qrCodeStore'; +import { useStore } from '@nanostores/react'; +import { StickToBottom, useStickToBottomContext } from '~/lib/hooks'; const TEXTAREA_MIN_HEIGHT = 76; @@ -84,8 +88,6 @@ export const BaseChat = React.forwardRef( ( { textareaRef, - messageRef, - scrollRef, showChat = true, chatStarted = false, isStreaming = false, @@ -130,6 +132,15 @@ export const BaseChat = React.forwardRef( const [transcript, setTranscript] = useState(''); const [isModelLoading, setIsModelLoading] = useState('all'); const [progressAnnotations, setProgressAnnotations] = useState([]); + const expoUrl = useStore(expoUrlAtom); + const [qrModalOpen, setQrModalOpen] = useState(false); + + useEffect(() => { + if (expoUrl) { + setQrModalOpen(true); + } + }, [expoUrl]); + useEffect(() => { if (data) { const progressList = data.filter( @@ -324,7 +335,7 @@ export const BaseChat = React.forwardRef( data-chat-visible={showChat} > {() => } -
+
{!chatStarted && (
@@ -336,50 +347,52 @@ export const BaseChat = React.forwardRef(

)} -
- - {() => { - return chatStarted ? ( - - ) : null; - }} - - {deployAlert && ( - clearDeployAlert?.()} - postMessage={(message: string | undefined) => { - sendMessage?.({} as any, message); - clearSupabaseAlert?.(); + + + {() => { + return chatStarted ? ( + + ) : null; }} - /> - )} - {supabaseAlert && ( - clearSupabaseAlert?.()} - postMessage={(message) => { - sendMessage?.({} as any, message); - clearSupabaseAlert?.(); - }} - /> - )} + +
-
+
+ {deployAlert && ( + clearDeployAlert?.()} + postMessage={(message: string | undefined) => { + sendMessage?.({} as any, message); + clearSupabaseAlert?.(); + }} + /> + )} + {supabaseAlert && ( + clearSupabaseAlert?.()} + postMessage={(message) => { + sendMessage?.({} as any, message); + clearSupabaseAlert?.(); + }} + /> + )} {actionAlert && ( ( /> )}
+ {progressAnnotations && }
(
) : null} + setQrModalOpen(false)} />
-
-
+ +
{!chatStarted && (
{ImportButtons(importChat)}
)} - {!chatStarted && - ExamplePrompts((event, messageInput) => { - if (isStreaming) { - handleStop?.(); - return; - } +
+ {!chatStarted && + ExamplePrompts((event, messageInput) => { + if (isStreaming) { + handleStop?.(); + return; + } - handleSendMessage?.(event, messageInput); - })} - {!chatStarted && } + handleSendMessage?.(event, messageInput); + })} + {!chatStarted && } +
@@ -662,3 +679,19 @@ export const BaseChat = React.forwardRef( return {baseChat}; }, ); + +function ScrollToBottom() { + const { isAtBottom, scrollToBottom } = useStickToBottomContext(); + + return ( + !isAtBottom && ( + + ) + ); +} diff --git a/app/components/chat/Chat.client.tsx b/app/components/chat/Chat.client.tsx index 4f97683..c5ff7db 100644 --- a/app/components/chat/Chat.client.tsx +++ b/app/components/chat/Chat.client.tsx @@ -8,7 +8,7 @@ import { useChat } from 'ai/react'; import { useAnimate } from 'framer-motion'; import { memo, useCallback, useEffect, useRef, useState } from 'react'; import { cssTransition, toast, ToastContainer } from 'react-toastify'; -import { useMessageParser, usePromptEnhancer, useShortcuts, useSnapScroll } from '~/lib/hooks'; +import { useMessageParser, usePromptEnhancer, useShortcuts } from '~/lib/hooks'; import { description, useChatHistory } from '~/lib/persistence'; import { chatStore } from '~/lib/stores/chat'; import { workbenchStore } from '~/lib/stores/workbench'; @@ -483,8 +483,6 @@ export const ChatImpl = memo( [], ); - const [messageRef, scrollRef] = useSnapScroll(); - useEffect(() => { const storedApiKeys = Cookies.get('apiKeys'); @@ -522,8 +520,6 @@ export const ChatImpl = memo( provider={provider} setProvider={handleProviderChange} providerList={activeProviders} - messageRef={messageRef} - scrollRef={scrollRef} handleInputChange={(e) => { onTextareaChange(e); debouncedCachePrompt(e); diff --git a/app/components/chat/CodeBlock.tsx b/app/components/chat/CodeBlock.tsx index bc20dc2..e6b09f0 100644 --- a/app/components/chat/CodeBlock.tsx +++ b/app/components/chat/CodeBlock.tsx @@ -35,18 +35,21 @@ export const CodeBlock = memo( }; useEffect(() => { + let effectiveLanguage = language; + if (language && !isSpecialLang(language) && !(language in bundledLanguages)) { - logger.warn(`Unsupported language '${language}'`); + logger.warn(`Unsupported language '${language}', falling back to plaintext`); + effectiveLanguage = 'plaintext'; } - logger.trace(`Language = ${language}`); + logger.trace(`Language = ${effectiveLanguage}`); const processCode = async () => { - setHTML(await codeToHtml(code, { lang: language, theme })); + setHTML(await codeToHtml(code, { lang: effectiveLanguage, theme })); }; processCode(); - }, [code]); + }, [code, language, theme]); return (
diff --git a/app/components/chat/ExamplePrompts.tsx b/app/components/chat/ExamplePrompts.tsx index 4ef117f..7171eca 100644 --- a/app/components/chat/ExamplePrompts.tsx +++ b/app/components/chat/ExamplePrompts.tsx @@ -1,6 +1,7 @@ import React from 'react'; const EXAMPLE_PROMPTS = [ + { text: 'Create a mobile app about bolt.diy' }, { 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' }, diff --git a/app/components/chat/FilePreview.tsx b/app/components/chat/FilePreview.tsx index 0500d03..e1400cf 100644 --- a/app/components/chat/FilePreview.tsx +++ b/app/components/chat/FilePreview.tsx @@ -12,18 +12,21 @@ const FilePreview: React.FC = ({ files, imageDataList, onRemov } return ( -
+
{files.map((file, index) => (
{imageDataList[index] && ( -
- {file.name} +
+ {file.name} +
+ {file.name} +
)}
diff --git a/app/components/chat/GitCloneButton.tsx b/app/components/chat/GitCloneButton.tsx index a450c2c..3fcebdc 100644 --- a/app/components/chat/GitCloneButton.tsx +++ b/app/components/chat/GitCloneButton.tsx @@ -156,13 +156,13 @@ ${escapeBoltTags(file.content)}
- )}
); }) : null} {isStreaming && ( -
+
)}
); diff --git a/app/components/chat/ModelSelector.tsx b/app/components/chat/ModelSelector.tsx index b80bfc8..8d38b25 100644 --- a/app/components/chat/ModelSelector.tsx +++ b/app/components/chat/ModelSelector.tsx @@ -3,7 +3,6 @@ import { useEffect, useState, useRef } from 'react'; import type { KeyboardEvent } from 'react'; import type { ModelInfo } from '~/lib/modules/llm/types'; import { classNames } from '~/utils/classNames'; -import * as React from 'react'; interface ModelSelectorProps { model?: string; @@ -27,17 +26,28 @@ export const ModelSelector = ({ }: ModelSelectorProps) => { const [modelSearchQuery, setModelSearchQuery] = useState(''); const [isModelDropdownOpen, setIsModelDropdownOpen] = useState(false); - const [focusedIndex, setFocusedIndex] = useState(-1); - const searchInputRef = useRef(null); - const optionsRef = useRef<(HTMLDivElement | null)[]>([]); - const dropdownRef = useRef(null); + const [focusedModelIndex, setFocusedModelIndex] = useState(-1); + const modelSearchInputRef = useRef(null); + const modelOptionsRef = useRef<(HTMLDivElement | null)[]>([]); + const modelDropdownRef = useRef(null); + const [providerSearchQuery, setProviderSearchQuery] = useState(''); + const [isProviderDropdownOpen, setIsProviderDropdownOpen] = useState(false); + const [focusedProviderIndex, setFocusedProviderIndex] = useState(-1); + const providerSearchInputRef = useRef(null); + const providerOptionsRef = useRef<(HTMLDivElement | null)[]>([]); + const providerDropdownRef = useRef(null); useEffect(() => { const handleClickOutside = (event: MouseEvent) => { - if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) { + if (modelDropdownRef.current && !modelDropdownRef.current.contains(event.target as Node)) { setIsModelDropdownOpen(false); setModelSearchQuery(''); } + + if (providerDropdownRef.current && !providerDropdownRef.current.contains(event.target as Node)) { + setIsProviderDropdownOpen(false); + setProviderSearchQuery(''); + } }; document.addEventListener('mousedown', handleClickOutside); @@ -45,7 +55,6 @@ export const ModelSelector = ({ return () => document.removeEventListener('mousedown', handleClickOutside); }, []); - // Filter models based on search query const filteredModels = [...modelList] .filter((e) => e.provider === provider?.name && e.name) .filter( @@ -54,20 +63,31 @@ export const ModelSelector = ({ model.name.toLowerCase().includes(modelSearchQuery.toLowerCase()), ); - // Reset focused index when search query changes or dropdown opens/closes + const filteredProviders = providerList.filter((p) => + p.name.toLowerCase().includes(providerSearchQuery.toLowerCase()), + ); + useEffect(() => { - setFocusedIndex(-1); + setFocusedModelIndex(-1); }, [modelSearchQuery, isModelDropdownOpen]); - // Focus search input when dropdown opens useEffect(() => { - if (isModelDropdownOpen && searchInputRef.current) { - searchInputRef.current.focus(); + setFocusedProviderIndex(-1); + }, [providerSearchQuery, isProviderDropdownOpen]); + + useEffect(() => { + if (isModelDropdownOpen && modelSearchInputRef.current) { + modelSearchInputRef.current.focus(); } }, [isModelDropdownOpen]); - // Handle keyboard navigation - const handleKeyDown = (e: KeyboardEvent) => { + useEffect(() => { + if (isProviderDropdownOpen && providerSearchInputRef.current) { + providerSearchInputRef.current.focus(); + } + }, [isProviderDropdownOpen]); + + const handleModelKeyDown = (e: KeyboardEvent) => { if (!isModelDropdownOpen) { return; } @@ -75,50 +95,30 @@ export const ModelSelector = ({ switch (e.key) { case 'ArrowDown': e.preventDefault(); - setFocusedIndex((prev) => { - const next = prev + 1; - - if (next >= filteredModels.length) { - return 0; - } - - return next; - }); + setFocusedModelIndex((prev) => (prev + 1 >= filteredModels.length ? 0 : prev + 1)); break; - case 'ArrowUp': e.preventDefault(); - setFocusedIndex((prev) => { - const next = prev - 1; - - if (next < 0) { - return filteredModels.length - 1; - } - - return next; - }); + setFocusedModelIndex((prev) => (prev - 1 < 0 ? filteredModels.length - 1 : prev - 1)); break; - case 'Enter': e.preventDefault(); - if (focusedIndex >= 0 && focusedIndex < filteredModels.length) { - const selectedModel = filteredModels[focusedIndex]; + if (focusedModelIndex >= 0 && focusedModelIndex < filteredModels.length) { + const selectedModel = filteredModels[focusedModelIndex]; setModel?.(selectedModel.name); setIsModelDropdownOpen(false); setModelSearchQuery(''); } break; - case 'Escape': e.preventDefault(); setIsModelDropdownOpen(false); setModelSearchQuery(''); break; - case 'Tab': - if (!e.shiftKey && focusedIndex === filteredModels.length - 1) { + if (!e.shiftKey && focusedModelIndex === filteredModels.length - 1) { setIsModelDropdownOpen(false); } @@ -126,25 +126,76 @@ export const ModelSelector = ({ } }; - // Focus the selected option - useEffect(() => { - if (focusedIndex >= 0 && optionsRef.current[focusedIndex]) { - optionsRef.current[focusedIndex]?.scrollIntoView({ block: 'nearest' }); + const handleProviderKeyDown = (e: KeyboardEvent) => { + if (!isProviderDropdownOpen) { + return; } - }, [focusedIndex]); - // Update enabled providers when cookies change + switch (e.key) { + case 'ArrowDown': + e.preventDefault(); + setFocusedProviderIndex((prev) => (prev + 1 >= filteredProviders.length ? 0 : prev + 1)); + break; + case 'ArrowUp': + e.preventDefault(); + setFocusedProviderIndex((prev) => (prev - 1 < 0 ? filteredProviders.length - 1 : prev - 1)); + break; + case 'Enter': + e.preventDefault(); + + if (focusedProviderIndex >= 0 && focusedProviderIndex < filteredProviders.length) { + const selectedProvider = filteredProviders[focusedProviderIndex]; + + if (setProvider) { + setProvider(selectedProvider); + + const firstModel = modelList.find((m) => m.provider === selectedProvider.name); + + if (firstModel && setModel) { + setModel(firstModel.name); + } + } + + setIsProviderDropdownOpen(false); + setProviderSearchQuery(''); + } + + break; + case 'Escape': + e.preventDefault(); + setIsProviderDropdownOpen(false); + setProviderSearchQuery(''); + break; + case 'Tab': + if (!e.shiftKey && focusedProviderIndex === filteredProviders.length - 1) { + setIsProviderDropdownOpen(false); + } + + break; + } + }; + + useEffect(() => { + if (focusedModelIndex >= 0 && modelOptionsRef.current[focusedModelIndex]) { + modelOptionsRef.current[focusedModelIndex]?.scrollIntoView({ block: 'nearest' }); + } + }, [focusedModelIndex]); + + useEffect(() => { + if (focusedProviderIndex >= 0 && providerOptionsRef.current[focusedProviderIndex]) { + providerOptionsRef.current[focusedProviderIndex]?.scrollIntoView({ block: 'nearest' }); + } + }, [focusedProviderIndex]); + useEffect(() => { - // If current provider is disabled, switch to first enabled provider if (providerList.length === 0) { return; } - if (provider && !providerList.map((p) => p.name).includes(provider.name)) { + if (provider && !providerList.some((p) => p.name === provider.name)) { const firstEnabledProvider = providerList[0]; setProvider?.(firstEnabledProvider); - // Also update the model to the first available one for the new provider const firstModel = modelList.find((m) => m.provider === firstEnabledProvider.name); if (firstModel) { @@ -165,32 +216,136 @@ export const ModelSelector = ({ } return ( -
- setProviderSearchQuery(e.target.value)} + placeholder="Search providers..." + className={classNames( + 'w-full pl-2 py-1.5 rounded-md text-sm', + 'bg-bolt-elements-background-depth-2 border border-bolt-elements-borderColor', + 'text-bolt-elements-textPrimary placeholder:text-bolt-elements-textTertiary', + 'focus:outline-none focus:ring-2 focus:ring-bolt-elements-focus', + 'transition-all', + )} + onClick={(e) => e.stopPropagation()} + role="searchbox" + aria-label="Search providers" + /> +
+ +
+
+
- const firstModel = [...modelList].find((m) => m.provider === e.target.value); +
+ {filteredProviders.length === 0 ? ( +
No providers found
+ ) : ( + filteredProviders.map((providerOption, index) => ( +
(providerOptionsRef.current[index] = el)} + key={providerOption.name} + role="option" + aria-selected={provider?.name === providerOption.name} + className={classNames( + 'px-3 py-2 text-sm cursor-pointer', + 'hover:bg-bolt-elements-background-depth-3', + 'text-bolt-elements-textPrimary', + 'outline-none', + provider?.name === providerOption.name || focusedProviderIndex === index + ? 'bg-bolt-elements-background-depth-2' + : undefined, + focusedProviderIndex === index ? 'ring-1 ring-inset ring-bolt-elements-focus' : undefined, + )} + onClick={(e) => { + e.stopPropagation(); - if (firstModel && setModel) { - setModel(firstModel.name); - } - }} - className="flex-1 p-2 rounded-lg border border-bolt-elements-borderColor bg-bolt-elements-prompt-background text-bolt-elements-textPrimary focus:outline-none focus:ring-2 focus:ring-bolt-elements-focus transition-all" - > - {providerList.map((provider: ProviderInfo) => ( - - ))} - + if (setProvider) { + setProvider(providerOption); -
+ const firstModel = modelList.find((m) => m.provider === providerOption.name); + + if (firstModel && setModel) { + setModel(firstModel.name); + } + } + + setIsProviderDropdownOpen(false); + setProviderSearchQuery(''); + }} + tabIndex={focusedProviderIndex === index ? 0 : -1} + > + {providerOption.name} +
+ )) + )} +
+
+ )} +
+ + {/* Model Combobox */} +
setModelSearchQuery(e.target.value)} placeholder="Search models..." className={classNames( - 'w-full pl-8 pr-3 py-1.5 rounded-md text-sm', + 'w-full pl-2 py-1.5 rounded-md text-sm', 'bg-bolt-elements-background-depth-2 border border-bolt-elements-borderColor', 'text-bolt-elements-textPrimary placeholder:text-bolt-elements-textTertiary', 'focus:outline-none focus:ring-2 focus:ring-bolt-elements-focus', @@ -277,8 +432,8 @@ export const ModelSelector = ({ ) : ( filteredModels.map((modelOption, index) => (
(optionsRef.current[index] = el)} - key={index} + ref={(el) => (modelOptionsRef.current[index] = el)} + key={index} // Consider using modelOption.name if unique role="option" aria-selected={model === modelOption.name} className={classNames( @@ -286,10 +441,10 @@ export const ModelSelector = ({ 'hover:bg-bolt-elements-background-depth-3', 'text-bolt-elements-textPrimary', 'outline-none', - model === modelOption.name || focusedIndex === index + model === modelOption.name || focusedModelIndex === index ? 'bg-bolt-elements-background-depth-2' : undefined, - focusedIndex === index ? 'ring-1 ring-inset ring-bolt-elements-focus' : undefined, + focusedModelIndex === index ? 'ring-1 ring-inset ring-bolt-elements-focus' : undefined, )} onClick={(e) => { e.stopPropagation(); @@ -297,7 +452,7 @@ export const ModelSelector = ({ setIsModelDropdownOpen(false); setModelSearchQuery(''); }} - tabIndex={focusedIndex === index ? 0 : -1} + tabIndex={focusedModelIndex === index ? 0 : -1} > {modelOption.label}
diff --git a/app/components/chat/StarterTemplates.tsx b/app/components/chat/StarterTemplates.tsx index fa51961..20b666e 100644 --- a/app/components/chat/StarterTemplates.tsx +++ b/app/components/chat/StarterTemplates.tsx @@ -21,19 +21,11 @@ const FrameworkLink: React.FC = ({ template }) => ( ); const StarterTemplates: React.FC = () => { - // Debug: Log available templates and their icons - React.useEffect(() => { - console.log( - 'Available templates:', - STARTER_TEMPLATES.map((t) => ({ name: t.name, icon: t.icon })), - ); - }, []); - return (
or start a blank app with your favorite stack
-
+
{STARTER_TEMPLATES.map((template) => ( ))} diff --git a/app/components/chat/SupabaseAlert.tsx b/app/components/chat/SupabaseAlert.tsx index d86e5e5..414a6e5 100644 --- a/app/components/chat/SupabaseAlert.tsx +++ b/app/components/chat/SupabaseAlert.tsx @@ -99,7 +99,7 @@ export function SupabaseChatAlert({ alert, clearAlert, postMessage }: Props) { animate={{ opacity: 1, y: 0 }} exit={{ opacity: 0, y: -20 }} transition={{ duration: 0.3 }} - className="max-w-chat rounded-lg border-l-2 border-l-[#098F5F] border-bolt-elements-borderColor bg-bolt-elements-background-depth-2" + className="max-w-chat rounded-lg border-l-2 border-l-[#098F5F] border border-bolt-elements-borderColor bg-bolt-elements-background-depth-2" > {/* Header */}
diff --git a/app/components/chat/SupabaseConnection.tsx b/app/components/chat/SupabaseConnection.tsx index dc73973..64d46ef 100644 --- a/app/components/chat/SupabaseConnection.tsx +++ b/app/components/chat/SupabaseConnection.tsx @@ -296,7 +296,7 @@ export function SupabaseConnection() { Close -
+
Disconnect
diff --git a/app/components/chat/UserMessage.tsx b/app/components/chat/UserMessage.tsx index e7ef54a..4e3d4ad 100644 --- a/app/components/chat/UserMessage.tsx +++ b/app/components/chat/UserMessage.tsx @@ -16,7 +16,7 @@ export function UserMessage({ content }: UserMessageProps) { const images = content.filter((item) => item.type === 'image' && item.image); return ( -
+
{textContent && {textContent}} {images.map((item, index) => ( diff --git a/app/components/chat/chatExportAndImport/ImportButtons.tsx b/app/components/chat/chatExportAndImport/ImportButtons.tsx index cc792c3..c183558 100644 --- a/app/components/chat/chatExportAndImport/ImportButtons.tsx +++ b/app/components/chat/chatExportAndImport/ImportButtons.tsx @@ -64,13 +64,13 @@ export function ImportButtons(importChat: ((description: string, messages: Messa const input = document.getElementById('chat-import'); input?.click(); }} - variant="outline" + variant="default" size="lg" className={classNames( 'gap-2 bg-bolt-elements-background-depth-1', 'text-bolt-elements-textPrimary', 'hover:bg-bolt-elements-background-depth-2', - 'border-[rgba(0,0,0,0.08)] dark:border-[rgba(255,255,255,0.08)]', + 'border border-bolt-elements-borderColor', 'h-10 px-4 py-2 min-w-[120px] justify-center', 'transition-all duration-200 ease-in-out', )} diff --git a/app/components/ui/Dialog.tsx b/app/components/ui/Dialog.tsx index 46af878..ed072dd 100644 --- a/app/components/ui/Dialog.tsx +++ b/app/components/ui/Dialog.tsx @@ -116,7 +116,7 @@ export const Dialog = memo(({ children, className, showCloseButton = true, onClo )} -
+
void; +} + +export const ExpoQrModal: React.FC = ({ open, onClose }) => { + const expoUrl = useStore(expoUrlAtom); + + return ( + !v && onClose()}> + +
+
+ + Preview on your own mobile device + + + Scan this QR code with the Expo Go app on your mobile device to open your project. + +
+ {expoUrl ? ( + + ) : ( +
No Expo URL detected.
+ )} +
+
+
+
+ ); +}; diff --git a/app/components/workbench/FileTree.tsx b/app/components/workbench/FileTree.tsx index 197fa4f..7ea96f8 100644 --- a/app/components/workbench/FileTree.tsx +++ b/app/components/workbench/FileTree.tsx @@ -143,7 +143,7 @@ export const FileTree = memo( }; return ( -
+
{filteredFileList.map((fileOrFolder) => { switch (fileOrFolder.kind) { case 'file': { diff --git a/app/components/workbench/PortDropdown.tsx b/app/components/workbench/PortDropdown.tsx index 13457b2..d84f940 100644 --- a/app/components/workbench/PortDropdown.tsx +++ b/app/components/workbench/PortDropdown.tsx @@ -1,5 +1,4 @@ import { memo, useEffect, useRef } from 'react'; -import { IconButton } from '~/components/ui/IconButton'; import type { PreviewInfo } from '~/lib/stores/previews'; interface PortDropdownProps { @@ -48,9 +47,18 @@ export const PortDropdown = memo( return (
- setIsDropdownOpen(!isDropdownOpen)} /> + {/* Display the active port if available, otherwise show the plug icon */} + {isDropdownOpen && ( -
+
Ports
diff --git a/app/components/workbench/Preview.tsx b/app/components/workbench/Preview.tsx index ec2a1cd..0d41571 100644 --- a/app/components/workbench/Preview.tsx +++ b/app/components/workbench/Preview.tsx @@ -4,6 +4,8 @@ import { IconButton } from '~/components/ui/IconButton'; import { workbenchStore } from '~/lib/stores/workbench'; import { PortDropdown } from './PortDropdown'; import { ScreenshotSelector } from './ScreenshotSelector'; +import { expoUrlAtom } from '~/lib/stores/qrCodeStore'; +import { ExpoQrModal } from '~/components/workbench/ExpoQrModal'; type ResizeSide = 'left' | 'right' | null; @@ -53,12 +55,10 @@ export const Preview = memo(() => { 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]; - - const [url, setUrl] = useState(''); + const [displayPath, setDisplayPath] = useState('/'); const [iframeUrl, setIframeUrl] = useState(); const [isSelectionMode, setIsSelectionMode] = useState(false); @@ -86,39 +86,22 @@ export const Preview = memo(() => { const [isLandscape, setIsLandscape] = useState(false); const [showDeviceFrame, setShowDeviceFrame] = useState(true); const [showDeviceFrameInPreview, setShowDeviceFrameInPreview] = useState(false); + const expoUrl = useStore(expoUrlAtom); + const [isExpoQrModalOpen, setIsExpoQrModalOpen] = useState(false); useEffect(() => { if (!activePreview) { - setUrl(''); setIframeUrl(undefined); + setDisplayPath('/'); return; } const { baseUrl } = activePreview; - setUrl(baseUrl); setIframeUrl(baseUrl); + setDisplayPath('/'); }, [activePreview]); - const validateUrl = useCallback( - (value: string) => { - if (!activePreview) { - return false; - } - - const { baseUrl } = activePreview; - - if (value === baseUrl) { - return true; - } else if (value.startsWith(baseUrl)) { - return ['/', '?', '#'].includes(value.charAt(baseUrl.length)); - } - - return false; - }, - [activePreview], - ); - const findMinPortIndex = useCallback( (minIndex: number, preview: { port: number }, index: number, array: { port: number }[]) => { return preview.port < array[minIndex].port ? index : minIndex; @@ -565,6 +548,12 @@ export const Preview = memo(() => { } }; + const openInNewTab = () => { + if (activePreview?.baseUrl) { + window.open(activePreview?.baseUrl, '_blank'); + } + }; + // Function to get the correct frame padding based on orientation const getFramePadding = useCallback(() => { if (!selectedWindowSize) { @@ -630,10 +619,7 @@ export const Preview = memo(() => { }, [showDeviceFrameInPreview]); return ( -
+
{isPortDropdownOpen && (
setIsPortDropdownOpen(false)} /> )} @@ -647,50 +633,60 @@ export const Preview = memo(() => { />
-
+
+ (hasSelectedPreview.current = value)} + setIsDropdownOpen={setIsPortDropdownOpen} + previews={previews} + /> { - setUrl(event.target.value); + setDisplayPath(event.target.value); }} onKeyDown={(event) => { - if (event.key === 'Enter' && validateUrl(url)) { - setIframeUrl(url); + if (event.key === 'Enter' && activePreview) { + let targetPath = displayPath.trim(); + + if (!targetPath.startsWith('/')) { + targetPath = '/' + targetPath; + } + + const fullUrl = activePreview.baseUrl + targetPath; + setIframeUrl(fullUrl); + setDisplayPath(targetPath); if (inputRef.current) { inputRef.current.blur(); } } }} + disabled={!activePreview} />
- {previews.length > 1 && ( - (hasSelectedPreview.current = value)} - setIsDropdownOpen={setIsPortDropdownOpen} - previews={previews} - /> - )} - + {expoUrl && setIsExpoQrModalOpen(true)} title="Show QR" />} + + setIsExpoQrModalOpen(false)} /> + {isDeviceModeOn && ( <> setIsLandscape(!isLandscape)} title={isLandscape ? 'Switch to Portrait' : 'Switch to Landscape'} /> @@ -702,60 +698,17 @@ export const Preview = memo(() => { )} - setIsPreviewOnly(!isPreviewOnly)} - title={isPreviewOnly ? 'Show Full Interface' : 'Show Preview Only'} - /> - - {/* Simple preview button */} - { - if (!activePreview?.baseUrl) { - console.warn('[Preview] No active preview available'); - return; - } - - const match = activePreview.baseUrl.match( - /^https?:\/\/([^.]+)\.local-credentialless\.webcontainer-api\.io/, - ); - - if (!match) { - console.warn('[Preview] Invalid WebContainer URL:', activePreview.baseUrl); - return; - } - - const previewId = match[1]; - const previewUrl = `/webcontainer/preview/${previewId}`; - - // Open in a new window with simple parameters - window.open( - previewUrl, - `preview-${previewId}`, - 'width=1280,height=720,menubar=no,toolbar=no,location=no,status=no,resizable=yes', - ); - }} - title="Open Preview in New Window" - /> -
openInNewWindow(selectedWindowSize)} - title={`Open Preview in ${selectedWindowSize.name} Window`} - /> - setIsWindowSizeDropdownOpen(!isWindowSizeDropdownOpen)} - className="ml-1" - title="Select Window Size" + title="New Window Options" /> {isWindowSizeDropdownOpen && ( @@ -764,11 +717,51 @@ export const Preview = memo(() => {
- Device Options + Window Options
+ +
- Show Device Frame + Show Device Frame
- Landscape Mode + Landscape Mode