diff --git a/app/components/chat/BaseChat.tsx b/app/components/chat/BaseChat.tsx index 934a3d5545..6348759b21 100644 --- a/app/components/chat/BaseChat.tsx +++ b/app/components/chat/BaseChat.tsx @@ -28,6 +28,7 @@ import type { ProgressAnnotation } from '~/types/context'; import { SupabaseChatAlert } from '~/components/chat/SupabaseAlert'; import { expoUrlAtom } from '~/lib/stores/qrCodeStore'; import { useStore } from '@nanostores/react'; +import { PromptQueuePanel } from './PromptQueuePanel'; import { StickToBottom, useStickToBottomContext } from '~/lib/hooks'; import { ChatBox } from './ChatBox'; import type { DesignScheme } from '~/types/design-scheme'; @@ -469,6 +470,7 @@ export const BaseChat = React.forwardRef( setSelectedElement={setSelectedElement} onWebSearchResult={onWebSearchResult} /> + {chatStarted && }
diff --git a/app/components/chat/Chat.client.tsx b/app/components/chat/Chat.client.tsx index ccddaf51d6..2e44add883 100644 --- a/app/components/chat/Chat.client.tsx +++ b/app/components/chat/Chat.client.tsx @@ -21,6 +21,7 @@ import { createSampler } from '~/utils/sampler'; import { getTemplates, selectStarterTemplate } from '~/utils/selectStarterTemplate'; import { logStore } from '~/lib/stores/logs'; import { streamingState } from '~/lib/stores/streaming'; +import { promptQueueStore, advanceQueue, clearPendingPrompt, stopQueue } from '~/lib/stores/promptQueue'; import { filesToArtifacts } from '~/utils/fileUtils'; import { supabaseConnection } from '~/lib/stores/supabase'; import { defaultDesignScheme, type DesignScheme } from '~/types/design-scheme'; @@ -154,6 +155,7 @@ export const ChatImpl = memo( onError: (e) => { setFakeLoading(false); handleError(e, 'chat'); + stopQueue(); }, onFinish: (message, response) => { const usage = response.usage; @@ -172,6 +174,16 @@ export const ChatImpl = memo( } logger.debug('Finished streaming'); + + /* Advance the prompt queue if one is running */ + const nextPrompt = advanceQueue(); + + if (nextPrompt) { + /* Small delay so the UI can settle before the next message fires */ + setTimeout(() => { + promptQueueStore.setKey('pendingPrompt', nextPrompt); + }, 800); + } }, initialMessages, initialInput: Cookies.get(PROMPT_COOKIE_KEY) || '', @@ -200,6 +212,38 @@ export const ChatImpl = memo( chatStore.setKey('started', initialMessages.length > 0); }, []); + /* + * Pre-fill the textarea with the follow-up prompt set by ImportZipButton before the + * full-page navigation. Using setInput instead of append so the user confirms with + * one Enter keystroke — avoids model-state race conditions on chat initialisation. + */ + useEffect(() => { + if (initialMessages.length === 0) { + return; + } + + const autorun = localStorage.getItem('bolt_zip_autorun'); + + if (autorun) { + localStorage.removeItem('bolt_zip_autorun'); + setInput(autorun); + } + }, []); + + /* Fire the next queued prompt whenever the store signals one is ready */ + useEffect(() => { + const unsubscribe = promptQueueStore.subscribe((state) => { + if (state.pendingPrompt) { + clearPendingPrompt(); + + const messageText = `[Model: ${model}]\n\n[Provider: ${provider.name}]\n\n${state.pendingPrompt}`; + append({ role: 'user', content: messageText }); + } + }); + + return unsubscribe; + }, [append, model, provider]); + useEffect(() => { processSampledMessages({ messages, diff --git a/app/components/chat/ImportZipButton.tsx b/app/components/chat/ImportZipButton.tsx new file mode 100644 index 0000000000..fce033fc6f --- /dev/null +++ b/app/components/chat/ImportZipButton.tsx @@ -0,0 +1,109 @@ +import React, { useRef, useState } from 'react'; +import type { Message } from 'ai'; +import { toast } from 'react-toastify'; +import { createChatFromZip } from '~/utils/zipImport'; +import { logStore } from '~/lib/stores/logs'; +import { Button } from '~/components/ui/Button'; +import { classNames } from '~/utils/classNames'; + +interface ImportZipButtonProps { + className?: string; + importChat?: (description: string, messages: Message[]) => Promise; +} + +export const ImportZipButton: React.FC = ({ className, importChat }) => { + const [isLoading, setIsLoading] = useState(false); + const inputRef = useRef(null); + + const handleFileChange = async (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + + if (!file) { + return; + } + + setIsLoading(true); + + const loadingToast = toast.loading(`Importing ${file.name}…`); + + try { + const result = await createChatFromZip(file); + + if (result.skippedBinary > 0) { + logStore.logWarning('Skipping binary files during ZIP import', { + zipName: file.name, + binaryCount: result.skippedBinary, + }); + toast.info(`Skipping ${result.skippedBinary} binary file${result.skippedBinary === 1 ? '' : 's'}`); + } + + /* + * Set flag before navigation so the new chat picks it up on mount. + * importChat does a full window.location.href redirect, so append() + * would be gone by the time it resolves. + */ + if (result.hasExpoConfig) { + /* + * Expo/React Native — WebContainer can't run native code. + * Ask bolt to review the code instead of trying to boot the project. + */ + localStorage.setItem( + 'bolt_zip_autorun', + 'This is an Expo/React Native project. Review the code structure and give me a summary of what the app does and how it is organized. Do not run any install or dev server commands — I will run this locally with Expo CLI.', + ); + } else if (result.hasPackageJson) { + localStorage.setItem('bolt_zip_autorun', 'Install the dependencies and start the development server.'); + } + + if (importChat) { + await importChat(file.name.replace(/\.zip$/i, ''), result.messages); + } + + logStore.logSystem('ZIP imported successfully', { + zipName: file.name, + textFileCount: result.totalFiles - result.skippedBinary - result.skippedIgnored, + binaryFileCount: result.skippedBinary, + ignoredFileCount: result.skippedIgnored, + }); + + toast.success('ZIP imported successfully'); + } catch (error) { + logStore.logError('Failed to import ZIP', error, { zipName: file.name }); + console.error('Failed to import ZIP:', error); + toast.error(error instanceof Error ? error.message : 'Failed to import ZIP'); + } finally { + setIsLoading(false); + toast.dismiss(loadingToast); + + // Reset so the same file can be re-selected + if (inputRef.current) { + inputRef.current.value = ''; + } + } + }; + + return ( + <> + + + + ); +}; diff --git a/app/components/chat/PromptQueuePanel.tsx b/app/components/chat/PromptQueuePanel.tsx new file mode 100644 index 0000000000..b606486c31 --- /dev/null +++ b/app/components/chat/PromptQueuePanel.tsx @@ -0,0 +1,239 @@ +import { useStore } from '@nanostores/react'; +import { useState } from 'react'; +import { toast } from 'react-toastify'; +import { promptQueueStore, loadQueue, startQueue, stopQueue } from '~/lib/stores/promptQueue'; +import { classNames } from '~/utils/classNames'; + +/** + * Parses a raw string into an array of prompts. + * + * Supported formats (auto-detected, tried in order): + * 1. Code-block format — content inside every ``` block is one prompt + * (handles the "## PROMPT NNN\n```\n...\n```" style) + * 2. --- separator — sections divided by horizontal rules + * 3. ## heading format — sections divided by markdown headings + * 4. One prompt per line — plain newline-separated list (original fallback) + */ +function parsePrompts(raw: string): string[] { + const trimmed = raw.trim(); + + const codeBlockMatches = [...trimmed.matchAll(/```(?:\w+)?\n([\s\S]*?)```/g)]; + + if (codeBlockMatches.length > 0) { + return codeBlockMatches.map((m) => m[1].trim()).filter(Boolean); + } + + if (/\n---+\n/.test(trimmed)) { + return trimmed + .split(/\n---+\n/) + .map((s) => s.trim()) + .filter(Boolean); + } + + if (/^#{1,3} /m.test(trimmed)) { + return trimmed + .split(/^#{1,3} .+$/m) + .map((s) => s.trim()) + .filter(Boolean); + } + + return trimmed + .split('\n') + .map((l) => l.trim()) + .filter(Boolean); +} + +interface PromptQueuePanelProps { + isStreaming: boolean; +} + +export function PromptQueuePanel({ isStreaming }: PromptQueuePanelProps) { + const [isOpen, setIsOpen] = useState(false); + const [draft, setDraft] = useState(''); + const { prompts, currentIndex, isRunning } = useStore(promptQueueStore); + + const handleLoad = () => { + const parsed = parsePrompts(draft); + + if (parsed.length === 0) { + toast.error('Enter at least one prompt'); + return; + } + + loadQueue(parsed); + toast.success(`Loaded ${parsed.length} prompt${parsed.length === 1 ? '' : 's'}`); + }; + + const handleStart = () => { + if (prompts.length === 0) { + toast.error('Load prompts first'); + return; + } + + if (isStreaming) { + toast.error('Wait for the current response to finish'); + return; + } + + startQueue(); + }; + + const handleStop = () => { + stopQueue(); + toast.info('Queue stopped'); + }; + + const isAllDone = !isRunning && currentIndex === prompts.length && prompts.length > 0; + const progressLabel = isAllDone + ? `All ${prompts.length} prompts done ✓` + : isRunning + ? `Prompt ${currentIndex + 1} of ${prompts.length}` + : prompts.length > 0 + ? `${prompts.length} prompt${prompts.length === 1 ? '' : 's'} loaded` + : ''; + + return ( +
+ {/* Toggle bar — always visible, minimal height */} + + + {/* Expandable body — floats ABOVE the toggle, does not push chat layout */} + {isOpen && ( +
+ {/* Tip */} +

+ Tip: queues of 10–15 prompts work well. Longer runs may stall if bolt hits context limits or errors — use + Stop to recover and resume. +

+ + {/* Prompt editor */} +