From cd390fd4ee4afc8e53cff87d6c68ebca6b4f7bf7 Mon Sep 17 00:00:00 2001 From: embire2 Date: Thu, 5 Feb 2026 15:37:30 +0200 Subject: [PATCH] feat: issue 2097 momentum features --- .../@settings/tabs/features/FeaturesTab.tsx | 21 +++- app/components/chat/BaseChat.tsx | 101 +++++++++++++---- app/components/chat/Chat.client.tsx | 38 ++++++- app/components/chat/ChatBox.tsx | 64 +++++++++-- app/components/chat/FilePreview.tsx | 16 ++- app/components/chat/ProjectMemoryDialog.tsx | 103 ++++++++++++++++++ app/components/chat/uploadUtils.ts | 60 ++++++++++ app/components/ui/PanelHeaderButton.tsx | 4 +- app/components/workbench/EditorPanel.tsx | 63 +++++++++-- app/lib/.server/llm/stream-text.ts | 39 ++++++- app/lib/hooks/useSettings.ts | 12 ++ app/lib/persistence/projectSettings.ts | 45 ++++++++ app/lib/stores/settings.ts | 8 ++ app/routes/api.chat.ts | 54 ++++++--- docs/issue-2097-tracker.txt | 10 ++ 15 files changed, 572 insertions(+), 66 deletions(-) create mode 100644 app/components/chat/ProjectMemoryDialog.tsx create mode 100644 app/components/chat/uploadUtils.ts create mode 100644 app/lib/persistence/projectSettings.ts create mode 100644 docs/issue-2097-tracker.txt diff --git a/app/components/@settings/tabs/features/FeaturesTab.tsx b/app/components/@settings/tabs/features/FeaturesTab.tsx index 3b14a7565d..e772576486 100644 --- a/app/components/@settings/tabs/features/FeaturesTab.tsx +++ b/app/components/@settings/tabs/features/FeaturesTab.tsx @@ -111,12 +111,14 @@ export default function FeaturesTab() { isLatestBranch, contextOptimizationEnabled, eventLogs, + autoPromptOptimization, setAutoSelectTemplate, enableLatestBranch, enableContextOptimization, setEventLogs, setPromptId, promptId, + setAutoPromptOptimization, } = useSettings(); // Enable features by default on first load @@ -141,6 +143,10 @@ export default function FeaturesTab() { if (eventLogs === undefined) { setEventLogs(true); // Default: ON - Enable event logging } + + if (autoPromptOptimization === undefined) { + setAutoPromptOptimization(true); // Default: ON - Optimize prompts for smaller models + } }, []); // Only run once on component mount const handleToggleFeature = useCallback( @@ -169,12 +175,17 @@ export default function FeaturesTab() { toast.success(`Event logging ${enabled ? 'enabled' : 'disabled'}`); break; } + case 'autoPromptOptimization': { + setAutoPromptOptimization(enabled); + toast.success(`Auto prompt optimization ${enabled ? 'enabled' : 'disabled'}`); + break; + } default: break; } }, - [enableLatestBranch, setAutoSelectTemplate, enableContextOptimization, setEventLogs], + [enableLatestBranch, setAutoSelectTemplate, enableContextOptimization, setEventLogs, setAutoPromptOptimization], ); const features = { @@ -211,6 +222,14 @@ export default function FeaturesTab() { enabled: eventLogs, tooltip: 'Enabled by default to record detailed logs of system events and user actions', }, + { + id: 'autoPromptOptimization', + title: 'Auto Prompt Optimization', + description: 'Use optimized prompts for smaller models automatically', + icon: 'i-ph:magic-wand', + enabled: autoPromptOptimization, + tooltip: 'Enabled by default to keep prompts concise for smaller context windows', + }, ], beta: [], }; diff --git a/app/components/chat/BaseChat.tsx b/app/components/chat/BaseChat.tsx index 7daffb6dd9..1f38dd32a1 100644 --- a/app/components/chat/BaseChat.tsx +++ b/app/components/chat/BaseChat.tsx @@ -28,11 +28,19 @@ import type { ProgressAnnotation } from '~/types/context'; import { SupabaseChatAlert } from '~/components/chat/SupabaseAlert'; import { expoUrlAtom } from '~/lib/stores/qrCodeStore'; import { useStore } from '@nanostores/react'; +import { toast } from 'react-toastify'; import { StickToBottom, useStickToBottomContext } from '~/lib/hooks'; import { ChatBox } from './ChatBox'; import type { DesignScheme } from '~/types/design-scheme'; import type { ElementInfo } from '~/components/workbench/Inspector'; import LlmErrorAlert from './LLMApiAlert'; +import { + ACCEPTED_ATTACHMENT_TYPES, + MAX_ATTACHMENT_BYTES, + isImageFile, + isSupportedAttachment, + readFileAsDataUrl, +} from './uploadUtils'; const TEXTAREA_MIN_HEIGHT = 76; @@ -49,6 +57,10 @@ interface BaseChatProps { enhancingPrompt?: boolean; promptEnhanced?: boolean; input?: string; + projectMemory?: string; + setProjectMemory?: (value: string) => void; + planMode?: boolean; + setPlanMode?: (enabled: boolean) => void; model?: string; setModel?: (model: string) => void; provider?: ProviderInfo; @@ -97,6 +109,10 @@ export const BaseChat = React.forwardRef( setProvider, providerList, input = '', + projectMemory, + setProjectMemory, + planMode, + setPlanMode, enhancingPrompt, handleInputChange, @@ -287,24 +303,51 @@ export const BaseChat = React.forwardRef( } }; + const appendUploads = (items: Array<{ file: File; imageData?: string }>) => { + if (!setUploadedFiles || !setImageDataList || items.length === 0) { + return; + } + + setUploadedFiles([...uploadedFiles, ...items.map((item) => item.file)]); + setImageDataList([...imageDataList, ...items.map((item) => item.imageData || '')]); + }; + const handleFileUpload = () => { const input = document.createElement('input'); input.type = 'file'; - input.accept = 'image/*'; + input.accept = ACCEPTED_ATTACHMENT_TYPES; + input.multiple = true; input.onchange = async (e) => { - const file = (e.target as HTMLInputElement).files?.[0]; + const files = Array.from((e.target as HTMLInputElement).files || []); - if (file) { - const reader = new FileReader(); + if (!files.length) { + return; + } + + const uploads: Array<{ file: File; imageData?: string }> = []; + + for (const file of files) { + if (file.size > MAX_ATTACHMENT_BYTES) { + toast.error(`"${file.name}" exceeds the ${Math.round(MAX_ATTACHMENT_BYTES / 1024 / 1024)}MB limit`); + continue; + } + + if (!isSupportedAttachment(file)) { + toast.error(`"${file.name}" is not a supported attachment type`); + continue; + } - reader.onload = (e) => { - const base64Image = e.target?.result as string; - setUploadedFiles?.([...uploadedFiles, file]); - setImageDataList?.([...imageDataList, base64Image]); - }; - reader.readAsDataURL(file); + try { + const dataUrl = await readFileAsDataUrl(file); + uploads.push({ file, imageData: isImageFile(file) ? dataUrl : '' }); + } catch (error) { + console.error('Failed to read file', error); + toast.error(`Failed to read "${file.name}"`); + } } + + appendUploads(uploads); }; input.click(); @@ -318,24 +361,32 @@ export const BaseChat = React.forwardRef( } for (const item of items) { - if (item.type.startsWith('image/')) { - e.preventDefault(); + if (!item.type.startsWith('image/')) { + continue; + } - const file = item.getAsFile(); + e.preventDefault(); - if (file) { - const reader = new FileReader(); + const file = item.getAsFile(); - reader.onload = (e) => { - const base64Image = e.target?.result as string; - setUploadedFiles?.([...uploadedFiles, file]); - setImageDataList?.([...imageDataList, base64Image]); - }; - reader.readAsDataURL(file); - } + if (!file) { + continue; + } - break; + if (file.size > MAX_ATTACHMENT_BYTES) { + toast.error(`"${file.name}" exceeds the ${Math.round(MAX_ATTACHMENT_BYTES / 1024 / 1024)}MB limit`); + continue; } + + try { + const base64Image = await readFileAsDataUrl(file); + appendUploads([{ file, imageData: base64Image }]); + } catch (error) { + console.error('Failed to read pasted image', error); + toast.error(`Failed to read "${file.name}"`); + } + + break; } }; @@ -442,6 +493,10 @@ export const BaseChat = React.forwardRef( setImageDataList={setImageDataList} textareaRef={textareaRef} input={input} + projectMemory={projectMemory} + setProjectMemory={setProjectMemory} + planMode={planMode} + setPlanMode={setPlanMode} handleInputChange={handleInputChange} handlePaste={handlePaste} TEXTAREA_MIN_HEIGHT={TEXTAREA_MIN_HEIGHT} diff --git a/app/components/chat/Chat.client.tsx b/app/components/chat/Chat.client.tsx index c4706e1764..d02710322e 100644 --- a/app/components/chat/Chat.client.tsx +++ b/app/components/chat/Chat.client.tsx @@ -6,6 +6,8 @@ import { memo, useCallback, useEffect, useRef, useState } from 'react'; import { toast } from 'react-toastify'; import { useMessageParser, usePromptEnhancer, useShortcuts } from '~/lib/hooks'; import { description, useChatHistory } from '~/lib/persistence'; +import { chatId } from '~/lib/persistence/useChatHistory'; +import { getProjectSettings, setProjectSettings } from '~/lib/persistence/projectSettings'; import { chatStore } from '~/lib/stores/chat'; import { workbenchStore } from '~/lib/stores/workbench'; import { DEFAULT_MODEL, DEFAULT_PROVIDER, PROMPT_COOKIE_KEY, PROVIDER_LIST } from '~/utils/constants'; @@ -100,7 +102,11 @@ export const ChatImpl = memo( (project) => project.id === supabaseConn.selectedProjectId, ); const supabaseAlert = useStore(workbenchStore.supabaseAlert); - const { activeProviders, promptId, autoSelectTemplate, contextOptimizationEnabled } = useSettings(); + const { activeProviders, promptId, autoSelectTemplate, contextOptimizationEnabled, autoPromptOptimization } = + useSettings(); + const currentChatId = useStore(chatId); + const [projectMemory, setProjectMemory] = useState(''); + const [planMode, setPlanMode] = useState(false); const [llmErrorAlert, setLlmErrorAlert] = useState(undefined); const [model, setModel] = useState(() => { const savedModel = Cookies.get('selectedModel'); @@ -117,6 +123,27 @@ export const ChatImpl = memo( const [selectedElement, setSelectedElement] = useState(null); const mcpSettings = useMCPStore((state) => state.settings); + useEffect(() => { + if (!currentChatId) { + setProjectMemory(''); + setPlanMode(false); + + return; + } + + const settings = getProjectSettings(currentChatId); + setProjectMemory(settings.memory); + setPlanMode(settings.planMode); + }, [currentChatId]); + + useEffect(() => { + if (!currentChatId) { + return; + } + + setProjectSettings(currentChatId, { memory: projectMemory, planMode }); + }, [currentChatId, projectMemory, planMode]); + const { messages, isLoading, @@ -140,6 +167,9 @@ export const ChatImpl = memo( contextOptimization: contextOptimizationEnabled, chatMode, designScheme, + projectMemory, + planMode, + autoPromptOptimization, supabase: { isConnected: supabaseConn.isConnected, hasSelectedProject: !!selectedProject, @@ -344,7 +374,7 @@ export const ChatImpl = memo( ]; // Add image parts if any - images.forEach((imageData) => { + images.filter(Boolean).forEach((imageData) => { // Extract correct MIME type from the data URL const mimeType = imageData.split(';')[0].split(':')[1] || 'image/jpeg'; @@ -643,6 +673,10 @@ export const ChatImpl = memo( apiKeys, ); }} + projectMemory={projectMemory} + setProjectMemory={setProjectMemory} + planMode={planMode} + setPlanMode={setPlanMode} uploadedFiles={uploadedFiles} setUploadedFiles={setUploadedFiles} imageDataList={imageDataList} diff --git a/app/components/chat/ChatBox.tsx b/app/components/chat/ChatBox.tsx index 4cd9a149a2..ec28a83a86 100644 --- a/app/components/chat/ChatBox.tsx +++ b/app/components/chat/ChatBox.tsx @@ -19,6 +19,8 @@ import { ColorSchemeDialog } from '~/components/ui/ColorSchemeDialog'; import type { DesignScheme } from '~/types/design-scheme'; import type { ElementInfo } from '~/components/workbench/Inspector'; import { McpTools } from './MCPTools'; +import { ProjectMemoryDialog } from './ProjectMemoryDialog'; +import { MAX_ATTACHMENT_BYTES, isSupportedAttachment, isImageFile, readFileAsDataUrl } from './uploadUtils'; interface ChatBoxProps { isModelSettingsCollapsed: boolean; @@ -55,6 +57,10 @@ interface ChatBoxProps { handleStop?: (() => void) | undefined; enhancingPrompt?: boolean | undefined; enhancePrompt?: (() => void) | undefined; + projectMemory?: string; + setProjectMemory?: ((value: string) => void) | undefined; + planMode?: boolean; + setPlanMode?: ((enabled: boolean) => void) | undefined; chatMode?: 'discuss' | 'build'; setChatMode?: (mode: 'discuss' | 'build') => void; designScheme?: DesignScheme; @@ -193,18 +199,40 @@ export const ChatBox: React.FC = (props) => { e.currentTarget.style.border = '1px solid var(--bolt-elements-borderColor)'; const files = Array.from(e.dataTransfer.files); + const uploads: Array<{ file: File; imageData?: string }> = []; + files.forEach((file) => { - if (file.type.startsWith('image/')) { - const reader = new FileReader(); + if (file.size > MAX_ATTACHMENT_BYTES) { + toast.error(`\"${file.name}\" exceeds the ${Math.round(MAX_ATTACHMENT_BYTES / 1024 / 1024)}MB limit`); + return; + } - reader.onload = (e) => { - const base64Image = e.target?.result as string; - props.setUploadedFiles?.([...props.uploadedFiles, file]); - props.setImageDataList?.([...props.imageDataList, base64Image]); - }; - reader.readAsDataURL(file); + if (!isSupportedAttachment(file)) { + toast.error(`\"${file.name}\" is not a supported attachment type`); + return; } + + uploads.push({ file }); }); + + if (!uploads.length) { + return; + } + + Promise.all( + uploads.map(async (item) => ({ + ...item, + imageData: isImageFile(item.file) ? await readFileAsDataUrl(item.file) : '', + })), + ) + .then((resolved) => { + props.setUploadedFiles?.([...props.uploadedFiles, ...resolved.map((item) => item.file)]); + props.setImageDataList?.([...props.imageDataList, ...resolved.map((item) => item.imageData || '')]); + }) + .catch((error) => { + console.error('Failed to read dropped files', error); + toast.error('Failed to read one or more dropped files'); + }); }} onKeyDown={(event) => { if (event.key === 'Enter') { @@ -262,6 +290,26 @@ export const ChatBox: React.FC = (props) => {
+ { + props.setProjectMemory?.(value); + toast.success(value ? 'Project memory saved' : 'Project memory cleared'); + }} + /> + props.setPlanMode?.(!props.planMode)} + > +
+ {props.planMode ? Plan : } + props.handleFileUpload()}>
diff --git a/app/components/chat/FilePreview.tsx b/app/components/chat/FilePreview.tsx index e1400cf823..efaa3e066f 100644 --- a/app/components/chat/FilePreview.tsx +++ b/app/components/chat/FilePreview.tsx @@ -15,7 +15,7 @@ const FilePreview: React.FC = ({ files, imageDataList, onRemov
{files.map((file, index) => (
- {imageDataList[index] && ( + {imageDataList[index] ? (
{file.name}
+ ) : ( +
+
+
+ {file.name} +
+
{Math.round(file.size / 1024)} KB
+ +
)}
))} diff --git a/app/components/chat/ProjectMemoryDialog.tsx b/app/components/chat/ProjectMemoryDialog.tsx new file mode 100644 index 0000000000..17fa0d6723 --- /dev/null +++ b/app/components/chat/ProjectMemoryDialog.tsx @@ -0,0 +1,103 @@ +import { useEffect, useMemo, useState } from 'react'; +import { IconButton } from '~/components/ui/IconButton'; +import { Dialog, DialogDescription, DialogRoot, DialogTitle } from '~/components/ui/Dialog'; +import { classNames } from '~/utils/classNames'; + +const MAX_MEMORY_CHARS = 2000; + +interface ProjectMemoryDialogProps { + memory?: string; + onSave: (value: string) => void; +} + +export function ProjectMemoryDialog({ memory = '', onSave }: ProjectMemoryDialogProps) { + const [isOpen, setIsOpen] = useState(false); + const [draft, setDraft] = useState(memory); + + useEffect(() => { + if (isOpen) { + setDraft(memory); + } + }, [isOpen, memory]); + + const trimmedDraft = useMemo(() => draft.trim(), [draft]); + const remainingChars = MAX_MEMORY_CHARS - draft.length; + + const handleSave = () => { + onSave(trimmedDraft.slice(0, MAX_MEMORY_CHARS)); + setIsOpen(false); + }; + + const handleClear = () => { + setDraft(''); + onSave(''); + setIsOpen(false); + }; + + return ( + <> + setIsOpen(true)} + > +
+ {memory ? Memory : } + + + +
+
+ Project Memory + + Notes saved here are injected into every prompt for this chat. Use this for tech stack decisions, + conventions, and constraints you want the model to remember. + +
+
+