Merge pull request #1748 from xKevIsDev/enhancements

feat: add inspector, design palette and redesign
This commit is contained in:
KevIsDev
2025-06-03 11:39:32 +01:00
committed by GitHub
29 changed files with 1506 additions and 295 deletions

View File

@@ -31,6 +31,8 @@ import { expoUrlAtom } from '~/lib/stores/qrCodeStore';
import { useStore } from '@nanostores/react'; import { useStore } from '@nanostores/react';
import { StickToBottom, useStickToBottomContext } from '~/lib/hooks'; import { StickToBottom, useStickToBottomContext } from '~/lib/hooks';
import { ChatBox } from './ChatBox'; import { ChatBox } from './ChatBox';
import type { DesignScheme } from '~/types/design-scheme';
import type { ElementInfo } from '~/components/workbench/Inspector';
const TEXTAREA_MIN_HEIGHT = 76; const TEXTAREA_MIN_HEIGHT = 76;
@@ -73,6 +75,10 @@ interface BaseChatProps {
chatMode?: 'discuss' | 'build'; chatMode?: 'discuss' | 'build';
setChatMode?: (mode: 'discuss' | 'build') => void; setChatMode?: (mode: 'discuss' | 'build') => void;
append?: (message: Message) => void; append?: (message: Message) => void;
designScheme?: DesignScheme;
setDesignScheme?: (scheme: DesignScheme) => void;
selectedElement?: ElementInfo | null;
setSelectedElement?: (element: ElementInfo | null) => void;
} }
export const BaseChat = React.forwardRef<HTMLDivElement, BaseChatProps>( export const BaseChat = React.forwardRef<HTMLDivElement, BaseChatProps>(
@@ -114,6 +120,10 @@ export const BaseChat = React.forwardRef<HTMLDivElement, BaseChatProps>(
chatMode, chatMode,
setChatMode, setChatMode,
append, append,
designScheme,
setDesignScheme,
selectedElement,
setSelectedElement,
}, },
ref, ref,
) => { ) => {
@@ -253,6 +263,7 @@ export const BaseChat = React.forwardRef<HTMLDivElement, BaseChatProps>(
const handleSendMessage = (event: React.UIEvent, messageInput?: string) => { const handleSendMessage = (event: React.UIEvent, messageInput?: string) => {
if (sendMessage) { if (sendMessage) {
sendMessage(event, messageInput); sendMessage(event, messageInput);
setSelectedElement?.(null);
if (recognition) { if (recognition) {
recognition.abort(); // Stop current recognition recognition.abort(); // Stop current recognition
@@ -332,7 +343,7 @@ export const BaseChat = React.forwardRef<HTMLDivElement, BaseChatProps>(
<div className="flex flex-col lg:flex-row overflow-y-auto w-full h-full"> <div className="flex flex-col lg:flex-row overflow-y-auto w-full h-full">
<div className={classNames(styles.Chat, 'flex flex-col flex-grow lg:min-w-[var(--chat-min-width)] h-full')}> <div className={classNames(styles.Chat, 'flex flex-col flex-grow lg:min-w-[var(--chat-min-width)] h-full')}>
{!chatStarted && ( {!chatStarted && (
<div id="intro" className="mt-[16vh] max-w-chat mx-auto text-center px-4 lg:px-0"> <div id="intro" className="mt-[16vh] max-w-2xl mx-auto text-center px-4 lg:px-0">
<h1 className="text-3xl lg:text-6xl font-bold text-bolt-elements-textPrimary mb-4 animate-fade-in"> <h1 className="text-3xl lg:text-6xl font-bold text-bolt-elements-textPrimary mb-4 animate-fade-in">
Where ideas begin Where ideas begin
</h1> </h1>
@@ -348,12 +359,12 @@ export const BaseChat = React.forwardRef<HTMLDivElement, BaseChatProps>(
resize="smooth" resize="smooth"
initial="smooth" initial="smooth"
> >
<StickToBottom.Content className="flex flex-col gap-4"> <StickToBottom.Content className="flex flex-col gap-4 relative ">
<ClientOnly> <ClientOnly>
{() => { {() => {
return chatStarted ? ( return chatStarted ? (
<Messages <Messages
className="flex flex-col w-full flex-1 max-w-chat pb-6 mx-auto z-1" className="flex flex-col w-full flex-1 max-w-chat pb-4 mx-auto z-1"
messages={messages} messages={messages}
isStreaming={isStreaming} isStreaming={isStreaming}
append={append} append={append}
@@ -365,6 +376,7 @@ export const BaseChat = React.forwardRef<HTMLDivElement, BaseChatProps>(
) : null; ) : null;
}} }}
</ClientOnly> </ClientOnly>
<ScrollToBottom />
</StickToBottom.Content> </StickToBottom.Content>
<div <div
className={classNames('my-auto flex flex-col gap-2 w-full max-w-chat mx-auto z-prompt mb-6', { className={classNames('my-auto flex flex-col gap-2 w-full max-w-chat mx-auto z-prompt mb-6', {
@@ -403,7 +415,6 @@ export const BaseChat = React.forwardRef<HTMLDivElement, BaseChatProps>(
/> />
)} )}
</div> </div>
<ScrollToBottom />
{progressAnnotations && <ProgressCompilation data={progressAnnotations} />} {progressAnnotations && <ProgressCompilation data={progressAnnotations} />}
<ChatBox <ChatBox
isModelSettingsCollapsed={isModelSettingsCollapsed} isModelSettingsCollapsed={isModelSettingsCollapsed}
@@ -442,6 +453,10 @@ export const BaseChat = React.forwardRef<HTMLDivElement, BaseChatProps>(
handleFileUpload={handleFileUpload} handleFileUpload={handleFileUpload}
chatMode={chatMode} chatMode={chatMode}
setChatMode={setChatMode} setChatMode={setChatMode}
designScheme={designScheme}
setDesignScheme={setDesignScheme}
selectedElement={selectedElement}
setSelectedElement={setSelectedElement}
/> />
</div> </div>
</StickToBottom> </StickToBottom>
@@ -472,6 +487,7 @@ export const BaseChat = React.forwardRef<HTMLDivElement, BaseChatProps>(
actionRunner={actionRunner ?? ({} as ActionRunner)} actionRunner={actionRunner ?? ({} as ActionRunner)}
chatStarted={chatStarted} chatStarted={chatStarted}
isStreaming={isStreaming} isStreaming={isStreaming}
setSelectedElement={setSelectedElement}
/> />
)} )}
</ClientOnly> </ClientOnly>
@@ -488,13 +504,16 @@ function ScrollToBottom() {
return ( return (
!isAtBottom && ( !isAtBottom && (
<>
<div className="sticky bottom-0 left-0 right-0 bg-gradient-to-t from-bolt-elements-background-depth-1 to-transparent h-20 z-10" />
<button <button
className="absolute z-50 top-[0%] translate-y-[-100%] text-4xl rounded-lg left-[50%] translate-x-[-50%] px-1.5 py-0.5 flex items-center gap-2 bg-bolt-elements-background-depth-3 border border-bolt-elements-borderColor text-bolt-elements-textPrimary text-sm" className="sticky z-50 bottom-0 left-0 right-0 text-4xl rounded-lg px-1.5 py-0.5 flex items-center justify-center mx-auto gap-2 bg-bolt-elements-background-depth-2 border border-bolt-elements-borderColor text-bolt-elements-textPrimary text-sm"
onClick={() => scrollToBottom()} onClick={() => scrollToBottom()}
> >
Go to last message Go to last message
<span className="i-ph:arrow-down animate-bounce" /> <span className="i-ph:arrow-down animate-bounce" />
</button> </button>
</>
) )
); );
} }

View File

@@ -27,6 +27,8 @@ import { logStore } from '~/lib/stores/logs';
import { streamingState } from '~/lib/stores/streaming'; import { streamingState } from '~/lib/stores/streaming';
import { filesToArtifacts } from '~/utils/fileUtils'; import { filesToArtifacts } from '~/utils/fileUtils';
import { supabaseConnection } from '~/lib/stores/supabase'; import { supabaseConnection } from '~/lib/stores/supabase';
import { defaultDesignScheme, type DesignScheme } from '~/types/design-scheme';
import type { ElementInfo } from '~/components/workbench/Inspector';
const toastAnimation = cssTransition({ const toastAnimation = cssTransition({
enter: 'animated fadeInRight', enter: 'animated fadeInRight',
@@ -124,6 +126,7 @@ export const ChatImpl = memo(
const [searchParams, setSearchParams] = useSearchParams(); const [searchParams, setSearchParams] = useSearchParams();
const [fakeLoading, setFakeLoading] = useState(false); const [fakeLoading, setFakeLoading] = useState(false);
const files = useStore(workbenchStore.files); const files = useStore(workbenchStore.files);
const [designScheme, setDesignScheme] = useState<DesignScheme>(defaultDesignScheme);
const actionAlert = useStore(workbenchStore.alert); const actionAlert = useStore(workbenchStore.alert);
const deployAlert = useStore(workbenchStore.deployAlert); const deployAlert = useStore(workbenchStore.deployAlert);
const supabaseConn = useStore(supabaseConnection); // Add this line to get Supabase connection const supabaseConn = useStore(supabaseConnection); // Add this line to get Supabase connection
@@ -132,7 +135,6 @@ export const ChatImpl = memo(
); );
const supabaseAlert = useStore(workbenchStore.supabaseAlert); const supabaseAlert = useStore(workbenchStore.supabaseAlert);
const { activeProviders, promptId, autoSelectTemplate, contextOptimizationEnabled } = useSettings(); const { activeProviders, promptId, autoSelectTemplate, contextOptimizationEnabled } = useSettings();
const [model, setModel] = useState(() => { const [model, setModel] = useState(() => {
const savedModel = Cookies.get('selectedModel'); const savedModel = Cookies.get('selectedModel');
return savedModel || DEFAULT_MODEL; return savedModel || DEFAULT_MODEL;
@@ -141,14 +143,11 @@ export const ChatImpl = memo(
const savedProvider = Cookies.get('selectedProvider'); const savedProvider = Cookies.get('selectedProvider');
return (PROVIDER_LIST.find((p) => p.name === savedProvider) || DEFAULT_PROVIDER) as ProviderInfo; return (PROVIDER_LIST.find((p) => p.name === savedProvider) || DEFAULT_PROVIDER) as ProviderInfo;
}); });
const { showChat } = useStore(chatStore); const { showChat } = useStore(chatStore);
const [animationScope, animate] = useAnimate(); const [animationScope, animate] = useAnimate();
const [apiKeys, setApiKeys] = useState<Record<string, string>>({}); const [apiKeys, setApiKeys] = useState<Record<string, string>>({});
const [chatMode, setChatMode] = useState<'discuss' | 'build'>('build'); const [chatMode, setChatMode] = useState<'discuss' | 'build'>('build');
const [selectedElement, setSelectedElement] = useState<ElementInfo | null>(null);
const { const {
messages, messages,
isLoading, isLoading,
@@ -170,6 +169,7 @@ export const ChatImpl = memo(
promptId, promptId,
contextOptimization: contextOptimizationEnabled, contextOptimization: contextOptimizationEnabled,
chatMode, chatMode,
designScheme,
supabase: { supabase: {
isConnected: supabaseConn.isConnected, isConnected: supabaseConn.isConnected,
hasSelectedProject: !!selectedProject, hasSelectedProject: !!selectedProject,
@@ -312,8 +312,14 @@ export const ChatImpl = memo(
return; return;
} }
// If no locked items, proceed normally with the original message let finalMessageContent = messageContent;
const finalMessageContent = messageContent;
if (selectedElement) {
console.log('Selected Element:', selectedElement);
const elementInfo = `<div class=\"__boltSelectedElement__\" data-element='${JSON.stringify(selectedElement)}'>${JSON.stringify(`${selectedElement.displayText}`)}</div>`;
finalMessageContent = messageContent + elementInfo;
}
runAnimation(); runAnimation();
@@ -569,6 +575,10 @@ export const ChatImpl = memo(
chatMode={chatMode} chatMode={chatMode}
setChatMode={setChatMode} setChatMode={setChatMode}
append={append} append={append}
designScheme={designScheme}
setDesignScheme={setDesignScheme}
selectedElement={selectedElement}
setSelectedElement={setSelectedElement}
/> />
); );
}, },

View File

@@ -11,11 +11,13 @@ import { SendButton } from './SendButton.client';
import { IconButton } from '~/components/ui/IconButton'; import { IconButton } from '~/components/ui/IconButton';
import { toast } from 'react-toastify'; import { toast } from 'react-toastify';
import { SpeechRecognitionButton } from '~/components/chat/SpeechRecognition'; import { SpeechRecognitionButton } from '~/components/chat/SpeechRecognition';
import { ExportChatButton } from '~/components/chat/chatExportAndImport/ExportChatButton';
import { SupabaseConnection } from './SupabaseConnection'; import { SupabaseConnection } from './SupabaseConnection';
import { ExpoQrModal } from '~/components/workbench/ExpoQrModal'; import { ExpoQrModal } from '~/components/workbench/ExpoQrModal';
import styles from './BaseChat.module.scss'; import styles from './BaseChat.module.scss';
import type { ProviderInfo } from '~/types/model'; import type { ProviderInfo } from '~/types/model';
import { ColorSchemeDialog } from '~/components/ui/ColorSchemeDialog';
import type { DesignScheme } from '~/types/design-scheme';
import type { ElementInfo } from '~/components/workbench/Inspector';
interface ChatBoxProps { interface ChatBoxProps {
isModelSettingsCollapsed: boolean; isModelSettingsCollapsed: boolean;
@@ -54,13 +56,17 @@ interface ChatBoxProps {
enhancePrompt?: (() => void) | undefined; enhancePrompt?: (() => void) | undefined;
chatMode?: 'discuss' | 'build'; chatMode?: 'discuss' | 'build';
setChatMode?: (mode: 'discuss' | 'build') => void; setChatMode?: (mode: 'discuss' | 'build') => void;
designScheme?: DesignScheme;
setDesignScheme?: (scheme: DesignScheme) => void;
selectedElement?: ElementInfo | null;
setSelectedElement?: ((element: ElementInfo | null) => void) | undefined;
} }
export const ChatBox: React.FC<ChatBoxProps> = (props) => { export const ChatBox: React.FC<ChatBoxProps> = (props) => {
return ( return (
<div <div
className={classNames( className={classNames(
'relative bg-bolt-elements-background-depth-2 p-3 rounded-lg border border-bolt-elements-borderColor relative w-full max-w-chat mx-auto z-prompt', 'relative bg-bolt-elements-background-depth-2 backdrop-blur p-3 rounded-lg border border-bolt-elements-borderColor relative w-full max-w-chat mx-auto z-prompt',
/* /*
* { * {
@@ -143,6 +149,22 @@ export const ChatBox: React.FC<ChatBoxProps> = (props) => {
/> />
)} )}
</ClientOnly> </ClientOnly>
{props.selectedElement && (
<div className="flex mx-1.5 gap-2 items-center justify-between rounded-lg rounded-b-none border border-b-none border-bolt-elements-borderColor text-bolt-elements-textPrimary flex py-1 px-2.5 font-medium text-xs">
<div className="flex gap-2 items-center lowercase">
<code className="bg-accent-500 rounded-4px px-1.5 py-1 mr-0.5 text-white">
{props?.selectedElement?.tagName}
</code>
selected for inspection
</div>
<button
className="bg-transparent text-accent-500 pointer-auto"
onClick={() => props.setSelectedElement?.(null)}
>
Clear
</button>
</div>
)}
<div <div
className={classNames('relative shadow-xs border border-bolt-elements-borderColor backdrop-blur rounded-lg')} className={classNames('relative shadow-xs border border-bolt-elements-borderColor backdrop-blur rounded-lg')}
> >
@@ -237,6 +259,7 @@ export const ChatBox: React.FC<ChatBoxProps> = (props) => {
</ClientOnly> </ClientOnly>
<div className="flex justify-between items-center text-sm p-4 pt-2"> <div className="flex justify-between items-center text-sm p-4 pt-2">
<div className="flex gap-1 items-center"> <div className="flex gap-1 items-center">
<ColorSchemeDialog designScheme={props.designScheme} setDesignScheme={props.setDesignScheme} />
<IconButton title="Upload file" className="transition-all" onClick={() => props.handleFileUpload()}> <IconButton title="Upload file" className="transition-all" onClick={() => props.handleFileUpload()}>
<div className="i-ph:paperclip text-xl"></div> <div className="i-ph:paperclip text-xl"></div>
</IconButton> </IconButton>
@@ -279,7 +302,6 @@ export const ChatBox: React.FC<ChatBoxProps> = (props) => {
{props.chatMode === 'discuss' ? <span>Discuss</span> : <span />} {props.chatMode === 'discuss' ? <span>Discuss</span> : <span />}
</IconButton> </IconButton>
)} )}
{props.chatStarted && <ClientOnly>{() => <ExportChatButton exportChat={props.exportChat} />}</ClientOnly>}
<IconButton <IconButton
title="Model Settings" title="Model Settings"
className={classNames('transition-all flex items-center gap-1', { className={classNames('transition-all flex items-center gap-1', {

View File

@@ -42,6 +42,42 @@ export const Markdown = memo(
return <Artifact messageId={messageId} />; return <Artifact messageId={messageId} />;
} }
if (className?.includes('__boltSelectedElement__')) {
const messageId = node?.properties.dataMessageId as string;
const elementDataAttr = node?.properties.dataElement as string;
// Parse the element data if it exists
let elementData: any = null;
if (elementDataAttr) {
try {
elementData = JSON.parse(elementDataAttr);
} catch (e) {
console.error('Failed to parse element data:', e);
}
}
if (!messageId) {
logger.error(`Invalid message id ${messageId}`);
}
return (
<div className="bg-bolt-elements-background-depth-3 border border-bolt-elements-borderColor rounded-lg p-3 my-2">
<div className="flex items-center gap-2 mb-2">
<span className="text-xs font-mono bg-bolt-elements-background-depth-2 px-2 py-1 rounded text-bolt-elements-textTer">
{elementData?.tagName}
</span>
{elementData?.className && (
<span className="text-xs text-bolt-elements-textSecondary">.{elementData.className}</span>
)}
</div>
<code className="block text-sm !text-bolt-elements-textSecondary !bg-bolt-elements-background-depth-2 border border-bolt-elements-borderColor p-2 rounded">
{elementData?.displayText}
</code>
</div>
);
}
if (className?.includes('__boltThought__')) { if (className?.includes('__boltThought__')) {
return <ThoughtBox title="Thought process">{children}</ThoughtBox>; return <ThoughtBox title="Thought process">{children}</ThoughtBox>;
} }

View File

@@ -7,8 +7,6 @@ import { useLocation } from '@remix-run/react';
import { db, chatId } from '~/lib/persistence/useChatHistory'; import { db, chatId } from '~/lib/persistence/useChatHistory';
import { forkChat } from '~/lib/persistence/db'; import { forkChat } from '~/lib/persistence/db';
import { toast } from 'react-toastify'; import { toast } from 'react-toastify';
import { useStore } from '@nanostores/react';
import { profileStore } from '~/lib/stores/profile';
import { forwardRef } from 'react'; import { forwardRef } from 'react';
import type { ForwardedRef } from 'react'; import type { ForwardedRef } from 'react';
import type { ProviderInfo } from '~/types/model'; import type { ProviderInfo } from '~/types/model';
@@ -29,7 +27,6 @@ export const Messages = forwardRef<HTMLDivElement, MessagesProps>(
(props: MessagesProps, ref: ForwardedRef<HTMLDivElement> | undefined) => { (props: MessagesProps, ref: ForwardedRef<HTMLDivElement> | undefined) => {
const { id, isStreaming = false, messages = [] } = props; const { id, isStreaming = false, messages = [] } = props;
const location = useLocation(); const location = useLocation();
const profile = useStore(profileStore);
const handleRewind = (messageId: string) => { const handleRewind = (messageId: string) => {
const searchParams = new URLSearchParams(location.search); const searchParams = new URLSearchParams(location.search);
@@ -58,7 +55,6 @@ export const Messages = forwardRef<HTMLDivElement, MessagesProps>(
const { role, content, id: messageId, annotations } = message; const { role, content, id: messageId, annotations } = message;
const isUserMessage = role === 'user'; const isUserMessage = role === 'user';
const isFirst = index === 0; const isFirst = index === 0;
const isLast = index === messages.length - 1;
const isHidden = annotations?.includes('hidden'); const isHidden = annotations?.includes('hidden');
if (isHidden) { if (isHidden) {
@@ -68,28 +64,10 @@ export const Messages = forwardRef<HTMLDivElement, MessagesProps>(
return ( return (
<div <div
key={index} key={index}
className={classNames('flex gap-4 p-6 py-5 w-full rounded-[calc(0.75rem-1px)]', { className={classNames('flex gap-4 py-3 w-full rounded-lg', {
'bg-bolt-elements-messages-background': isUserMessage || !isStreaming || (isStreaming && !isLast),
'bg-gradient-to-b from-bolt-elements-messages-background from-30% to-transparent':
isStreaming && isLast,
'mt-4': !isFirst, 'mt-4': !isFirst,
})} })}
> >
{isUserMessage && (
<div className="flex items-center justify-center w-[40px] h-[40px] overflow-hidden bg-white dark:bg-gray-800 text-gray-600 dark:text-gray-500 rounded-full shrink-0 self-start">
{profile?.avatar ? (
<img
src={profile.avatar}
alt={profile?.username || 'User'}
className="w-full h-full object-cover"
loading="eager"
decoding="sync"
/>
) : (
<div className="i-ph:user-fill text-2xl" />
)}
</div>
)}
<div className="grid grid-col-1 w-full"> <div className="grid grid-col-1 w-full">
{isUserMessage ? ( {isUserMessage ? (
<UserMessage content={content} /> <UserMessage content={content} />

View File

@@ -4,6 +4,8 @@
*/ */
import { MODEL_REGEX, PROVIDER_REGEX } from '~/utils/constants'; import { MODEL_REGEX, PROVIDER_REGEX } from '~/utils/constants';
import { Markdown } from './Markdown'; import { Markdown } from './Markdown';
import { useStore } from '@nanostores/react';
import { profileStore } from '~/lib/stores/profile';
interface UserMessageProps { interface UserMessageProps {
content: string | Array<{ type: string; text?: string; image?: string }>; content: string | Array<{ type: string; text?: string; image?: string }>;
@@ -14,10 +16,29 @@ export function UserMessage({ content }: UserMessageProps) {
const textItem = content.find((item) => item.type === 'text'); const textItem = content.find((item) => item.type === 'text');
const textContent = stripMetadata(textItem?.text || ''); const textContent = stripMetadata(textItem?.text || '');
const images = content.filter((item) => item.type === 'image' && item.image); const images = content.filter((item) => item.type === 'image' && item.image);
const profile = useStore(profileStore);
return ( return (
<div className="overflow-hidden flex items-center"> <div className="overflow-hidden flex flex-col gap-3 items-center ">
<div className="flex flex-col gap-4"> <div className="flex flex-row items-start justify-center overflow-hidden shrink-0 self-start">
{profile?.avatar || profile?.username ? (
<div className="flex items-end gap-2">
<img
src={profile.avatar}
alt={profile?.username || 'User'}
className="w-[25px] h-[25px] object-cover rounded-full"
loading="eager"
decoding="sync"
/>
<span className="text-bolt-elements-textPrimary text-sm">
{profile?.username ? profile.username : ''}
</span>
</div>
) : (
<div className="i-ph:user-fill text-accent-500 text-2xl" />
)}
</div>
<div className="flex flex-col gap-4 bg-accent-500/10 backdrop-blur-sm p-3 py-3 w-auto rounded-lg mr-auto">
{textContent && <Markdown html>{textContent}</Markdown>} {textContent && <Markdown html>{textContent}</Markdown>}
{images.map((item, index) => ( {images.map((item, index) => (
<img <img

View File

@@ -1,13 +1,49 @@
import WithTooltip from '~/components/ui/Tooltip'; import * as DropdownMenu from '@radix-ui/react-dropdown-menu';
import { IconButton } from '~/components/ui/IconButton'; import { workbenchStore } from '~/lib/stores/workbench';
import React from 'react'; import { classNames } from '~/utils/classNames';
export const ExportChatButton = ({ exportChat }: { exportChat?: () => void }) => { export const ExportChatButton = ({ exportChat }: { exportChat?: () => void }) => {
return ( return (
<WithTooltip tooltip="Export Chat"> <div className="flex border border-bolt-elements-borderColor rounded-md overflow-hidden mr-2 text-sm">
<IconButton title="Export Chat" onClick={() => exportChat?.()}> <DropdownMenu.Root>
<div className="i-ph:download-simple text-xl"></div> <DropdownMenu.Trigger className="rounded-md items-center justify-center [&:is(:disabled,.disabled)]:cursor-not-allowed [&:is(:disabled,.disabled)]:opacity-60 px-3 py-1.5 text-xs bg-bolt-elements-background-depth-2 text-bolt-elements-textPrimary [&:not(:disabled,.disabled)]:hover:bg-bolt-elements-button-primary-backgroundHover outline-accent-500 flex gap-1.7">
</IconButton> Export
</WithTooltip> <span className={classNames('i-ph:caret-down transition-transform')} />
</DropdownMenu.Trigger>
<DropdownMenu.Content
className={classNames(
'z-[250]',
'bg-bolt-elements-background-depth-2',
'rounded-lg shadow-lg',
'border border-bolt-elements-borderColor',
'animate-in fade-in-0 zoom-in-95',
'py-1',
)}
sideOffset={5}
align="end"
>
<DropdownMenu.Item
className={classNames(
'cursor-pointer flex items-center w-auto px-4 py-2 text-sm text-bolt-elements-textPrimary hover:bg-bolt-elements-item-backgroundActive gap-2 rounded-md group relative',
)}
onClick={() => {
workbenchStore.downloadZip();
}}
>
<div className="i-ph:code size-4.5"></div>
<span>Download Code</span>
</DropdownMenu.Item>
<DropdownMenu.Item
className={classNames(
'cursor-pointer flex items-center w-full px-4 py-2 text-sm text-bolt-elements-textPrimary hover:bg-bolt-elements-item-backgroundActive gap-2 rounded-md group relative',
)}
onClick={() => exportChat?.()}
>
<div className="i-ph:chat size-4.5"></div>
<span>Export Chat</span>
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Root>
</div>
); );
}; };

View File

@@ -0,0 +1,146 @@
import * as DropdownMenu from '@radix-ui/react-dropdown-menu';
import { useStore } from '@nanostores/react';
import { netlifyConnection } from '~/lib/stores/netlify';
import { vercelConnection } from '~/lib/stores/vercel';
import { workbenchStore } from '~/lib/stores/workbench';
import { streamingState } from '~/lib/stores/streaming';
import { classNames } from '~/utils/classNames';
import { useState } from 'react';
import { NetlifyDeploymentLink } from '~/components/chat/NetlifyDeploymentLink.client';
import { VercelDeploymentLink } from '~/components/chat/VercelDeploymentLink.client';
import { useVercelDeploy } from '~/components/deploy/VercelDeploy.client';
import { useNetlifyDeploy } from '~/components/deploy/NetlifyDeploy.client';
interface DeployButtonProps {
onVercelDeploy?: () => Promise<void>;
onNetlifyDeploy?: () => Promise<void>;
}
export const DeployButton = ({ onVercelDeploy, onNetlifyDeploy }: DeployButtonProps) => {
const netlifyConn = useStore(netlifyConnection);
const vercelConn = useStore(vercelConnection);
const [activePreviewIndex] = useState(0);
const previews = useStore(workbenchStore.previews);
const activePreview = previews[activePreviewIndex];
const [isDeploying, setIsDeploying] = useState(false);
const [deployingTo, setDeployingTo] = useState<'netlify' | 'vercel' | null>(null);
const isStreaming = useStore(streamingState);
const { handleVercelDeploy } = useVercelDeploy();
const { handleNetlifyDeploy } = useNetlifyDeploy();
const handleVercelDeployClick = async () => {
setIsDeploying(true);
setDeployingTo('vercel');
try {
if (onVercelDeploy) {
await onVercelDeploy();
} else {
await handleVercelDeploy();
}
} finally {
setIsDeploying(false);
setDeployingTo(null);
}
};
const handleNetlifyDeployClick = async () => {
setIsDeploying(true);
setDeployingTo('netlify');
try {
if (onNetlifyDeploy) {
await onNetlifyDeploy();
} else {
await handleNetlifyDeploy();
}
} finally {
setIsDeploying(false);
setDeployingTo(null);
}
};
return (
<div className="flex border border-bolt-elements-borderColor rounded-md overflow-hidden text-sm">
<DropdownMenu.Root>
<DropdownMenu.Trigger
disabled={isDeploying || !activePreview || isStreaming}
className="rounded-md items-center justify-center [&:is(:disabled,.disabled)]:cursor-not-allowed [&:is(:disabled,.disabled)]:opacity-60 px-3 py-1.5 text-xs bg-accent-500 text-white hover:text-bolt-elements-item-contentAccent [&:not(:disabled,.disabled)]:hover:bg-bolt-elements-button-primary-backgroundHover outline-accent-500 flex gap-1.7"
>
{isDeploying ? `Deploying to ${deployingTo}...` : 'Deploy'}
<span className={classNames('i-ph:caret-down transition-transform')} />
</DropdownMenu.Trigger>
<DropdownMenu.Content
className={classNames(
'z-[250]',
'bg-bolt-elements-background-depth-2',
'rounded-lg shadow-lg',
'border border-bolt-elements-borderColor',
'animate-in fade-in-0 zoom-in-95',
'py-1',
)}
sideOffset={5}
align="end"
>
<DropdownMenu.Item
className={classNames(
'cursor-pointer flex items-center w-full px-4 py-2 text-sm text-bolt-elements-textPrimary hover:bg-bolt-elements-item-backgroundActive gap-2 rounded-md group relative',
{
'opacity-60 cursor-not-allowed': isDeploying || !activePreview || !netlifyConn.user,
},
)}
disabled={isDeploying || !activePreview || !netlifyConn.user}
onClick={handleNetlifyDeployClick}
>
<img
className="w-5 h-5"
height="24"
width="24"
crossOrigin="anonymous"
src="https://cdn.simpleicons.org/netlify"
/>
<span className="mx-auto">{!netlifyConn.user ? 'No Netlify Account Connected' : 'Deploy to Netlify'}</span>
{netlifyConn.user && <NetlifyDeploymentLink />}
</DropdownMenu.Item>
<DropdownMenu.Item
className={classNames(
'cursor-pointer flex items-center w-full px-4 py-2 text-sm text-bolt-elements-textPrimary hover:bg-bolt-elements-item-backgroundActive gap-2 rounded-md group relative',
{
'opacity-60 cursor-not-allowed': isDeploying || !activePreview || !vercelConn.user,
},
)}
disabled={isDeploying || !activePreview || !vercelConn.user}
onClick={handleVercelDeployClick}
>
<img
className="w-5 h-5 bg-black p-1 rounded"
height="24"
width="24"
crossOrigin="anonymous"
src="https://cdn.simpleicons.org/vercel/white"
alt="vercel"
/>
<span className="mx-auto">{!vercelConn.user ? 'No Vercel Account Connected' : 'Deploy to Vercel'}</span>
{vercelConn.user && <VercelDeploymentLink />}
</DropdownMenu.Item>
<DropdownMenu.Item
disabled
className="flex items-center w-full rounded-md px-4 py-2 text-sm text-bolt-elements-textTertiary gap-2 opacity-60 cursor-not-allowed"
>
<img
className="w-5 h-5"
height="24"
width="24"
crossOrigin="anonymous"
src="https://cdn.simpleicons.org/cloudflare"
alt="cloudflare"
/>
<span className="mx-auto">Deploy to Cloudflare (Coming Soon)</span>
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Root>
</div>
);
};

View File

@@ -10,7 +10,7 @@ export function Header() {
return ( return (
<header <header
className={classNames('flex items-center p-5 border-b h-[var(--header-height)]', { className={classNames('flex items-center px-4 border-b h-[var(--header-height)]', {
'border-transparent': !chat.started, 'border-transparent': !chat.started,
'border-bolt-elements-borderColor': chat.started, 'border-bolt-elements-borderColor': chat.started,
})} })}
@@ -30,8 +30,8 @@ export function Header() {
</span> </span>
<ClientOnly> <ClientOnly>
{() => ( {() => (
<div className="mr-1"> <div className="">
<HeaderActionButtons /> <HeaderActionButtons chatStarted={chat.started} />
</div> </div>
)} )}
</ClientOnly> </ClientOnly>

View File

@@ -1,206 +1,28 @@
import { useStore } from '@nanostores/react'; import { useStore } from '@nanostores/react';
import useViewport from '~/lib/hooks';
import { chatStore } from '~/lib/stores/chat';
import { netlifyConnection } from '~/lib/stores/netlify';
import { vercelConnection } from '~/lib/stores/vercel';
import { workbenchStore } from '~/lib/stores/workbench'; import { workbenchStore } from '~/lib/stores/workbench';
import { classNames } from '~/utils/classNames'; import { useState } from 'react';
import { useEffect, useRef, useState } from 'react';
import { streamingState } from '~/lib/stores/streaming'; import { streamingState } from '~/lib/stores/streaming';
import { NetlifyDeploymentLink } from '~/components/chat/NetlifyDeploymentLink.client'; import { ExportChatButton } from '~/components/chat/chatExportAndImport/ExportChatButton';
import { VercelDeploymentLink } from '~/components/chat/VercelDeploymentLink.client'; import { useChatHistory } from '~/lib/persistence';
import { useVercelDeploy } from '~/components/deploy/VercelDeploy.client'; import { DeployButton } from '~/components/deploy/DeployButton';
import { useNetlifyDeploy } from '~/components/deploy/NetlifyDeploy.client';
interface HeaderActionButtonsProps {} interface HeaderActionButtonsProps {
chatStarted: boolean;
}
export function HeaderActionButtons({}: HeaderActionButtonsProps) { export function HeaderActionButtons({ chatStarted }: HeaderActionButtonsProps) {
const showWorkbench = useStore(workbenchStore.showWorkbench);
const { showChat } = useStore(chatStore);
const netlifyConn = useStore(netlifyConnection);
const vercelConn = useStore(vercelConnection);
const [activePreviewIndex] = useState(0); const [activePreviewIndex] = useState(0);
const previews = useStore(workbenchStore.previews); const previews = useStore(workbenchStore.previews);
const activePreview = previews[activePreviewIndex]; const activePreview = previews[activePreviewIndex];
const [isDeploying, setIsDeploying] = useState(false);
const [deployingTo, setDeployingTo] = useState<'netlify' | 'vercel' | null>(null);
const isSmallViewport = useViewport(1024);
const canHideChat = showWorkbench || !showChat;
const [isDropdownOpen, setIsDropdownOpen] = useState(false);
const dropdownRef = useRef<HTMLDivElement>(null);
const isStreaming = useStore(streamingState); const isStreaming = useStore(streamingState);
const { handleVercelDeploy } = useVercelDeploy(); const { exportChat } = useChatHistory();
const { handleNetlifyDeploy } = useNetlifyDeploy();
useEffect(() => { const shouldShowButtons = !isStreaming && activePreview;
function handleClickOutside(event: MouseEvent) {
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
setIsDropdownOpen(false);
}
}
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, []);
const onVercelDeploy = async () => {
setIsDeploying(true);
setDeployingTo('vercel');
try {
await handleVercelDeploy();
} finally {
setIsDeploying(false);
setDeployingTo(null);
}
};
const onNetlifyDeploy = async () => {
setIsDeploying(true);
setDeployingTo('netlify');
try {
await handleNetlifyDeploy();
} finally {
setIsDeploying(false);
setDeployingTo(null);
}
};
return ( return (
<div className="flex"> <div className="flex items-center">
<div className="relative" ref={dropdownRef}> {chatStarted && shouldShowButtons && <ExportChatButton exportChat={exportChat} />}
<div className="flex border border-bolt-elements-borderColor rounded-md overflow-hidden mr-2 text-sm"> {shouldShowButtons && <DeployButton />}
<Button
active
disabled={isDeploying || !activePreview || isStreaming}
onClick={() => setIsDropdownOpen(!isDropdownOpen)}
className="px-4 hover:bg-bolt-elements-item-backgroundActive flex items-center gap-2"
>
{isDeploying ? `Deploying to ${deployingTo}...` : 'Deploy'}
<div
className={classNames('i-ph:caret-down w-4 h-4 transition-transform', isDropdownOpen ? 'rotate-180' : '')}
/>
</Button>
</div>
{isDropdownOpen && (
<div className="absolute right-2 flex flex-col gap-1 z-50 p-1 mt-1 min-w-[13.5rem] bg-bolt-elements-background-depth-2 rounded-md shadow-lg bg-bolt-elements-backgroundDefault border border-bolt-elements-borderColor">
<Button
active
onClick={() => {
onNetlifyDeploy();
setIsDropdownOpen(false);
}}
disabled={isDeploying || !activePreview || !netlifyConn.user}
className="flex items-center w-full px-4 py-2 text-sm text-bolt-elements-textPrimary hover:bg-bolt-elements-item-backgroundActive gap-2 rounded-md group relative"
>
<img
className="w-5 h-5"
height="24"
width="24"
crossOrigin="anonymous"
src="https://cdn.simpleicons.org/netlify"
/>
<span className="mx-auto">
{!netlifyConn.user ? 'No Netlify Account Connected' : 'Deploy to Netlify'}
</span>
{netlifyConn.user && <NetlifyDeploymentLink />}
</Button>
<Button
active
onClick={() => {
onVercelDeploy();
setIsDropdownOpen(false);
}}
disabled={isDeploying || !activePreview || !vercelConn.user}
className="flex items-center w-full px-4 py-2 text-sm text-bolt-elements-textPrimary hover:bg-bolt-elements-item-backgroundActive gap-2 rounded-md group relative"
>
<img
className="w-5 h-5 bg-black p-1 rounded"
height="24"
width="24"
crossOrigin="anonymous"
src="https://cdn.simpleicons.org/vercel/white"
alt="vercel"
/>
<span className="mx-auto">{!vercelConn.user ? 'No Vercel Account Connected' : 'Deploy to Vercel'}</span>
{vercelConn.user && <VercelDeploymentLink />}
</Button>
<Button
active={false}
disabled
className="flex items-center w-full rounded-md px-4 py-2 text-sm text-bolt-elements-textTertiary gap-2"
>
<span className="sr-only">Coming Soon</span>
<img
className="w-5 h-5"
height="24"
width="24"
crossOrigin="anonymous"
src="https://cdn.simpleicons.org/cloudflare"
alt="cloudflare"
/>
<span className="mx-auto">Deploy to Cloudflare (Coming Soon)</span>
</Button>
</div>
)}
</div>
<div className="flex border border-bolt-elements-borderColor rounded-md overflow-hidden">
<Button
active={showChat}
disabled={!canHideChat || isSmallViewport} // expand button is disabled on mobile as it's not needed
onClick={() => {
if (canHideChat) {
chatStore.setKey('showChat', !showChat);
}
}}
>
<div className="i-bolt:chat text-sm" />
</Button>
<div className="w-[1px] bg-bolt-elements-borderColor" />
<Button
active={showWorkbench}
onClick={() => {
if (showWorkbench && !showChat) {
chatStore.setKey('showChat', true);
}
workbenchStore.showWorkbench.set(!showWorkbench);
}}
>
<div className="i-ph:code-bold" />
</Button>
</div>
</div> </div>
); );
} }
interface ButtonProps {
active?: boolean;
disabled?: boolean;
children?: any;
onClick?: VoidFunction;
className?: string;
}
function Button({ active = false, disabled = false, children, onClick, className }: ButtonProps) {
return (
<button
className={classNames(
'flex items-center p-1.5',
{
'bg-bolt-elements-item-backgroundDefault hover:bg-bolt-elements-item-backgroundActive text-bolt-elements-textTertiary hover:text-bolt-elements-textPrimary':
!active,
'bg-bolt-elements-item-backgroundAccent text-bolt-elements-item-contentAccent': active && !disabled,
'bg-bolt-elements-item-backgroundDefault text-alpha-gray-20 dark:text-alpha-white-20 cursor-not-allowed':
disabled,
},
className,
)}
onClick={onClick}
>
{children}
</button>
);
}

View File

@@ -279,8 +279,8 @@ export const Menu = () => {
}, [open, selectionMode]); }, [open, selectionMode]);
useEffect(() => { useEffect(() => {
const enterThreshold = 40; const enterThreshold = 20;
const exitThreshold = 40; const exitThreshold = 20;
function onMouseMove(event: MouseEvent) { function onMouseMove(event: MouseEvent) {
if (isSettingsOpen) { if (isSettingsOpen) {
@@ -331,13 +331,13 @@ export const Menu = () => {
variants={menuVariants} variants={menuVariants}
style={{ width: '340px' }} style={{ width: '340px' }}
className={classNames( className={classNames(
'flex selection-accent flex-col side-menu fixed top-0 h-full', 'flex selection-accent flex-col side-menu fixed top-0 h-full rounded-r-2xl',
'bg-white dark:bg-gray-950 border-r border-gray-100 dark:border-gray-800/50', 'bg-white dark:bg-gray-950 border-r border-bolt-elements-borderColor',
'shadow-sm text-sm', 'shadow-sm text-sm',
isSettingsOpen ? 'z-40' : 'z-sidebar', isSettingsOpen ? 'z-40' : 'z-sidebar',
)} )}
> >
<div className="h-12 flex items-center justify-between px-4 border-b border-gray-100 dark:border-gray-800/50 bg-gray-50/50 dark:bg-gray-900/50"> <div className="h-12 flex items-center justify-between px-4 border-b border-gray-100 dark:border-gray-800/50 bg-gray-50/50 dark:bg-gray-900/50 rounded-tr-2xl">
<div className="text-gray-900 dark:text-white font-medium"></div> <div className="text-gray-900 dark:text-white font-medium"></div>
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<span className="font-medium text-sm text-gray-900 dark:text-white truncate"> <span className="font-medium text-sm text-gray-900 dark:text-white truncate">

View File

@@ -0,0 +1,378 @@
import React, { useState, useEffect } from 'react';
import { Dialog, DialogTitle, DialogDescription, DialogRoot } from './Dialog';
import { Button } from './Button';
import { IconButton } from './IconButton';
import type { DesignScheme } from '~/types/design-scheme';
import { defaultDesignScheme, designFeatures, designFonts, paletteRoles } from '~/types/design-scheme';
export interface ColorSchemeDialogProps {
designScheme?: DesignScheme;
setDesignScheme?: (scheme: DesignScheme) => void;
}
export const ColorSchemeDialog: React.FC<ColorSchemeDialogProps> = ({ setDesignScheme, designScheme }) => {
const [palette, setPalette] = useState<{ [key: string]: string }>(() => {
if (designScheme?.palette) {
return { ...defaultDesignScheme.palette, ...designScheme.palette };
}
return defaultDesignScheme.palette;
});
const [features, setFeatures] = useState<string[]>(designScheme?.features || defaultDesignScheme.features);
const [font, setFont] = useState<string[]>(designScheme?.font || defaultDesignScheme.font);
const [isDialogOpen, setIsDialogOpen] = useState(false);
const [activeSection, setActiveSection] = useState<'colors' | 'typography' | 'features'>('colors');
useEffect(() => {
if (designScheme) {
setPalette(() => ({ ...defaultDesignScheme.palette, ...designScheme.palette }));
setFeatures(designScheme.features || defaultDesignScheme.features);
setFont(designScheme.font || defaultDesignScheme.font);
} else {
setPalette(defaultDesignScheme.palette);
setFeatures(defaultDesignScheme.features);
setFont(defaultDesignScheme.font);
}
}, [designScheme]);
const handleColorChange = (role: string, value: string) => {
setPalette((prev) => ({ ...prev, [role]: value }));
};
const handleFeatureToggle = (key: string) => {
setFeatures((prev) => (prev.includes(key) ? prev.filter((f) => f !== key) : [...prev, key]));
};
const handleFontToggle = (key: string) => {
setFont((prev) => (prev.includes(key) ? prev.filter((f) => f !== key) : [...prev, key]));
};
const handleSave = () => {
setDesignScheme?.({ palette, features, font });
setIsDialogOpen(false);
};
const handleReset = () => {
setPalette(defaultDesignScheme.palette);
setFeatures(defaultDesignScheme.features);
setFont(defaultDesignScheme.font);
};
const renderColorSection = () => (
<div className="space-y-4">
<div className="flex justify-between items-center">
<h3 className="text-lg font-semibold text-bolt-elements-textPrimary flex items-center gap-2">
<div className="w-2 h-2 rounded-full bg-bolt-elements-item-contentAccent"></div>
Color Palette
</h3>
<button
onClick={handleReset}
className="text-sm bg-transparent hover:bg-bolt-elements-bg-depth-2 text-bolt-elements-textSecondary hover:text-bolt-elements-textPrimary rounded-lg flex items-center gap-2 transition-all duration-200"
>
<span className="i-ph:arrow-clockwise text-sm" />
Reset
</button>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3 max-h-80 overflow-y-auto pr-2 custom-scrollbar">
{paletteRoles.map((role) => (
<div
key={role.key}
className="group flex items-center gap-4 p-4 rounded-xl bg-bolt-elements-bg-depth-3 hover:bg-bolt-elements-bg-depth-2 border border-transparent hover:border-bolt-elements-borderColor transition-all duration-200"
>
<div className="relative flex-shrink-0">
<div
className="w-12 h-12 rounded-xl shadow-md cursor-pointer transition-all duration-200 hover:scale-110 ring-2 ring-transparent hover:ring-bolt-elements-borderColorActive"
style={{ backgroundColor: palette[role.key] }}
onClick={() => document.getElementById(`color-input-${role.key}`)?.click()}
role="button"
tabIndex={0}
aria-label={`Change ${role.label} color`}
/>
<input
id={`color-input-${role.key}`}
type="color"
value={palette[role.key]}
onChange={(e) => handleColorChange(role.key, e.target.value)}
className="absolute inset-0 w-full h-full opacity-0 cursor-pointer"
tabIndex={-1}
/>
<div className="absolute -bottom-1 -right-1 w-4 h-4 bg-bolt-elements-bg-depth-1 rounded-full flex items-center justify-center shadow-sm">
<span className="i-ph:pencil-simple text-xs text-bolt-elements-textSecondary" />
</div>
</div>
<div className="flex-1 min-w-0">
<div className="font-semibold text-bolt-elements-textPrimary transition-colors">{role.label}</div>
<div className="text-sm text-bolt-elements-textSecondary line-clamp-2 leading-relaxed">
{role.description}
</div>
<div className="text-xs text-bolt-elements-textTertiary font-mono mt-1 px-2 py-1 bg-bolt-elements-bg-depth-1 rounded-md inline-block">
{palette[role.key]}
</div>
</div>
</div>
))}
</div>
</div>
);
const renderTypographySection = () => (
<div className="space-y-4">
<h3 className="text-lg font-semibold text-bolt-elements-textPrimary flex items-center gap-2">
<div className="w-2 h-2 rounded-full bg-bolt-elements-item-contentAccent"></div>
Typography
</h3>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3 max-h-80 overflow-y-auto pr-2 custom-scrollbar">
{designFonts.map((f) => (
<button
key={f.key}
type="button"
onClick={() => handleFontToggle(f.key)}
className={`group p-4 rounded-xl border-2 transition-all duration-200 focus:outline-none focus:ring-2 focus:ring-bolt-elements-borderColorActive ${
font.includes(f.key)
? 'bg-bolt-elements-item-backgroundAccent border-bolt-elements-borderColorActive shadow-lg'
: 'bg-bolt-elements-background-depth-3 border-bolt-elements-borderColor hover:border-bolt-elements-borderColorActive hover:bg-bolt-elements-bg-depth-2'
}`}
>
<div className="text-center space-y-2">
<div
className={`text-2xl font-medium transition-colors ${
font.includes(f.key) ? 'text-bolt-elements-item-contentAccent' : 'text-bolt-elements-textPrimary'
}`}
style={{ fontFamily: f.key }}
>
{f.preview}
</div>
<div
className={`text-sm font-medium transition-colors ${
font.includes(f.key) ? 'text-bolt-elements-item-contentAccent' : 'text-bolt-elements-textSecondary'
}`}
>
{f.label}
</div>
{font.includes(f.key) && (
<div className="w-6 h-6 mx-auto bg-bolt-elements-item-contentAccent rounded-full flex items-center justify-center">
<span className="i-ph:check text-white text-sm" />
</div>
)}
</div>
</button>
))}
</div>
</div>
);
const renderFeaturesSection = () => (
<div className="space-y-4">
<h3 className="text-lg font-semibold text-bolt-elements-textPrimary flex items-center gap-2">
<div className="w-2 h-2 rounded-full bg-bolt-elements-item-contentAccent"></div>
Design Features
</h3>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4 max-h-80 overflow-y-auto pr-2 custom-scrollbar">
{designFeatures.map((f) => {
const isSelected = features.includes(f.key);
return (
<div key={f.key} className="feature-card-container p-2">
<button
type="button"
onClick={() => handleFeatureToggle(f.key)}
className={`group relative w-full p-6 text-sm font-medium transition-all duration-200 bg-bolt-elements-background-depth-3 text-bolt-elements-item-textSecondary ${
f.key === 'rounded'
? isSelected
? 'rounded-3xl'
: 'rounded-xl'
: f.key === 'border'
? 'rounded-lg'
: 'rounded-xl'
} ${
f.key === 'border'
? isSelected
? 'border-3 border-bolt-elements-borderColorActive bg-bolt-elements-item-backgroundAccent text-bolt-elements-item-contentAccent'
: 'border-2 border-bolt-elements-borderColor hover:border-bolt-elements-borderColorActive text-bolt-elements-textSecondary'
: f.key === 'gradient'
? ''
: isSelected
? 'bg-bolt-elements-item-backgroundAccent text-bolt-elements-item-contentAccent shadow-lg'
: 'bg-bolt-elements-bg-depth-3 hover:bg-bolt-elements-bg-depth-2 text-bolt-elements-textSecondary hover:text-bolt-elements-textPrimary'
} ${f.key === 'shadow' ? (isSelected ? 'shadow-xl' : 'shadow-lg') : 'shadow-md'}`}
style={{
...(f.key === 'gradient' && {
background: isSelected
? 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)'
: 'var(--bolt-elements-bg-depth-3)',
color: isSelected ? 'white' : 'var(--bolt-elements-textSecondary)',
}),
}}
>
<div className="flex flex-col items-center gap-4">
<div className="flex items-center justify-center w-12 h-12 rounded-xl bg-bolt-elements-bg-depth-1 bg-opacity-20">
{f.key === 'rounded' && (
<div
className={`w-6 h-6 bg-current transition-all duration-200 ${
isSelected ? 'rounded-full' : 'rounded'
} opacity-80`}
/>
)}
{f.key === 'border' && (
<div
className={`w-6 h-6 rounded-lg transition-all duration-200 ${
isSelected ? 'border-3 border-current opacity-90' : 'border-2 border-current opacity-70'
}`}
/>
)}
{f.key === 'gradient' && (
<div className="w-6 h-6 rounded-lg bg-gradient-to-br from-purple-400 via-pink-400 to-indigo-400 opacity-90" />
)}
{f.key === 'shadow' && (
<div className="relative">
<div
className={`w-6 h-6 bg-current rounded-lg transition-all duration-200 ${
isSelected ? 'opacity-90' : 'opacity-70'
}`}
/>
<div
className={`absolute top-1 left-1 w-6 h-6 bg-current rounded-lg transition-all duration-200 ${
isSelected ? 'opacity-40' : 'opacity-30'
}`}
/>
</div>
)}
{f.key === 'frosted-glass' && (
<div className="relative">
<div
className={`w-6 h-6 rounded-lg transition-all duration-200 backdrop-blur-sm bg-white/20 border border-white/30 ${
isSelected ? 'opacity-90' : 'opacity-70'
}`}
/>
<div
className={`absolute inset-0 w-6 h-6 rounded-lg transition-all duration-200 backdrop-blur-md bg-gradient-to-br from-white/10 to-transparent ${
isSelected ? 'opacity-60' : 'opacity-40'
}`}
/>
</div>
)}
</div>
<div className="text-center">
<div className="font-semibold">{f.label}</div>
{isSelected && <div className="mt-2 w-8 h-1 bg-current rounded-full mx-auto opacity-60" />}
</div>
</div>
</button>
</div>
);
})}
</div>
</div>
);
return (
<div>
<IconButton title="Design Palette" className="transition-all" onClick={() => setIsDialogOpen(!isDialogOpen)}>
<div className="i-ph:palette text-xl"></div>
</IconButton>
<DialogRoot open={isDialogOpen} onOpenChange={setIsDialogOpen}>
<Dialog>
<div className="py-4 px-4 min-w-[480px] max-w-[90vw] max-h-[85vh] flex flex-col gap-6 overflow-hidden">
<div className="">
<DialogTitle className="text-2xl font-bold text-bolt-elements-textPrimary">
Design Palette & Features
</DialogTitle>
<DialogDescription className="text-bolt-elements-textSecondary leading-relaxed">
Customize your color palette, typography, and design features. These preferences will guide the AI in
creating designs that match your style.
</DialogDescription>
</div>
{/* Navigation Tabs */}
<div className="flex gap-1 p-1 bg-bolt-elements-bg-depth-3 rounded-xl">
{[
{ key: 'colors', label: 'Colors', icon: 'i-ph:palette' },
{ key: 'typography', label: 'Typography', icon: 'i-ph:text-aa' },
{ key: 'features', label: 'Features', icon: 'i-ph:magic-wand' },
].map((tab) => (
<button
key={tab.key}
onClick={() => setActiveSection(tab.key as any)}
className={`flex-1 flex items-center justify-center gap-2 px-4 py-3 rounded-lg font-medium transition-all duration-200 ${
activeSection === tab.key
? 'bg-bolt-elements-background-depth-3 text-bolt-elements-textPrimary shadow-md'
: 'bg-bolt-elements-background-depth-2 text-bolt-elements-textSecondary hover:text-bolt-elements-textPrimary hover:bg-bolt-elements-bg-depth-2'
}`}
>
<span className={`${tab.icon} text-lg`} />
<span>{tab.label}</span>
</button>
))}
</div>
{/* Content Area */}
<div className=" min-h-92 overflow-y-auto">
{activeSection === 'colors' && renderColorSection()}
{activeSection === 'typography' && renderTypographySection()}
{activeSection === 'features' && renderFeaturesSection()}
</div>
{/* Action Buttons */}
<div className="flex justify-between items-center">
<div className="text-sm text-bolt-elements-textSecondary">
{Object.keys(palette).length} colors {font.length} fonts {features.length} features
</div>
<div className="flex gap-3">
<Button variant="secondary" onClick={() => setIsDialogOpen(false)}>
Cancel
</Button>
<Button
variant="ghost"
onClick={handleSave}
className="bg-bolt-elements-button-primary-background hover:bg-bolt-elements-button-primary-backgroundHover text-bolt-elements-button-primary-text"
>
Save Changes
</Button>
</div>
</div>
</div>
</Dialog>
</DialogRoot>
<style>{`
.custom-scrollbar {
scrollbar-width: thin;
scrollbar-color: var(--bolt-elements-textTertiary) transparent;
}
.custom-scrollbar::-webkit-scrollbar {
width: 6px;
}
.custom-scrollbar::-webkit-scrollbar-track {
background: transparent;
}
.custom-scrollbar::-webkit-scrollbar-thumb {
background-color: var(--bolt-elements-textTertiary);
border-radius: 3px;
}
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
background-color: var(--bolt-elements-textSecondary);
}
.line-clamp-2 {
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.feature-card-container {
min-height: 140px;
display: flex;
align-items: stretch;
}
.feature-card-container button {
flex: 1;
}
`}</style>
</div>
);
};

View File

@@ -0,0 +1,126 @@
import { useEffect, useRef, useState } from 'react';
interface InspectorProps {
isActive: boolean;
iframeRef: React.RefObject<HTMLIFrameElement>;
onElementSelect: (elementInfo: ElementInfo) => void;
}
export interface ElementInfo {
displayText: string;
tagName: string;
className: string;
id: string;
textContent: string;
styles: Record<string, string>; // Changed from CSSStyleDeclaration
rect: {
x: number;
y: number;
width: number;
height: number;
top: number;
left: number;
};
}
export const Inspector = ({ isActive, iframeRef, onElementSelect }: InspectorProps) => {
const [hoveredElement, setHoveredElement] = useState<ElementInfo | null>(null);
const overlayRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!isActive || !iframeRef.current) {
return undefined;
}
const iframe = iframeRef.current;
// Listen for messages from the iframe
const handleMessage = (event: MessageEvent) => {
if (event.data.type === 'INSPECTOR_HOVER') {
const elementInfo = event.data.elementInfo;
// Adjust coordinates relative to iframe position
const iframeRect = iframe.getBoundingClientRect();
elementInfo.rect.x += iframeRect.x;
elementInfo.rect.y += iframeRect.y;
elementInfo.rect.top += iframeRect.y;
elementInfo.rect.left += iframeRect.x;
setHoveredElement(elementInfo);
} else if (event.data.type === 'INSPECTOR_CLICK') {
const elementInfo = event.data.elementInfo;
// Adjust coordinates relative to iframe position
const iframeRect = iframe.getBoundingClientRect();
elementInfo.rect.x += iframeRect.x;
elementInfo.rect.y += iframeRect.y;
elementInfo.rect.top += iframeRect.y;
elementInfo.rect.left += iframeRect.x;
onElementSelect(elementInfo);
} else if (event.data.type === 'INSPECTOR_LEAVE') {
setHoveredElement(null);
}
};
window.addEventListener('message', handleMessage);
// Send activation message to iframe
const sendActivationMessage = () => {
if (iframe.contentWindow) {
iframe.contentWindow.postMessage(
{
type: 'INSPECTOR_ACTIVATE',
active: isActive,
},
'*',
);
}
};
// Try to send activation message immediately and on load
sendActivationMessage();
iframe.addEventListener('load', sendActivationMessage);
return () => {
window.removeEventListener('message', handleMessage);
iframe.removeEventListener('load', sendActivationMessage);
// Deactivate inspector in iframe
if (iframe.contentWindow) {
iframe.contentWindow.postMessage(
{
type: 'INSPECTOR_ACTIVATE',
active: false,
},
'*',
);
}
};
}, [isActive, iframeRef, onElementSelect]);
// Render overlay for hovered element
return (
<>
{isActive && hoveredElement && (
<div
ref={overlayRef}
className="fixed pointer-events-none z-50 border-2 border-blue-500 bg-blue-500/10"
style={{
left: hoveredElement.rect.x,
top: hoveredElement.rect.y,
width: hoveredElement.rect.width,
height: hoveredElement.rect.height,
}}
>
{/* Element info tooltip */}
<div className="absolute -top-8 left-0 bg-gray-900 text-white text-xs px-2 py-1 rounded whitespace-nowrap">
{hoveredElement.tagName.toLowerCase()}
{hoveredElement.id && `#${hoveredElement.id}`}
{hoveredElement.className && `.${hoveredElement.className.split(' ')[0]}`}
</div>
</div>
)}
</>
);
};

View File

@@ -0,0 +1,146 @@
import { useState } from 'react';
interface ElementInfo {
tagName: string;
className: string;
id: string;
textContent: string;
styles: Record<string, string>; // Changed from CSSStyleDeclaration
rect: {
x: number;
y: number;
width: number;
height: number;
top: number;
left: number;
};
}
interface InspectorPanelProps {
selectedElement: ElementInfo | null;
isVisible: boolean;
onClose: () => void;
}
export const InspectorPanel = ({ selectedElement, isVisible, onClose }: InspectorPanelProps) => {
const [activeTab, setActiveTab] = useState<'styles' | 'computed' | 'box'>('styles');
if (!isVisible || !selectedElement) {
return null;
}
const getRelevantStyles = (styles: Record<string, string>) => {
const relevantProps = [
'display',
'position',
'width',
'height',
'margin',
'padding',
'border',
'background',
'color',
'font-size',
'font-family',
'text-align',
'flex-direction',
'justify-content',
'align-items',
];
return relevantProps.reduce(
(acc, prop) => {
const value = styles[prop];
if (value) {
acc[prop] = value;
}
return acc;
},
{} as Record<string, string>,
);
};
return (
<div className="fixed right-4 top-20 w-80 bg-bolt-elements-bg-depth-1 border border-bolt-elements-borderColor rounded-lg shadow-lg z-40 max-h-[calc(100vh-6rem)] overflow-hidden">
{/* Header */}
<div className="flex items-center justify-between p-3 border-b border-bolt-elements-borderColor">
<h3 className="font-medium text-bolt-elements-textPrimary">Element Inspector</h3>
<button onClick={onClose} className="text-bolt-elements-textSecondary hover:text-bolt-elements-textPrimary">
</button>
</div>
{/* Element Info */}
<div className="p-3 border-b border-bolt-elements-borderColor">
<div className="text-sm">
<div className="font-mono text-blue-500">
{selectedElement.tagName.toLowerCase()}
{selectedElement.id && <span className="text-green-500">#{selectedElement.id}</span>}
{selectedElement.className && (
<span className="text-yellow-500">.{selectedElement.className.split(' ')[0]}</span>
)}
</div>
{selectedElement.textContent && (
<div className="mt-1 text-bolt-elements-textSecondary text-xs truncate">
"{selectedElement.textContent}"
</div>
)}
</div>
</div>
{/* Tabs */}
<div className="flex border-b border-bolt-elements-borderColor">
{(['styles', 'computed', 'box'] as const).map((tab) => (
<button
key={tab}
onClick={() => setActiveTab(tab)}
className={`px-3 py-2 text-sm capitalize ${
activeTab === tab
? 'border-b-2 border-blue-500 text-blue-500'
: 'text-bolt-elements-textSecondary hover:text-bolt-elements-textPrimary'
}`}
>
{tab}
</button>
))}
</div>
{/* Content */}
<div className="p-3 overflow-y-auto max-h-96">
{activeTab === 'styles' && (
<div className="space-y-2">
{Object.entries(getRelevantStyles(selectedElement.styles)).map(([prop, value]) => (
<div key={prop} className="flex justify-between text-sm">
<span className="text-bolt-elements-textSecondary">{prop}:</span>
<span className="text-bolt-elements-textPrimary font-mono">{value}</span>
</div>
))}
</div>
)}
{activeTab === 'box' && (
<div className="space-y-2 text-sm">
<div className="flex justify-between">
<span className="text-bolt-elements-textSecondary">Width:</span>
<span className="text-bolt-elements-textPrimary">{Math.round(selectedElement.rect.width)}px</span>
</div>
<div className="flex justify-between">
<span className="text-bolt-elements-textSecondary">Height:</span>
<span className="text-bolt-elements-textPrimary">{Math.round(selectedElement.rect.height)}px</span>
</div>
<div className="flex justify-between">
<span className="text-bolt-elements-textSecondary">Top:</span>
<span className="text-bolt-elements-textPrimary">{Math.round(selectedElement.rect.top)}px</span>
</div>
<div className="flex justify-between">
<span className="text-bolt-elements-textSecondary">Left:</span>
<span className="text-bolt-elements-textPrimary">{Math.round(selectedElement.rect.left)}px</span>
</div>
</div>
)}
</div>
</div>
);
};

View File

@@ -6,9 +6,14 @@ import { PortDropdown } from './PortDropdown';
import { ScreenshotSelector } from './ScreenshotSelector'; import { ScreenshotSelector } from './ScreenshotSelector';
import { expoUrlAtom } from '~/lib/stores/qrCodeStore'; import { expoUrlAtom } from '~/lib/stores/qrCodeStore';
import { ExpoQrModal } from '~/components/workbench/ExpoQrModal'; import { ExpoQrModal } from '~/components/workbench/ExpoQrModal';
import type { ElementInfo } from './Inspector';
type ResizeSide = 'left' | 'right' | null; type ResizeSide = 'left' | 'right' | null;
interface PreviewProps {
setSelectedElement?: (element: ElementInfo | null) => void;
}
interface WindowSize { interface WindowSize {
name: string; name: string;
width: number; width: number;
@@ -47,11 +52,10 @@ const WINDOW_SIZES: WindowSize[] = [
{ name: '4K Display', width: 3840, height: 2160, icon: 'i-ph:monitor', hasFrame: true, frameType: 'desktop' }, { name: '4K Display', width: 3840, height: 2160, icon: 'i-ph:monitor', hasFrame: true, frameType: 'desktop' },
]; ];
export const Preview = memo(() => { export const Preview = memo(({ setSelectedElement }: PreviewProps) => {
const iframeRef = useRef<HTMLIFrameElement>(null); const iframeRef = useRef<HTMLIFrameElement>(null);
const containerRef = useRef<HTMLDivElement>(null); const containerRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null); const inputRef = useRef<HTMLInputElement>(null);
const [activePreviewIndex, setActivePreviewIndex] = useState(0); const [activePreviewIndex, setActivePreviewIndex] = useState(0);
const [isPortDropdownOpen, setIsPortDropdownOpen] = useState(false); const [isPortDropdownOpen, setIsPortDropdownOpen] = useState(false);
const [isFullscreen, setIsFullscreen] = useState(false); const [isFullscreen, setIsFullscreen] = useState(false);
@@ -61,11 +65,8 @@ export const Preview = memo(() => {
const [displayPath, setDisplayPath] = useState('/'); const [displayPath, setDisplayPath] = useState('/');
const [iframeUrl, setIframeUrl] = useState<string | undefined>(); const [iframeUrl, setIframeUrl] = useState<string | undefined>();
const [isSelectionMode, setIsSelectionMode] = useState(false); const [isSelectionMode, setIsSelectionMode] = useState(false);
const [isInspectorMode, setIsInspectorMode] = useState(false);
// Toggle between responsive mode and device mode
const [isDeviceModeOn, setIsDeviceModeOn] = useState(false); const [isDeviceModeOn, setIsDeviceModeOn] = useState(false);
// Use percentage for width
const [widthPercent, setWidthPercent] = useState<number>(37.5); const [widthPercent, setWidthPercent] = useState<number>(37.5);
const [currentWidth, setCurrentWidth] = useState<number>(0); const [currentWidth, setCurrentWidth] = useState<number>(0);
@@ -618,6 +619,47 @@ export const Preview = memo(() => {
}; };
}, [showDeviceFrameInPreview]); }, [showDeviceFrameInPreview]);
useEffect(() => {
const handleMessage = (event: MessageEvent) => {
if (event.data.type === 'INSPECTOR_READY') {
if (iframeRef.current?.contentWindow) {
iframeRef.current.contentWindow.postMessage(
{
type: 'INSPECTOR_ACTIVATE',
active: isInspectorMode,
},
'*',
);
}
} else if (event.data.type === 'INSPECTOR_CLICK') {
const element = event.data.elementInfo;
navigator.clipboard.writeText(element.displayText).then(() => {
setSelectedElement?.(element);
});
}
};
window.addEventListener('message', handleMessage);
return () => window.removeEventListener('message', handleMessage);
}, [isInspectorMode]);
const toggleInspectorMode = () => {
const newInspectorMode = !isInspectorMode;
setIsInspectorMode(newInspectorMode);
if (iframeRef.current?.contentWindow) {
iframeRef.current.contentWindow.postMessage(
{
type: 'INSPECTOR_ACTIVATE',
active: newInspectorMode,
},
'*',
);
}
};
return ( return (
<div ref={containerRef} className={`w-full h-full flex flex-col relative`}> <div ref={containerRef} className={`w-full h-full flex flex-col relative`}>
{isPortDropdownOpen && ( {isPortDropdownOpen && (
@@ -697,7 +739,14 @@ export const Preview = memo(() => {
/> />
</> </>
)} )}
<IconButton
icon="i-ph:cursor-click"
onClick={toggleInspectorMode}
className={
isInspectorMode ? 'bg-bolt-elements-background-depth-3 !text-bolt-elements-item-contentAccent' : ''
}
title={isInspectorMode ? 'Disable Element Inspector' : 'Enable Element Inspector'}
/>
<IconButton <IconButton
icon={isFullscreen ? 'i-ph:arrows-in' : 'i-ph:arrows-out'} icon={isFullscreen ? 'i-ph:arrows-in' : 'i-ph:arrows-out'}
onClick={toggleFullscreen} onClick={toggleFullscreen}

View File

@@ -26,6 +26,8 @@ import useViewport from '~/lib/hooks';
import { PushToGitHubDialog } from '~/components/@settings/tabs/connections/components/PushToGitHubDialog'; import { PushToGitHubDialog } from '~/components/@settings/tabs/connections/components/PushToGitHubDialog';
import * as DropdownMenu from '@radix-ui/react-dropdown-menu'; import * as DropdownMenu from '@radix-ui/react-dropdown-menu';
import { usePreviewStore } from '~/lib/stores/previews'; import { usePreviewStore } from '~/lib/stores/previews';
import { chatStore } from '~/lib/stores/chat';
import type { ElementInfo } from './Inspector';
interface WorkspaceProps { interface WorkspaceProps {
chatStarted?: boolean; chatStarted?: boolean;
@@ -35,6 +37,7 @@ interface WorkspaceProps {
gitUrl?: string; gitUrl?: string;
}; };
updateChatMestaData?: (metadata: any) => void; updateChatMestaData?: (metadata: any) => void;
setSelectedElement?: (element: ElementInfo | null) => void;
} }
const viewTransition = { ease: cubicEasingFn }; const viewTransition = { ease: cubicEasingFn };
@@ -278,7 +281,7 @@ const FileModifiedDropdown = memo(
); );
export const Workbench = memo( export const Workbench = memo(
({ chatStarted, isStreaming, actionRunner, metadata, updateChatMestaData }: WorkspaceProps) => { ({ chatStarted, isStreaming, actionRunner, metadata, updateChatMestaData, setSelectedElement }: WorkspaceProps) => {
renderLogger.trace('Workbench'); renderLogger.trace('Workbench');
const [isSyncing, setIsSyncing] = useState(false); const [isSyncing, setIsSyncing] = useState(false);
@@ -294,6 +297,8 @@ export const Workbench = memo(
const unsavedFiles = useStore(workbenchStore.unsavedFiles); const unsavedFiles = useStore(workbenchStore.unsavedFiles);
const files = useStore(workbenchStore.files); const files = useStore(workbenchStore.files);
const selectedView = useStore(workbenchStore.currentView); const selectedView = useStore(workbenchStore.currentView);
const { showChat } = useStore(chatStore);
const canHideChat = showWorkbench || !showChat;
const isSmallViewport = useViewport(1024); const isSmallViewport = useViewport(1024);
@@ -370,7 +375,7 @@ export const Workbench = memo(
> >
<div <div
className={classNames( className={classNames(
'fixed top-[calc(var(--header-height)+1.5rem)] bottom-6 w-[var(--workbench-inner-width)] mr-4 z-0 transition-[left,width] duration-200 bolt-ease-cubic-bezier', 'fixed top-[calc(var(--header-height)+1.2rem)] bottom-6 w-[var(--workbench-inner-width)] z-0 transition-[left,width] duration-200 bolt-ease-cubic-bezier',
{ {
'w-full': isSmallViewport, 'w-full': isSmallViewport,
'left-0': showWorkbench && isSmallViewport, 'left-0': showWorkbench && isSmallViewport,
@@ -379,9 +384,18 @@ export const Workbench = memo(
}, },
)} )}
> >
<div className="absolute inset-0 px-2 lg:px-6"> <div className="absolute inset-0 px-2 lg:px-4">
<div className="h-full flex flex-col bg-bolt-elements-background-depth-2 border border-bolt-elements-borderColor shadow-sm rounded-lg overflow-hidden"> <div className="h-full flex flex-col bg-bolt-elements-background-depth-2 border border-bolt-elements-borderColor shadow-sm rounded-lg overflow-hidden">
<div className="flex items-center px-3 py-2 border-b border-bolt-elements-borderColor gap-1"> <div className="flex items-center px-3 py-2 border-b border-bolt-elements-borderColor gap-1.5">
<button
className={`${showChat ? 'i-ph:sidebar-simple-fill' : 'i-ph:sidebar-simple'} text-lg text-bolt-elements-textSecondary mr-1`}
disabled={!canHideChat || isSmallViewport}
onClick={() => {
if (canHideChat) {
chatStore.setKey('showChat', !showChat);
}
}}
/>
<Slider selected={selectedView} options={sliderOptions} setSelected={setSelectedView} /> <Slider selected={selectedView} options={sliderOptions} setSelected={setSelectedView} />
<div className="ml-auto" /> <div className="ml-auto" />
{selectedView === 'code' && ( {selectedView === 'code' && (
@@ -398,7 +412,7 @@ export const Workbench = memo(
<DropdownMenu.Root> <DropdownMenu.Root>
<DropdownMenu.Trigger className="text-sm flex items-center gap-1 text-bolt-elements-item-contentDefault bg-transparent enabled:hover:text-bolt-elements-item-contentActive rounded-md p-1 enabled:hover:bg-bolt-elements-item-backgroundActive disabled:cursor-not-allowed"> <DropdownMenu.Trigger className="text-sm flex items-center gap-1 text-bolt-elements-item-contentDefault bg-transparent enabled:hover:text-bolt-elements-item-contentActive rounded-md p-1 enabled:hover:bg-bolt-elements-item-backgroundActive disabled:cursor-not-allowed">
<div className="i-ph:box-arrow-up" /> <div className="i-ph:box-arrow-up" />
Sync & Export Sync
</DropdownMenu.Trigger> </DropdownMenu.Trigger>
<DropdownMenu.Content <DropdownMenu.Content
className={classNames( className={classNames(
@@ -412,19 +426,6 @@ export const Workbench = memo(
sideOffset={5} sideOffset={5}
align="end" align="end"
> >
<DropdownMenu.Item
className={classNames(
'cursor-pointer flex items-center w-full px-4 py-2 text-sm text-bolt-elements-textPrimary hover:bg-bolt-elements-item-backgroundActive gap-2 rounded-md group relative',
)}
onClick={() => {
workbenchStore.downloadZip();
}}
>
<div className="flex items-center gap-2">
<div className="i-ph:download-simple"></div>
<span>Download Code</span>
</div>
</DropdownMenu.Item>
<DropdownMenu.Item <DropdownMenu.Item
className={classNames( className={classNames(
'cursor-pointer flex items-center w-full px-4 py-2 text-sm text-bolt-elements-textPrimary hover:bg-bolt-elements-item-backgroundActive gap-2 rounded-md group relative', 'cursor-pointer flex items-center w-full px-4 py-2 text-sm text-bolt-elements-textPrimary hover:bg-bolt-elements-item-backgroundActive gap-2 rounded-md group relative',
@@ -488,7 +489,7 @@ export const Workbench = memo(
<DiffView fileHistory={fileHistory} setFileHistory={setFileHistory} actionRunner={actionRunner} /> <DiffView fileHistory={fileHistory} setFileHistory={setFileHistory} actionRunner={actionRunner} />
</View> </View>
<View initial={{ x: '100%' }} animate={{ x: selectedView === 'preview' ? '0%' : '100%' }}> <View initial={{ x: '100%' }} animate={{ x: selectedView === 'preview' ? '0%' : '100%' }}>
<Preview /> <Preview setSelectedElement={setSelectedElement} />
</View> </View>
</div> </div>
</div> </div>

View File

@@ -9,6 +9,7 @@ import { LLMManager } from '~/lib/modules/llm/manager';
import { createScopedLogger } from '~/utils/logger'; import { createScopedLogger } from '~/utils/logger';
import { createFilesContext, extractPropertiesFromMessage } from './utils'; import { createFilesContext, extractPropertiesFromMessage } from './utils';
import { discussPrompt } from '~/lib/common/prompts/discuss-prompt'; import { discussPrompt } from '~/lib/common/prompts/discuss-prompt';
import type { DesignScheme } from '~/types/design-scheme';
export type Messages = Message[]; export type Messages = Message[];
@@ -38,6 +39,7 @@ export async function streamText(props: {
summary?: string; summary?: string;
messageSliceId?: number; messageSliceId?: number;
chatMode?: 'discuss' | 'build'; chatMode?: 'discuss' | 'build';
designScheme?: DesignScheme;
}) { }) {
const { const {
messages, messages,
@@ -51,6 +53,7 @@ export async function streamText(props: {
contextFiles, contextFiles,
summary, summary,
chatMode, chatMode,
designScheme,
} = props; } = props;
let currentModel = DEFAULT_MODEL; let currentModel = DEFAULT_MODEL;
let currentProvider = DEFAULT_PROVIDER.name; let currentProvider = DEFAULT_PROVIDER.name;
@@ -120,6 +123,7 @@ export async function streamText(props: {
cwd: WORK_DIR, cwd: WORK_DIR,
allowedHtmlElements: allowedHTMLElements, allowedHtmlElements: allowedHTMLElements,
modificationTagName: MODIFICATIONS_TAG_NAME, modificationTagName: MODIFICATIONS_TAG_NAME,
designScheme,
supabase: { supabase: {
isConnected: options?.supabaseConnection?.isConnected || false, isConnected: options?.supabaseConnection?.isConnected || false,
hasSelectedProject: options?.supabaseConnection?.hasSelectedProject || false, hasSelectedProject: options?.supabaseConnection?.hasSelectedProject || false,

View File

@@ -1,11 +1,13 @@
import { getSystemPrompt } from './prompts/prompts'; import { getSystemPrompt } from './prompts/prompts';
import optimized from './prompts/optimized'; import optimized from './prompts/optimized';
import { getFineTunedPrompt } from './prompts/new-prompt'; import { getFineTunedPrompt } from './prompts/new-prompt';
import type { DesignScheme } from '~/types/design-scheme';
export interface PromptOptions { export interface PromptOptions {
cwd: string; cwd: string;
allowedHtmlElements: string[]; allowedHtmlElements: string[];
modificationTagName: string; modificationTagName: string;
designScheme?: DesignScheme;
supabase?: { supabase?: {
isConnected: boolean; isConnected: boolean;
hasSelectedProject: boolean; hasSelectedProject: boolean;
@@ -28,12 +30,12 @@ export class PromptLibrary {
default: { default: {
label: 'Default Prompt', label: 'Default Prompt',
description: 'This is the battle tested default system Prompt', description: 'This is the battle tested default system Prompt',
get: (options) => getSystemPrompt(options.cwd, options.supabase), get: (options) => getSystemPrompt(options.cwd, options.supabase, options.designScheme),
}, },
enhanced: { enhanced: {
label: 'Fine Tuned Prompt', label: 'Fine Tuned Prompt',
description: 'An fine tuned prompt for better results', description: 'An fine tuned prompt for better results',
get: (options) => getFineTunedPrompt(options.cwd, options.supabase), get: (options) => getFineTunedPrompt(options.cwd, options.supabase, options.designScheme),
}, },
optimized: { optimized: {
label: 'Optimized Prompt (experimental)', label: 'Optimized Prompt (experimental)',

View File

@@ -1,3 +1,4 @@
import type { DesignScheme } from '~/types/design-scheme';
import { WORK_DIR } from '~/utils/constants'; import { WORK_DIR } from '~/utils/constants';
import { allowedHTMLElements } from '~/utils/markdown'; import { allowedHTMLElements } from '~/utils/markdown';
import { stripIndents } from '~/utils/stripIndent'; import { stripIndents } from '~/utils/stripIndent';
@@ -9,6 +10,7 @@ export const getFineTunedPrompt = (
hasSelectedProject: boolean; hasSelectedProject: boolean;
credentials?: { anonKey?: string; supabaseUrl?: string }; credentials?: { anonKey?: string; supabaseUrl?: string };
}, },
designScheme?: DesignScheme,
) => ` ) => `
You are Bolt, an expert AI assistant and exceptional senior software developer with vast knowledge across multiple programming languages, frameworks, and best practices, created by StackBlitz. You are Bolt, an expert AI assistant and exceptional senior software developer with vast knowledge across multiple programming languages, frameworks, and best practices, created by StackBlitz.
@@ -428,6 +430,15 @@ The year is 2025.
- Use CSS Grid and Flexbox for layouts - Use CSS Grid and Flexbox for layouts
- Implement appropriate container queries when needed - Implement appropriate container queries when needed
- Structure mobile-first designs that progressively enhance for larger screens - Structure mobile-first designs that progressively enhance for larger screens
<user_provided_design>
USER PROVIDED DESIGN SCHEME:
- ALWAYS use the user provided design scheme when creating designs ensuring it complies with the professionalism of design instructions we have provided, unless the user specifically requests otherwise.
- Ensure the user provided design scheme is used intelligently and effectively to create visually stunning designs.
FONT: ${JSON.stringify(designScheme?.font)}
COLOR PALETTE: ${JSON.stringify(designScheme?.palette)}
FEATURES: ${JSON.stringify(designScheme?.features)}
</user_provided_design>
</design_instructions> </design_instructions>
<mobile_app_instructions> <mobile_app_instructions>

View File

@@ -1,3 +1,4 @@
import type { DesignScheme } from '~/types/design-scheme';
import { WORK_DIR } from '~/utils/constants'; import { WORK_DIR } from '~/utils/constants';
import { allowedHTMLElements } from '~/utils/markdown'; import { allowedHTMLElements } from '~/utils/markdown';
import { stripIndents } from '~/utils/stripIndent'; import { stripIndents } from '~/utils/stripIndent';
@@ -9,6 +10,7 @@ export const getSystemPrompt = (
hasSelectedProject: boolean; hasSelectedProject: boolean;
credentials?: { anonKey?: string; supabaseUrl?: string }; credentials?: { anonKey?: string; supabaseUrl?: string };
}, },
designScheme?: DesignScheme,
) => ` ) => `
You are Bolt, an expert AI assistant and exceptional senior software developer with vast knowledge across multiple programming languages, frameworks, and best practices. You are Bolt, an expert AI assistant and exceptional senior software developer with vast knowledge across multiple programming languages, frameworks, and best practices.
@@ -424,6 +426,14 @@ You are Bolt, an expert AI assistant and exceptional senior software developer w
- Ensure consistency in design language and interactions throughout. - Ensure consistency in design language and interactions throughout.
- Pay meticulous attention to detail and polish. - Pay meticulous attention to detail and polish.
- Always prioritize user needs and iterate based on feedback. - Always prioritize user needs and iterate based on feedback.
<user_provided_design>
USER PROVIDED DESIGN SCHEME:
- ALWAYS use the user provided design scheme when creating designs ensuring it complies with the professionalism of design instructions below, unless the user specifically requests otherwise.
FONT: ${JSON.stringify(designScheme?.font)}
COLOR PALETTE: ${JSON.stringify(designScheme?.palette)}
FEATURES: ${JSON.stringify(designScheme?.features)}
</user_provided_design>
</design_instructions> </design_instructions>
</artifact_info> </artifact_info>

View File

@@ -133,6 +133,7 @@ function Content({ children, ...props }: StickToBottomContentProps) {
<div {...props} ref={context.contentRef}> <div {...props} ref={context.contentRef}>
{typeof children === 'function' ? children(context) : children} {typeof children === 'function' ? children(context) : children}
</div> </div>
{/* Blur effect overlay */}
</div> </div>
); );
} }

View File

@@ -49,16 +49,14 @@ export function ChatDescription() {
{currentDescription} {currentDescription}
<TooltipProvider> <TooltipProvider>
<WithTooltip tooltip="Rename chat"> <WithTooltip tooltip="Rename chat">
<div className="flex justify-between items-center p-2 rounded-md bg-bolt-elements-item-backgroundAccent ml-2">
<button <button
type="button" type="button"
className="i-ph:pencil-fill scale-110 hover:text-bolt-elements-item-contentAccent" className="ml-2 i-ph:pencil-fill scale-110 hover:text-bolt-elements-item-contentAccent"
onClick={(event) => { onClick={(event) => {
event.preventDefault(); event.preventDefault();
toggleEditMode(); toggleEditMode();
}} }}
/> />
</div>
</WithTooltip> </WithTooltip>
</TooltipProvider> </TooltipProvider>
</> </>

View File

@@ -34,6 +34,10 @@ if (!import.meta.env.SSR) {
const { workbenchStore } = await import('~/lib/stores/workbench'); const { workbenchStore } = await import('~/lib/stores/workbench');
const response = await fetch('/inspector-script.js');
const inspectorScript = await response.text();
await webcontainer.setPreviewScript(inspectorScript);
// Listen for preview errors // Listen for preview errors
webcontainer.on('preview-message', (message) => { webcontainer.on('preview-message', (message) => {
console.log('WebContainer preview message:', message); console.log('WebContainer preview message:', message);

View File

@@ -11,6 +11,7 @@ import type { ContextAnnotation, ProgressAnnotation } from '~/types/context';
import { WORK_DIR } from '~/utils/constants'; import { WORK_DIR } from '~/utils/constants';
import { createSummary } from '~/lib/.server/llm/create-summary'; import { createSummary } from '~/lib/.server/llm/create-summary';
import { extractPropertiesFromMessage } from '~/lib/.server/llm/utils'; import { extractPropertiesFromMessage } from '~/lib/.server/llm/utils';
import type { DesignScheme } from '~/types/design-scheme';
export async function action(args: ActionFunctionArgs) { export async function action(args: ActionFunctionArgs) {
return chatAction(args); return chatAction(args);
@@ -37,12 +38,13 @@ function parseCookies(cookieHeader: string): Record<string, string> {
} }
async function chatAction({ context, request }: ActionFunctionArgs) { async function chatAction({ context, request }: ActionFunctionArgs) {
const { messages, files, promptId, contextOptimization, supabase, chatMode } = await request.json<{ const { messages, files, promptId, contextOptimization, supabase, chatMode, designScheme } = await request.json<{
messages: Messages; messages: Messages;
files: any; files: any;
promptId?: string; promptId?: string;
contextOptimization: boolean; contextOptimization: boolean;
chatMode: 'discuss' | 'build'; chatMode: 'discuss' | 'build';
designScheme?: DesignScheme;
supabase?: { supabase?: {
isConnected: boolean; isConnected: boolean;
hasSelectedProject: boolean; hasSelectedProject: boolean;
@@ -250,6 +252,7 @@ async function chatAction({ context, request }: ActionFunctionArgs) {
contextOptimization, contextOptimization,
contextFiles: filteredFiles, contextFiles: filteredFiles,
chatMode, chatMode,
designScheme,
summary, summary,
messageSliceId, messageSliceId,
}); });
@@ -290,6 +293,7 @@ async function chatAction({ context, request }: ActionFunctionArgs) {
contextOptimization, contextOptimization,
contextFiles: filteredFiles, contextFiles: filteredFiles,
chatMode, chatMode,
designScheme,
summary, summary,
messageSliceId, messageSliceId,
}); });

View File

@@ -11,6 +11,7 @@ html,
body { body {
height: 100%; height: 100%;
width: 100%; width: 100%;
background-color: var(--bolt-elements-bg-depth-1);
} }
:root { :root {

View File

@@ -221,8 +221,8 @@
*/ */
:root { :root {
--header-height: 54px; --header-height: 54px;
--chat-max-width: 35rem; --chat-max-width: 33rem;
--chat-min-width: 575px; --chat-min-width: 533px;
--workbench-width: min(calc(100% - var(--chat-min-width)), 2536px); --workbench-width: min(calc(100% - var(--chat-min-width)), 2536px);
--workbench-inner-width: var(--workbench-width); --workbench-inner-width: var(--workbench-width);
--workbench-left: calc(100% - var(--workbench-width)); --workbench-left: calc(100% - var(--workbench-width));

View File

@@ -0,0 +1,93 @@
export interface DesignScheme {
palette: { [key: string]: string }; // Changed from string[] to object
features: string[];
font: string[];
}
export const defaultDesignScheme: DesignScheme = {
palette: {
primary: '#9E7FFF',
secondary: '#38bdf8',
accent: '#f472b6',
background: '#171717',
surface: '#262626',
text: '#FFFFFF',
textSecondary: '#A3A3A3',
border: '#2F2F2F',
success: '#10b981',
warning: '#f59e0b',
error: '#ef4444',
},
features: ['rounded'],
font: ['sans-serif'],
};
export const paletteRoles = [
{
key: 'primary',
label: 'Primary',
description: 'Main brand color - use for primary buttons, active links, and key interactive elements',
},
{
key: 'secondary',
label: 'Secondary',
description: 'Supporting brand color - use for secondary buttons, inactive states, and complementary elements',
},
{
key: 'accent',
label: 'Accent',
description: 'Highlight color - use for badges, notifications, focus states, and call-to-action elements',
},
{
key: 'background',
label: 'Background',
description: 'Page backdrop - use for the main application/website background behind all content',
},
{
key: 'surface',
label: 'Surface',
description: 'Elevated content areas - use for cards, modals, dropdowns, and panels that sit above the background',
},
{ key: 'text', label: 'Text', description: 'Primary text - use for headings, body text, and main readable content' },
{
key: 'textSecondary',
label: 'Text Secondary',
description: 'Muted text - use for captions, placeholders, timestamps, and less important information',
},
{
key: 'border',
label: 'Border',
description: 'Separators - use for input borders, dividers, table lines, and element outlines',
},
{
key: 'success',
label: 'Success',
description: 'Positive feedback - use for success messages, completed states, and positive indicators',
},
{
key: 'warning',
label: 'Warning',
description: 'Caution alerts - use for warning messages, pending states, and attention-needed indicators',
},
{
key: 'error',
label: 'Error',
description: 'Error states - use for error messages, failed states, and destructive action indicators',
},
];
export const designFeatures = [
{ key: 'rounded', label: 'Rounded Corners' },
{ key: 'border', label: 'Subtle Border' },
{ key: 'gradient', label: 'Gradient Accent' },
{ key: 'shadow', label: 'Soft Shadow' },
{ key: 'frosted-glass', label: 'Frosted Glass' },
];
export const designFonts = [
{ key: 'sans-serif', label: 'Sans Serif', preview: 'Aa' },
{ key: 'serif', label: 'Serif', preview: 'Aa' },
{ key: 'monospace', label: 'Monospace', preview: 'Aa' },
{ key: 'cursive', label: 'Cursive', preview: 'Aa' },
{ key: 'fantasy', label: 'Fantasy', preview: 'Aa' },
];

View File

@@ -56,6 +56,7 @@ export const allowedHTMLElements = [
'ul', 'ul',
'var', 'var',
'think', 'think',
'header',
]; ];
// Add custom rehype plugin // Add custom rehype plugin
@@ -85,7 +86,7 @@ const rehypeSanitizeOptions: RehypeSanitizeOptions = {
div: [ div: [
...(defaultSchema.attributes?.div ?? []), ...(defaultSchema.attributes?.div ?? []),
'data*', 'data*',
['className', '__boltArtifact__', '__boltThought__', '__boltQuickAction'], ['className', '__boltArtifact__', '__boltThought__', '__boltQuickAction', '__boltSelectedElement__'],
// ['className', '__boltThought__'] // ['className', '__boltThought__']
], ],

292
public/inspector-script.js Normal file
View File

@@ -0,0 +1,292 @@
(function() {
let isInspectorActive = false;
let inspectorStyle = null;
let currentHighlight = null;
// Function to get relevant styles
function getRelevantStyles(element) {
const computedStyles = window.getComputedStyle(element);
const relevantProps = [
'display', 'position', 'width', 'height', 'margin', 'padding',
'border', 'background', 'color', 'font-size', 'font-family',
'text-align', 'flex-direction', 'justify-content', 'align-items'
];
const styles = {};
relevantProps.forEach(prop => {
const value = computedStyles.getPropertyValue(prop);
if (value) styles[prop] = value;
});
return styles;
}
// Function to create a readable element selector
function createReadableSelector(element) {
let selector = element.tagName.toLowerCase();
// Add ID if present
if (element.id) {
selector += `#${element.id}`;
}
// Add classes if present
let className = '';
if (element.className) {
if (typeof element.className === 'string') {
className = element.className;
} else if (element.className.baseVal !== undefined) {
className = element.className.baseVal;
} else {
className = element.className.toString();
}
if (className.trim()) {
const classes = className.trim().split(/\s+/).slice(0, 3); // Limit to first 3 classes
selector += `.${classes.join('.')}`;
}
}
return selector;
}
// Function to create element display text
function createElementDisplayText(element) {
const tagName = element.tagName.toLowerCase();
let displayText = `<${tagName}`;
// Add ID attribute
if (element.id) {
displayText += ` id="${element.id}"`;
}
// Add class attribute (limit to first 3 classes for readability)
let className = '';
if (element.className) {
if (typeof element.className === 'string') {
className = element.className;
} else if (element.className.baseVal !== undefined) {
className = element.className.baseVal;
} else {
className = element.className.toString();
}
if (className.trim()) {
const classes = className.trim().split(/\s+/);
const displayClasses = classes.length > 3 ?
classes.slice(0, 3).join(' ') + '...' :
classes.join(' ');
displayText += ` class="${displayClasses}"`;
}
}
// Add other important attributes
const importantAttrs = ['type', 'name', 'href', 'src', 'alt', 'title'];
importantAttrs.forEach(attr => {
const value = element.getAttribute(attr);
if (value) {
const truncatedValue = value.length > 30 ? value.substring(0, 30) + '...' : value;
displayText += ` ${attr}="${truncatedValue}"`;
}
});
displayText += '>';
// Add text content preview for certain elements
const textElements = ['span', 'p', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'button', 'a', 'label'];
if (textElements.includes(tagName) && element.textContent) {
const textPreview = element.textContent.trim().substring(0, 50);
if (textPreview) {
displayText += textPreview.length < element.textContent.trim().length ?
textPreview + '...' : textPreview;
}
}
displayText += `</${tagName}>`;
return displayText;
}
// Function to create element info
function createElementInfo(element) {
const rect = element.getBoundingClientRect();
return {
tagName: element.tagName,
className: getElementClassName(element),
id: element.id || '',
textContent: element.textContent?.slice(0, 100) || '',
styles: getRelevantStyles(element),
rect: {
x: rect.x,
y: rect.y,
width: rect.width,
height: rect.height,
top: rect.top,
left: rect.left
},
// Add new readable formats
selector: createReadableSelector(element),
displayText: createElementDisplayText(element),
elementPath: getElementPath(element)
};
}
// Helper function to get element class name consistently
function getElementClassName(element) {
if (!element.className) return '';
if (typeof element.className === 'string') {
return element.className;
} else if (element.className.baseVal !== undefined) {
return element.className.baseVal;
} else {
return element.className.toString();
}
}
// Function to get element path (breadcrumb)
function getElementPath(element) {
const path = [];
let current = element;
while (current && current !== document.body && current !== document.documentElement) {
let pathSegment = current.tagName.toLowerCase();
if (current.id) {
pathSegment += `#${current.id}`;
} else if (current.className) {
const className = getElementClassName(current);
if (className.trim()) {
const firstClass = className.trim().split(/\s+/)[0];
pathSegment += `.${firstClass}`;
}
}
path.unshift(pathSegment);
current = current.parentElement;
// Limit path length
if (path.length >= 5) break;
}
return path.join(' > ');
}
// Event handlers
function handleMouseMove(e) {
if (!isInspectorActive) return;
const target = e.target;
if (!target || target === document.body || target === document.documentElement) return;
// Remove previous highlight
if (currentHighlight) {
currentHighlight.classList.remove('inspector-highlight');
}
// Add highlight to current element
target.classList.add('inspector-highlight');
currentHighlight = target;
const elementInfo = createElementInfo(target);
// Send message to parent
window.parent.postMessage({
type: 'INSPECTOR_HOVER',
elementInfo: elementInfo
}, '*');
}
function handleClick(e) {
if (!isInspectorActive) return;
e.preventDefault();
e.stopPropagation();
const target = e.target;
if (!target || target === document.body || target === document.documentElement) return;
const elementInfo = createElementInfo(target);
// Send message to parent
window.parent.postMessage({
type: 'INSPECTOR_CLICK',
elementInfo: elementInfo
}, '*');
}
function handleMouseLeave() {
if (!isInspectorActive) return;
// Remove highlight
if (currentHighlight) {
currentHighlight.classList.remove('inspector-highlight');
currentHighlight = null;
}
// Send message to parent
window.parent.postMessage({
type: 'INSPECTOR_LEAVE'
}, '*');
}
// Function to activate/deactivate inspector
function setInspectorActive(active) {
isInspectorActive = active;
if (active) {
// Add inspector styles
if (!inspectorStyle) {
inspectorStyle = document.createElement('style');
inspectorStyle.textContent = `
.inspector-active * {
cursor: crosshair !important;
}
.inspector-highlight {
outline: 2px solid #3b82f6 !important;
outline-offset: -2px !important;
background-color: rgba(59, 130, 246, 0.1) !important;
}
`;
document.head.appendChild(inspectorStyle);
}
document.body.classList.add('inspector-active');
// Add event listeners
document.addEventListener('mousemove', handleMouseMove, true);
document.addEventListener('click', handleClick, true);
document.addEventListener('mouseleave', handleMouseLeave, true);
} else {
document.body.classList.remove('inspector-active');
// Remove highlight
if (currentHighlight) {
currentHighlight.classList.remove('inspector-highlight');
currentHighlight = null;
}
// Remove event listeners
document.removeEventListener('mousemove', handleMouseMove, true);
document.removeEventListener('click', handleClick, true);
document.removeEventListener('mouseleave', handleMouseLeave, true);
// Remove styles
if (inspectorStyle) {
inspectorStyle.remove();
inspectorStyle = null;
}
}
}
// Listen for messages from parent
window.addEventListener('message', function(event) {
if (event.data.type === 'INSPECTOR_ACTIVATE') {
setInspectorActive(event.data.active);
}
});
// Auto-inject if inspector is already active
window.parent.postMessage({ type: 'INSPECTOR_READY' }, '*');
})();