diff --git a/.bolt/prompt b/.bolt/prompt new file mode 100644 index 0000000000..0bbfcbb7b8 --- /dev/null +++ b/.bolt/prompt @@ -0,0 +1,10 @@ +This is a React Native / Expo project. + +Important constraints: +- Do NOT run npm install, npx expo start, or any dev server commands — I run this locally with Expo CLI +- Use React Native components (View, Text, TouchableOpacity, FlatList, etc.) — NOT web HTML elements +- Use StyleSheet.create() for styles, not CSS or Tailwind +- Navigation is handled by React Navigation — do not add routing libraries +- All file I/O and native APIs go through Expo SDK modules + +Please review the code structure and give me a summary of what the app does and how it is organized. diff --git a/.gitignore b/.gitignore index 4bc03e175d..3b1dc87167 100644 --- a/.gitignore +++ b/.gitignore @@ -46,3 +46,4 @@ docs/instructions/Roadmap.md .cursorrules *.md .qodo +data/ diff --git a/app/components/chat/Artifact.tsx b/app/components/chat/Artifact.tsx index 94987377d4..02b15a2116 100644 --- a/app/components/chat/Artifact.tsx +++ b/app/components/chat/Artifact.tsx @@ -34,15 +34,21 @@ export const Artifact = memo(({ artifactId }: ArtifactProps) => { const artifacts = useStore(workbenchStore.artifacts); const artifact = artifacts[artifactId]; - const actions = useStore( - computed(artifact.runner.actions, (actions) => { - // Filter out Supabase actions except for migrations - return Object.values(actions).filter((action) => { - // Exclude actions with type 'supabase' or actions that contain 'supabase' in their content - return action.type !== 'supabase' && !(action.type === 'shell' && action.content?.includes('supabase')); - }); - }), + /* + * IMPORTANT: computed() must only be created once per component instance. + * Calling it inline recreates the store every render, giving `actions` a new + * reference each time and triggering an infinite useEffect → setState loop. + * useRef guarantees a single creation — safe because each Artifact mounts once + * for a fixed artifactId and artifact.runner never changes for that instance. + */ + const actionsStoreRef = useRef( + computed(artifact.runner.actions, (actions) => + Object.values(actions).filter( + (action) => action.type !== 'supabase' && !(action.type === 'shell' && action.content?.includes('supabase')), + ), + ), ); + const actions = useStore(actionsStoreRef.current); const toggleActions = () => { userToggledActions.current = true; @@ -59,11 +65,14 @@ export const Artifact = memo(({ artifactId }: ArtifactProps) => { (action) => action.status !== 'complete' && !(action.type === 'start' && action.status === 'running'), ); - if (allActionFinished !== finished) { - setAllActionFinished(finished); - } + /* + * Always call setState — React bails out silently if the value hasn't + * changed, so this is safe and avoids having allActionFinished in deps + * (which would re-trigger this effect every time it updates). + */ + setAllActionFinished(finished); } - }, [actions, artifact.type, allActionFinished]); + }, [actions, artifact.type]); // Determine the dynamic title based on state for bundled artifacts const dynamicTitle = diff --git a/app/components/chat/BaseChat.tsx b/app/components/chat/BaseChat.tsx index 934a3d5545..76e87df003 100644 --- a/app/components/chat/BaseChat.tsx +++ b/app/components/chat/BaseChat.tsx @@ -28,6 +28,8 @@ 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 { LocalLlmPanel } from './LocalLLMPanel'; import { StickToBottom, useStickToBottomContext } from '~/lib/hooks'; import { ChatBox } from './ChatBox'; import type { DesignScheme } from '~/types/design-scheme'; @@ -469,6 +471,8 @@ 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..a74ec81fa9 100644 --- a/app/components/chat/Chat.client.tsx +++ b/app/components/chat/Chat.client.tsx @@ -2,7 +2,7 @@ import { useStore } from '@nanostores/react'; import type { Message } from 'ai'; import { useChat } from '@ai-sdk/react'; import { useAnimate } from 'framer-motion'; -import { memo, useCallback, useEffect, useRef, useState } from 'react'; +import { memo, useCallback, useEffect, useRef, startTransition, useState } from 'react'; import { toast } from 'react-toastify'; import { useMessageParser, usePromptEnhancer, useShortcuts } from '~/lib/hooks'; import { description, useChatHistory } from '~/lib/persistence'; @@ -21,6 +21,8 @@ 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 { localLLMSettingsStore, getTokenBudget, estimateTokens } from '~/lib/stores/localLLMSettings'; import { filesToArtifacts } from '~/utils/fileUtils'; import { supabaseConnection } from '~/lib/stores/supabase'; import { defaultDesignScheme, type DesignScheme } from '~/types/design-scheme'; @@ -67,7 +69,34 @@ const processSampledMessages = createSampler( parseMessages(messages, isLoading); if (messages.length > initialMessages.length) { - storeMessageHistory(messages).catch((error) => toast.error(error.message)); + /* + * Defer IndexedDB writes to avoid blocking the render thread. + * requestIdleCallback fires when the browser is idle between frames; + * setTimeout(0) is the fallback for browsers that don't support it. + */ + const saveHistory = () => { + storeMessageHistory(messages).catch((error) => { + /* + * Suppress network/resource exhaustion errors — not actionable by the user + * and cascade into a flood of toasts when the browser is under memory pressure. + */ + const msg: string = error?.message ?? ''; + const isResourceError = + msg.includes('Failed to fetch') || msg.includes('ERR_INSUFFICIENT') || msg.includes('NetworkError'); + + if (!isResourceError) { + toast.error(msg); + } else { + console.warn('Chat history save failed (resource pressure):', msg); + } + }); + }; + + if (typeof requestIdleCallback !== 'undefined') { + requestIdleCallback(saveHistory, { timeout: 2000 }); + } else { + setTimeout(saveHistory, 0); + } } }, 50, @@ -81,6 +110,75 @@ interface ChatProps { description?: string; } +/** + * Trims the WebContainer file map before it is JSON-serialised into the + * request body. Without this, a large ZIP import (hundreds of source files, + * or worse — a project that includes node_modules / android / ios) can produce + * a multi-megabyte body that freezes the main thread for several seconds every + * time the user hits Send. + * + * Rules (applied in order): + * 1. Skip folder entries in heavy directories — they add no value. + * 2. Skip any file whose path lives under a "never-send" directory. + * 3. Skip any file whose content exceeds MAX_FILE_BYTES. + * 4. Stop adding files once the running total exceeds MAX_TOTAL_BYTES. + * + * Source files (< 50 KB each) are almost always included; compiled bundles, + * lock files, and native code are excluded. + */ +function trimFilesForBody(fileMap: Record): Record { + const BLOCKED_DIRS = [ + 'node_modules/', + '.git/', + 'dist/', + 'build/', + '.expo/', + 'android/', + 'ios/', + '.gradle/', + '.idea/', + '__pycache__/', + ]; + const MAX_FILE_BYTES = 50_000; // 50 KB per file — compiled artefacts tend to be larger + const MAX_TOTAL_BYTES = 500_000; // 500 KB total across all files + + let totalBytes = 0; + const result: Record = {}; + + for (const [path, dirent] of Object.entries(fileMap)) { + if (!dirent) { + continue; + } + + // Always skip paths inside heavy directories + if (BLOCKED_DIRS.some((d) => path.includes(d))) { + continue; + } + + if (dirent.type === 'folder') { + result[path] = dirent; + continue; + } + + // File entry — gate on size + const content: string = typeof dirent.content === 'string' ? dirent.content : ''; + const bytes = content.length; + + if (bytes > MAX_FILE_BYTES) { + continue; // individual file too large + } + + if (totalBytes + bytes > MAX_TOTAL_BYTES) { + continue; // total budget exhausted — skip remaining files + } + + totalBytes += bytes; + result[path] = dirent; + } + + return result; +} + export const ChatImpl = memo( ({ description, initialMessages, storeMessageHistory, importChat, exportChat }: ChatProps) => { useShortcuts(); @@ -101,6 +199,10 @@ export const ChatImpl = memo( ); const supabaseAlert = useStore(workbenchStore.supabaseAlert); const { activeProviders, promptId, autoSelectTemplate, contextOptimizationEnabled } = useSettings(); + const localLLMSettings = useStore(localLLMSettingsStore); + + // When slim system prompt is enabled, override promptId to 'slim' + const effectivePromptId = localLLMSettings.slimSystemPrompt ? 'slim' : promptId; const [llmErrorAlert, setLlmErrorAlert] = useState(undefined); const [model, setModel] = useState(() => { const savedModel = Cookies.get('selectedModel'); @@ -135,9 +237,21 @@ export const ChatImpl = memo( api: '/api/chat', body: { apiKeys, - files, - promptId, - contextOptimization: contextOptimizationEnabled, + files: trimFilesForBody(files), + promptId: effectivePromptId, + + /* + * Disable bolt's context-file-selection pass when local models are + * active — it runs a separate LLM call that times out with Ollama, + * and fails on new files that don't exist in WebContainer yet. + */ + /* + * Context optimization is now safe for local models — the server wraps both + * LLM pre-passes in a timeout and falls back to keyword-based file selection + * if Ollama is too slow. Only hard-disable if the user explicitly opted out. + */ + contextOptimization: localLLMSettings.disableContextOptimization ? false : contextOptimizationEnabled, + isLocalModel: localLLMSettings.enableLocalModels && localLLMSettings.extendedStreamTimeout, chatMode, designScheme, supabase: { @@ -154,6 +268,7 @@ export const ChatImpl = memo( onError: (e) => { setFakeLoading(false); handleError(e, 'chat'); + stopQueue(); }, onFinish: (message, response) => { const usage = response.usage; @@ -172,6 +287,204 @@ export const ChatImpl = memo( } logger.debug('Finished streaming'); + + /* + * Collect all transformation inputs synchronously (before any async yields), + * then apply all message mutations in a SINGLE startTransition-wrapped setMessages + * call. This prevents 4 separate render cycles for each queue step and keeps the + * UI responsive — startTransition marks this batch as non-urgent so React can + * yield to user interactions (clicks, scrolls) between work chunks. + */ + + // Inputs for pass 1: ZIP import compaction + const zipFiletreeRaw = localStorage.getItem('bolt_zip_filetree'); + + if (zipFiletreeRaw) { + localStorage.removeItem('bolt_zip_filetree'); + } + + // Inputs for pass 2+: queue state and settings + const { isRunning } = promptQueueStore.get(); + const llmSettings = localLLMSettingsStore.get(); + const tokenBudget = getTokenBudget(llmSettings); + + startTransition(() => { + setMessages((prev) => { + let msgs = prev; + + // --- Pass 1: ZIP import — replace giant boltArtifact with compact file tree --- + if (zipFiletreeRaw) { + try { + const { folderName, fileCount, tree } = JSON.parse(zipFiletreeRaw); + const compactContent = `I've imported the "${folderName}" project (${fileCount} files). All files have been written to the WebContainer filesystem.\n\nProject structure:\n${tree}\n\nFiles are ready — I'll use context selection to pull relevant files as needed for each task.`; + msgs = msgs.map((m) => + m.content.includes('boltArtifact id="imported-files"') ? { ...m, content: compactContent } : m, + ); + } catch { + /* ignore parse errors */ + } + } + + // --- Pass 2: Queue artifact pruning — strip boltArtifact from older messages --- + if (isRunning) { + const assistantMsgs = msgs.filter((m) => m.role === 'assistant'); + const keepRecent = 2; + const pruneCount = Math.max(0, assistantMsgs.length - keepRecent); + + if (pruneCount > 0) { + let pruned = 0; + msgs = msgs.map((m) => { + if (m.role !== 'assistant' || pruned >= pruneCount) { + return m; + } + + if (m.content.includes('/g, + '[files applied to WebContainer]', + ), + }; + } + + return m; + }); + } + } + + // --- Pass 3: Local LLM optimizations — dedup file writes and strip old prose --- + if (llmSettings.dedupFileWrites || llmSettings.stripOldProse) { + if (llmSettings.dedupFileWrites) { + /* + * Walk messages newest-first, track file paths already seen. + * For any older write of the same path, replace with a stub so + * the model doesn't re-read stale file versions. + */ + const seenPaths = new Set(); + + msgs = msgs + .slice() + .reverse() + .map((m) => { + if (m.role !== 'assistant' || !m.content.includes('([\s\S]*?)<\/boltAction>/g, + (match, filePath) => { + if (seenPaths.has(filePath)) { + return `[superseded by later write]`; + } + + seenPaths.add(filePath); + + return match; + }, + ); + + return newContent !== m.content ? { ...m, content: newContent } : m; + }) + .reverse(); + } + + if (llmSettings.stripOldProse) { + /* + * For assistant messages older than the last 2, strip everything + * outside tags. The prose is already read — keeping + * it is pure token cost. + */ + const assistantIndices = msgs.map((m, i) => (m.role === 'assistant' ? i : -1)).filter((i) => i >= 0); + const pruneSet = new Set(assistantIndices.slice(0, Math.max(0, assistantIndices.length - 2))); + + msgs = msgs.map((m, i) => { + if (!pruneSet.has(i)) { + return m; + } + + if (!m.content.includes('/g)] + .map((match) => match[0]) + .join('\n'); + + return { ...m, content: artifacts || '[response]' }; + }); + } + } + + // --- Pass 4: Token-budget pruning — trim oldest messages until under budget --- + if (tokenBudget !== null) { + const totalTokens = msgs.reduce((sum, m) => sum + estimateTokens(String(m.content)), 0); + + if (totalTokens > tokenBudget) { + const KEEP_TAIL = 4; + + if (msgs.length > KEEP_TAIL + 1) { + const head = msgs.slice(0, 1); + const tail = msgs.slice(-KEEP_TAIL); + let middle = msgs.slice(1, -KEEP_TAIL); + + while ( + middle.length > 0 && + head.concat(middle, tail).reduce((sum, m) => sum + estimateTokens(String(m.content)), 0) > + tokenBudget + ) { + middle = middle.slice(1); + } + + msgs = [...head, ...middle, ...tail]; + } + } + } + + return msgs; + }); + }); + + /* 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 */ + /* + * Wait for the action runner to fully settle (all file writes / shell + * commands reach a terminal state) before firing the next prompt. + * This prevents WebContainer from being overwhelmed by rapid-fire writes. + * Falls back after maxWaitMs regardless so the queue never stalls forever. + */ + const waitForActionsToSettle = (maxWaitMs = 60_000): Promise => + new Promise((resolve) => { + const deadline = Date.now() + maxWaitMs; + + const check = () => { + const artifacts = workbenchStore.artifacts.get(); + const anyBusy = Object.values(artifacts).some((artifact) => + Object.values(artifact.runner.actions.get()).some( + (action) => action.status === 'running' || action.status === 'pending', + ), + ); + + if (!anyBusy || Date.now() >= deadline) { + // Extra breathing room after actions settle so WebContainer can flush I/O + setTimeout(resolve, 1500); + } else { + setTimeout(check, 500); + } + }; + + // Give the action runner a moment to start before we start polling + setTimeout(check, 1000); + }); + + waitForActionsToSettle().then(() => { + promptQueueStore.setKey('pendingPrompt', nextPrompt); + }); + } }, initialMessages, initialInput: Cookies.get(PROMPT_COOKIE_KEY) || '', @@ -200,6 +513,87 @@ 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); + } + }, []); + + /* + * Pre-send ZIP compaction. + * + * The boltArtifact id="imported-files" message can be hundreds of KB of + * raw file content. The onFinish handler compacts it AFTER the response, + * but on the VERY FIRST prompt it's still in the messages array when + * JSON.stringify is called to build the fetch body. That stringify blocks + * the main thread for several seconds, freezing the UI. + * + * setMessages() in @ai-sdk/react updates messagesRef.current synchronously + * (confirmed in the SDK source) before scheduling a re-render. Calling it + * immediately before append() means the hook reads the compacted array when + * it builds the request body — same content, a fraction of the size. + * + * Uses the functional form so it always reads the live ref (safe inside + * stale-closure callbacks like the queue subscription below). + */ + const compactZipIfPresent = useCallback(() => { + setMessages((prev) => { + const hasZip = prev.some( + (m) => typeof m.content === 'string' && m.content.includes('boltArtifact id="imported-files"'), + ); + + if (!hasZip) { + return prev; + } + + return prev.map((m) => { + if (typeof m.content !== 'string' || !m.content.includes('boltArtifact id="imported-files"')) { + return m; + } + + const paths = [...m.content.matchAll(/filePath="([^"]+)"/g)].map((match) => match[1]); + const count = paths.length; + const list = paths.map((p) => ` ${p}`).join('\n'); + + return { + ...m, + content: + `[Project imported to WebContainer — ${count} file${count === 1 ? '' : 's'}]\n\n` + + `Files available:\n${list}\n\n` + + `All files are in the WebContainer filesystem. ` + + `Use context selection to read relevant files as needed.`, + }; + }); + }); + }, [setMessages]); + + /* 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}`; + compactZipIfPresent(); + append({ role: 'user', content: messageText }); + } + }); + + return unsubscribe; + }, [append, compactZipIfPresent, model, provider]); + useEffect(() => { processSampledMessages({ messages, @@ -519,6 +913,7 @@ export const ChatImpl = memo( const attachmentOptions = uploadedFiles.length > 0 ? { experimental_attachments: await filesToAttachments(uploadedFiles) } : undefined; + compactZipIfPresent(); append( { role: 'user', @@ -535,6 +930,7 @@ export const ChatImpl = memo( const attachmentOptions = uploadedFiles.length > 0 ? { experimental_attachments: await filesToAttachments(uploadedFiles) } : undefined; + compactZipIfPresent(); append( { role: 'user', diff --git a/app/components/chat/ImportZipButton.tsx b/app/components/chat/ImportZipButton.tsx new file mode 100644 index 0000000000..038a53103a --- /dev/null +++ b/app/components/chat/ImportZipButton.tsx @@ -0,0 +1,128 @@ +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. + */ + /* + * Store file tree so Chat.client can replace the giant artifact message + * with a compact summary after bolt has written the files to WebContainer. + */ + localStorage.setItem( + 'bolt_zip_filetree', + JSON.stringify({ + folderName: file.name.replace(/\.zip$/i, ''), + fileCount: result.totalFiles - result.skippedBinary - result.skippedIgnored, + tree: result.fileTreeSummary, + }), + ); + + if (result.boltPrompt) { + /* + * Project includes a .bolt/prompt file — use its contents verbatim + * as the auto-fill first message, overriding all defaults. + */ + localStorage.setItem('bolt_zip_autorun', result.boltPrompt); + } else 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/LocalLLMPanel.tsx b/app/components/chat/LocalLLMPanel.tsx new file mode 100644 index 0000000000..c04c235a63 --- /dev/null +++ b/app/components/chat/LocalLLMPanel.tsx @@ -0,0 +1,226 @@ +import { useStore } from '@nanostores/react'; +import { useRef, useState } from 'react'; +import { localLLMSettingsStore, updateLocalLLMSettings, type TokenBudget } from '~/lib/stores/localLLMSettings'; +import { classNames } from '~/utils/classNames'; + +export function LocalLlmPanel() { + const [isOpen, setIsOpen] = useState(false); + const [panelStyle, setPanelStyle] = useState({}); + const barRef = useRef(null); + const settings = useStore(localLLMSettingsStore); + + const handleToggle = () => { + if (!isOpen && barRef.current) { + const rect = barRef.current.getBoundingClientRect(); + setPanelStyle({ + bottom: window.innerHeight - rect.top, + left: rect.left, + width: rect.width, + + // Clamp height so panel never overflows above the viewport + maxHeight: Math.min(rect.top - 8, window.innerHeight * 0.85), + }); + } + + setIsOpen((o) => !o); + }; + + const toggle = (key: keyof typeof settings) => { + updateLocalLLMSettings({ [key]: !settings[key] } as any); + }; + + const handleTokenBudget = (value: TokenBudget) => { + updateLocalLLMSettings({ tokenBudget: value }); + }; + + return ( +
+ {/* Toggle bar */} + + + {/* Expandable panel — floats above the bar, does not push chat layout */} + {isOpen && ( +
+ {/* Enable local models */} +
+ + + {settings.enableLocalModels && ( +
+
+ Ollama URL + updateLocalLLMSettings({ ollamaBaseUrl: e.target.value })} + onBlur={(e) => updateLocalLLMSettings({ ollamaBaseUrl: e.target.value })} + className={classNames( + 'flex-1 px-2 py-1 text-xs rounded-lg', + 'bg-bolt-elements-background-depth-3 border border-bolt-elements-borderColor', + 'text-bolt-elements-textPrimary', + 'focus:outline-none focus:ring-1 focus:ring-bolt-elements-focus', + )} + placeholder="http://localhost:11434" + /> +
+
+ LMStudio URL + updateLocalLLMSettings({ lmstudioBaseUrl: e.target.value })} + onBlur={(e) => updateLocalLLMSettings({ lmstudioBaseUrl: e.target.value })} + className={classNames( + 'flex-1 px-2 py-1 text-xs rounded-lg', + 'bg-bolt-elements-background-depth-3 border border-bolt-elements-borderColor', + 'text-bolt-elements-textPrimary', + 'focus:outline-none focus:ring-1 focus:ring-bolt-elements-focus', + )} + placeholder="http://localhost:1234" + /> +
+
+ )} +
+ +
+ + {/* Context optimizations — 2-column grid, descriptions as tooltips */} +
+ + Context Optimizations + + +
+ {( + [ + { + key: 'slimSystemPrompt', + label: 'Slim system prompt', + tip: 'Stripped-down system prompt for models under 13B. Removes WebContainer constraints and verbose copy.', + }, + { + key: 'dedupFileWrites', + label: 'Dedup file writes', + tip: 'Keeps only the most recent write per file in history — removes stale older versions from context.', + }, + { + key: 'stripOldProse', + label: 'Strip old prose', + tip: 'Removes explanation text from older assistant messages, keeping only boltArtifact blocks.', + }, + { + key: 'disableContextOptimization', + label: 'Skip context pre-pass', + tip: 'Skips the LLM file-selection pre-pass entirely. Use if the pre-pass is causing timeouts.', + }, + { + key: 'extendedStreamTimeout', + label: 'Extended timeout (3 min)', + tip: 'Uses 3-minute stream timeout instead of 45 s. Needed for local models with slow startup. Disable for cloud APIs.', + }, + { + key: 'blockHangingCommands', + label: 'Block install/server cmds', + tip: 'Blocks npm install, expo start, yarn, etc. — commands that hang WebContainer. Run them locally instead.', + }, + ] as { key: keyof typeof settings; label: string; tip: string }[] + ).map(({ key, label, tip }) => ( + + ))} +
+
+ +
+ + {/* Token budget */} +
+ + Token Budget + + — prune history when over limit + + + +
+ {(['off', '20k', '40k', 'custom'] as TokenBudget[]).map((val) => ( + + ))} + {settings.tokenBudget === 'custom' && ( + updateLocalLLMSettings({ tokenBudgetCustom: Number(e.target.value) })} + className={classNames( + 'w-24 px-2 py-0.5 text-xs rounded-lg', + 'bg-bolt-elements-background-depth-3 border border-bolt-elements-borderColor', + 'text-bolt-elements-textPrimary', + 'focus:outline-none focus:ring-1 focus:ring-bolt-elements-focus', + )} + /> + )} +
+
+
+ )} +
+ ); +} diff --git a/app/components/chat/Messages.client.tsx b/app/components/chat/Messages.client.tsx index 17600f0e10..a8fe16dac3 100644 --- a/app/components/chat/Messages.client.tsx +++ b/app/components/chat/Messages.client.tsx @@ -5,7 +5,7 @@ import { AssistantMessage } from './AssistantMessage'; import { UserMessage } from './UserMessage'; import { useLocation } from '@remix-run/react'; import { db, chatId } from '~/lib/persistence/useChatHistory'; -import { forkChat } from '~/lib/persistence/db'; +import { forkChat } from '~/lib/persistence/serverDb'; import { toast } from 'react-toastify'; import { forwardRef } from 'react'; import type { ForwardedRef } from 'react'; diff --git a/app/components/chat/PromptQueuePanel.tsx b/app/components/chat/PromptQueuePanel.tsx new file mode 100644 index 0000000000..7e4581863e --- /dev/null +++ b/app/components/chat/PromptQueuePanel.tsx @@ -0,0 +1,356 @@ +import { useStore } from '@nanostores/react'; +import { useRef, useState } from 'react'; +import { toast } from 'react-toastify'; +import { promptQueueStore, loadQueue, startQueue, stopQueue, resumeQueue, clearQueue } 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); + } + + // ####Prompt N#### delimiter style + if (/^####[^#\n]+####$/m.test(trimmed)) { + return trimmed + .split(/^####[^#\n]+####$/m) + .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 [panelStyle, setPanelStyle] = useState({}); + const barRef = useRef(null); + 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 isAllDone = !isRunning && prompts.length > 0 && currentIndex > 0 && currentIndex === prompts.length; + const isPaused = !isRunning && currentIndex > 0 && currentIndex < prompts.length; + const hasPrompts = prompts.length > 0; + + const progressLabel = isAllDone + ? `All ${prompts.length} prompts done ✓` + : isRunning + ? `Prompt ${currentIndex + 1} of ${prompts.length}` + : isPaused + ? `Paused at ${currentIndex + 1} of ${prompts.length}` + : hasPrompts + ? `${prompts.length} prompt${prompts.length === 1 ? '' : 's'} loaded` + : ''; + + const handleStop = () => { + stopQueue(); + toast.info('Queue paused — current response will finish'); + }; + + const handleClear = () => { + clearQueue(); + setDraft(''); + toast.info('Queue cleared'); + }; + + return ( +
+ {/* Toggle bar — always visible */} +
+ {/* Clickable label area */} + + + {/* Inline action buttons on the collapsed bar */} +
+ {isRunning && ( + + )} + {!isRunning && isPaused && ( + + )} + {!isRunning && hasPrompts && !isAllDone && !isPaused && ( + + )} +
+ + +
+ + {/* 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 */} +