From ec4793ecfc7f9b7dbbd6a00992ab7f2570835d66 Mon Sep 17 00:00:00 2001 From: FrostyPhoenix2 Date: Sat, 18 Apr 2026 09:52:34 -0600 Subject: [PATCH 1/6] feat: add ZIP archive import button --- app/components/chat/Chat.client.tsx | 18 ++ app/components/chat/ImportZipButton.tsx | 100 +++++++++++ .../chatExportAndImport/ImportButtons.tsx | 12 ++ app/utils/zipImport.ts | 155 ++++++++++++++++++ 4 files changed, 285 insertions(+) create mode 100644 app/components/chat/ImportZipButton.tsx create mode 100644 app/utils/zipImport.ts diff --git a/app/components/chat/Chat.client.tsx b/app/components/chat/Chat.client.tsx index ccddaf51d6..f5fe7b3087 100644 --- a/app/components/chat/Chat.client.tsx +++ b/app/components/chat/Chat.client.tsx @@ -200,6 +200,24 @@ 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); + } + }, []); + useEffect(() => { processSampledMessages({ messages, diff --git a/app/components/chat/ImportZipButton.tsx b/app/components/chat/ImportZipButton.tsx new file mode 100644 index 0000000000..a2ea228e4c --- /dev/null +++ b/app/components/chat/ImportZipButton.tsx @@ -0,0 +1,100 @@ +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.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/chatExportAndImport/ImportButtons.tsx b/app/components/chat/chatExportAndImport/ImportButtons.tsx index c183558714..92c9f46c90 100644 --- a/app/components/chat/chatExportAndImport/ImportButtons.tsx +++ b/app/components/chat/chatExportAndImport/ImportButtons.tsx @@ -1,6 +1,7 @@ import type { Message } from 'ai'; import { toast } from 'react-toastify'; import { ImportFolderButton } from '~/components/chat/ImportFolderButton'; +import { ImportZipButton } from '~/components/chat/ImportZipButton'; import { Button } from '~/components/ui/Button'; import { classNames } from '~/utils/classNames'; @@ -89,6 +90,17 @@ export function ImportButtons(importChat: ((description: string, messages: Messa 'transition-all duration-200 ease-in-out rounded-lg', )} /> + diff --git a/app/utils/zipImport.ts b/app/utils/zipImport.ts new file mode 100644 index 0000000000..1c0df51cf4 --- /dev/null +++ b/app/utils/zipImport.ts @@ -0,0 +1,155 @@ +import type { Message } from 'ai'; +import JSZip from 'jszip'; +import { generateId, shouldIncludeFile } from './fileUtils'; +import { escapeBoltTags } from './projectCommands'; + +/** + * Checks whether a Uint8Array looks like a binary file. + * Mirrors the logic in isBinaryFile (fileUtils.ts) but works directly + * with raw bytes instead of a browser File object. + */ +function isBinaryBuffer(buffer: Uint8Array): boolean { + const checkLength = Math.min(buffer.length, 1024); + + for (let i = 0; i < checkLength; i++) { + const byte = buffer[i]; + + if (byte === 0 || (byte < 32 && byte !== 9 && byte !== 10 && byte !== 13)) { + return true; + } + } + + return false; +} + +/** + * Detects and returns a common root prefix that all file paths share + * (e.g. "my-project/" when macOS Compress or GitHub wraps everything + * in a top-level folder). Returns an empty string if there is no such + * prefix, or if the prefix would consume all path segments. + */ +function detectRootPrefix(paths: string[]): string { + if (paths.length === 0) { + return ''; + } + + const firstSegment = paths[0].split('/')[0] + '/'; + const allSharePrefix = paths.every((p) => p.startsWith(firstSegment)); + + // Make sure the prefix is not the entire path of every file + const prefixIsWholeFile = paths.every((p) => p === firstSegment || p === firstSegment.slice(0, -1)); + + if (allSharePrefix && !prefixIsWholeFile && firstSegment !== '/') { + return firstSegment; + } + + return ''; +} + +export interface ZipImportResult { + messages: Message[]; + skippedBinary: number; + skippedIgnored: number; + totalFiles: number; + hasPackageJson: boolean; +} + +/** + * Reads a ZIP file and converts its contents into the same boltArtifact + * chat-message structure that createChatFromFolder produces. This lets + * bolt load the project correctly without writing directly to the + * WebContainer filesystem (which can be wiped by navigation resets). + */ +export const createChatFromZip = async (zipFile: File): Promise => { + const zip = new JSZip(); + const contents = await zip.loadAsync(zipFile); + + // Collect all non-directory entries + const allEntries = Object.entries(contents.files).filter(([, entry]) => !entry.dir); + + if (allEntries.length === 0) { + throw new Error('The ZIP file contains no files.'); + } + + // Strip common root prefix (e.g. "my-project/" from macOS Compress) + const rawPaths = allEntries.map(([path]) => path); + const prefix = detectRootPrefix(rawPaths); + + // Process every entry: resolve path, filter, read bytes + const fileArtifacts: Array<{ path: string; content: string }> = []; + const binaryFilePaths: string[] = []; + let skippedIgnored = 0; + + for (const [rawPath, zipEntry] of allEntries) { + const relativePath = prefix ? rawPath.slice(prefix.length) : rawPath; + + // Skip empty paths that arise when stripping the prefix of the root dir entry itself + if (!relativePath) { + continue; + } + + // Apply the same ignore rules as ImportFolderButton + if (!shouldIncludeFile(relativePath)) { + skippedIgnored++; + continue; + } + + const buffer = await zipEntry.async('uint8array'); + + if (isBinaryBuffer(buffer)) { + binaryFilePaths.push(relativePath); + continue; + } + + const content = new TextDecoder('utf-8', { fatal: false }).decode(buffer); + fileArtifacts.push({ path: relativePath, content }); + } + + if (fileArtifacts.length === 0) { + throw new Error('No readable text files found in the ZIP (all files were binary or ignored).'); + } + + // Detect whether this is a Node project so we can craft the follow-up prompt + const hasPackageJson = fileArtifacts.some((f) => f.path === 'package.json' || f.path.endsWith('/package.json')); + + const folderName = zipFile.name.replace(/\.zip$/i, ''); + + const binaryFilesMessage = + binaryFilePaths.length > 0 + ? `\n\nSkipped ${binaryFilePaths.length} binary file${binaryFilePaths.length === 1 ? '' : 's'}:\n${binaryFilePaths.map((f) => `- ${f}`).join('\n')}` + : ''; + + const filesMessage: Message = { + role: 'assistant', + content: `I've imported the contents of the "${folderName}" ZIP archive.${binaryFilesMessage} + + +${fileArtifacts + .map( + (file) => ` +${escapeBoltTags(file.content)} +`, + ) + .join('\n\n')} +`, + id: generateId(), + createdAt: new Date(), + }; + + const userMessage: Message = { + role: 'user', + id: generateId(), + content: `Import the "${folderName}" project from ZIP`, + createdAt: new Date(), + }; + + const messages: Message[] = [userMessage, filesMessage]; + + return { + messages, + skippedBinary: binaryFilePaths.length, + skippedIgnored, + totalFiles: fileArtifacts.length + binaryFilePaths.length + skippedIgnored, + hasPackageJson, + }; +}; From 4b0b0860a55d931004a88f359439c120ac149451 Mon Sep 17 00:00:00 2001 From: FrostyPhoenix2 Date: Sat, 18 Apr 2026 09:52:34 -0600 Subject: [PATCH 2/6] feat(zip-import): detect Expo projects and skip dev server autorun - Add hasExpoConfig detection in zipImport.ts (checks app.json, app.config.js, and expo key in package.json dependencies) - Strip runnable scripts from package.json for Expo imports so bolt cannot attempt to boot a native project in WebContainer - Show code review prompt for Expo imports instead of install+start command --- app/components/chat/ImportZipButton.tsx | 11 +++++- app/utils/zipImport.ts | 47 +++++++++++++++++++++++++ 2 files changed, 57 insertions(+), 1 deletion(-) diff --git a/app/components/chat/ImportZipButton.tsx b/app/components/chat/ImportZipButton.tsx index a2ea228e4c..fce033fc6f 100644 --- a/app/components/chat/ImportZipButton.tsx +++ b/app/components/chat/ImportZipButton.tsx @@ -42,7 +42,16 @@ export const ImportZipButton: React.FC = ({ className, imp * importChat does a full window.location.href redirect, so append() * would be gone by the time it resolves. */ - if (result.hasPackageJson) { + 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.'); } diff --git a/app/utils/zipImport.ts b/app/utils/zipImport.ts index 1c0df51cf4..9313fb8954 100644 --- a/app/utils/zipImport.ts +++ b/app/utils/zipImport.ts @@ -52,6 +52,7 @@ export interface ZipImportResult { skippedIgnored: number; totalFiles: number; hasPackageJson: boolean; + hasExpoConfig: boolean; } /** @@ -112,6 +113,51 @@ export const createChatFromZip = async (zipFile: File): Promise // Detect whether this is a Node project so we can craft the follow-up prompt const hasPackageJson = fileArtifacts.some((f) => f.path === 'package.json' || f.path.endsWith('/package.json')); + /* + * Detect Expo/React Native projects so we can suppress the "npm install + * and start dev server" prompt — WebContainer can't run native mobile code. + * We check for app.json / app.config.js (Expo-specific config files) or + * an "expo" key in the package.json dependencies. + */ + const hasExpoConfig = + fileArtifacts.some((f) => f.path === 'app.json' || f.path === 'app.config.js' || f.path === 'app.config.ts') || + fileArtifacts.some((f) => { + if (f.path !== 'package.json') { + return false; + } + + try { + const pkg = JSON.parse(f.content); + return !!(pkg.dependencies?.expo || pkg.devDependencies?.expo); + } catch { + return false; + } + }); + + /* + * For Expo projects, strip the scripts from package.json so bolt has + * nothing to execute even if it tries. The user will run the project + * locally with Expo CLI. We preserve the rest of package.json intact. + */ + if (hasExpoConfig) { + const pkgIndex = fileArtifacts.findIndex((f) => f.path === 'package.json'); + + if (pkgIndex !== -1) { + try { + const pkg = JSON.parse(fileArtifacts[pkgIndex].content); + pkg.scripts = { + _note: 'Run this project locally: npx expo start', + }; + fileArtifacts[pkgIndex] = { + ...fileArtifacts[pkgIndex], + content: JSON.stringify(pkg, null, 2), + }; + } catch { + /* If package.json is unparseable, leave it as-is */ + } + } + } + const folderName = zipFile.name.replace(/\.zip$/i, ''); const binaryFilesMessage = @@ -151,5 +197,6 @@ ${escapeBoltTags(file.content)} skippedIgnored, totalFiles: fileArtifacts.length + binaryFilePaths.length + skippedIgnored, hasPackageJson, + hasExpoConfig, }; }; From 69b36abce42860bb1d61d49dcec60c8919e37de4 Mon Sep 17 00:00:00 2001 From: FrostyPhoenix2 Date: Sat, 18 Apr 2026 09:52:34 -0600 Subject: [PATCH 3/6] feat(prompt-queue): add prompt queue panel for automated multi-step runs - New promptQueue nanotore tracking prompts, currentIndex, isRunning, pendingPrompt - PromptQueuePanel component with collapsible floating UI above chat input - Supports multi-line prompt formats: code blocks, --- separators, ## headings, one-per-line - Advances queue automatically via onFinish hook, stops on error - Collapsed bar shows live progress (Prompt X of Y) and completion state - Strikethroughs persist after queue finishes so run history stays visible - Tip copy sets expectations on practical queue length --- app/components/chat/BaseChat.tsx | 2 + app/components/chat/Chat.client.tsx | 26 +++ app/components/chat/PromptQueuePanel.tsx | 239 +++++++++++++++++++++++ app/lib/stores/promptQueue.ts | 78 ++++++++ 4 files changed, 345 insertions(+) create mode 100644 app/components/chat/PromptQueuePanel.tsx create mode 100644 app/lib/stores/promptQueue.ts 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 f5fe7b3087..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) || '', @@ -218,6 +230,20 @@ export const ChatImpl = memo( } }, []); + /* 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/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 */} +