feat: add element inspector with chat integration

- Implement element inspector tool for preview iframe with hover/click detection
- Add inspector panel UI to display element details and styles
- Integrate selected elements into chat messages for reference
- Style improvements for chat messages and scroll behavior
- Add inspector script injection to preview iframe
- Support element selection and context in chat prompts
-Redesign Messgaes, Workbench and Header for a more refined look allowing more workspace in view
This commit is contained in:
KevIsDev
2025-05-30 13:16:53 +01:00
parent 6c4b4204e3
commit 5838d7121a
20 changed files with 1114 additions and 333 deletions

View File

@@ -83,78 +83,76 @@ export const AssistantMessage = memo(
return (
<div className="overflow-hidden w-full">
<>
<div className=" flex gap-2 items-center text-sm text-bolt-elements-textSecondary mb-2">
{(codeContext || chatSummary) && (
<Popover side="right" align="start" trigger={<div className="i-ph:info" />}>
{chatSummary && (
<div className="max-w-chat">
<div className="summary max-h-96 flex flex-col">
<h2 className="border border-bolt-elements-borderColor rounded-md p4">Summary</h2>
<div style={{ zoom: 0.7 }} className="overflow-y-auto m4">
<Markdown>{chatSummary}</Markdown>
<div className=" flex gap-2 items-center text-sm text-bolt-elements-textSecondary mb-2">
{(codeContext || chatSummary) && (
<Popover side="right" align="start" trigger={<div className="i-ph:info" />}>
{chatSummary && (
<div className="max-w-chat">
<div className="summary max-h-96 flex flex-col">
<h2 className="border border-bolt-elements-borderColor rounded-md p4">Summary</h2>
<div style={{ zoom: 0.7 }} className="overflow-y-auto m4">
<Markdown>{chatSummary}</Markdown>
</div>
</div>
{codeContext && (
<div className="code-context flex flex-col p4 border border-bolt-elements-borderColor rounded-md">
<h2>Context</h2>
<div className="flex gap-4 mt-4 bolt" style={{ zoom: 0.6 }}>
{codeContext.map((x) => {
const normalized = normalizedFilePath(x);
return (
<Fragment key={normalized}>
<code
className="bg-bolt-elements-artifacts-inlineCode-background text-bolt-elements-artifacts-inlineCode-text px-1.5 py-1 rounded-md text-bolt-elements-item-contentAccent hover:underline cursor-pointer"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
openArtifactInWorkbench(normalized);
}}
>
{normalized}
</code>
</Fragment>
);
})}
</div>
</div>
{codeContext && (
<div className="code-context flex flex-col p4 border border-bolt-elements-borderColor rounded-md">
<h2>Context</h2>
<div className="flex gap-4 mt-4 bolt" style={{ zoom: 0.6 }}>
{codeContext.map((x) => {
const normalized = normalizedFilePath(x);
return (
<Fragment key={normalized}>
<code
className="bg-bolt-elements-artifacts-inlineCode-background text-bolt-elements-artifacts-inlineCode-text px-1.5 py-1 rounded-md text-bolt-elements-item-contentAccent hover:underline cursor-pointer"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
openArtifactInWorkbench(normalized);
}}
>
{normalized}
</code>
</Fragment>
);
})}
</div>
</div>
)}
</div>
)}
<div className="context"></div>
</Popover>
)}
</div>
)}
<div className="context"></div>
</Popover>
)}
<div className="flex w-full items-center justify-between">
{usage && (
<div>
Tokens: {usage.totalTokens} (prompt: {usage.promptTokens}, completion: {usage.completionTokens})
</div>
)}
{(onRewind || onFork) && messageId && (
<div className="flex gap-2 flex-col lg:flex-row ml-auto">
{onRewind && (
<WithTooltip tooltip="Revert to this message">
<button
onClick={() => onRewind(messageId)}
key="i-ph:arrow-u-up-left"
className="i-ph:arrow-u-up-left text-xl text-bolt-elements-textSecondary hover:text-bolt-elements-textPrimary transition-colors"
/>
</WithTooltip>
)}
{onFork && (
<WithTooltip tooltip="Fork chat from this message">
<button
onClick={() => onFork(messageId)}
key="i-ph:git-fork"
className="i-ph:git-fork text-xl text-bolt-elements-textSecondary hover:text-bolt-elements-textPrimary transition-colors"
/>
</WithTooltip>
)}
</div>
)}
<div className="flex w-full items-center justify-between">
{usage && (
<div>
Tokens: {usage.totalTokens} (prompt: {usage.promptTokens}, completion: {usage.completionTokens})
</div>
)}
{(onRewind || onFork) && messageId && (
<div className="flex gap-2 flex-col lg:flex-row ml-auto">
{onRewind && (
<WithTooltip tooltip="Revert to this message">
<button
onClick={() => onRewind(messageId)}
key="i-ph:arrow-u-up-left"
className="i-ph:arrow-u-up-left text-xl text-bolt-elements-textSecondary hover:text-bolt-elements-textPrimary transition-colors"
/>
</WithTooltip>
)}
{onFork && (
<WithTooltip tooltip="Fork chat from this message">
<button
onClick={() => onFork(messageId)}
key="i-ph:git-fork"
className="i-ph:git-fork text-xl text-bolt-elements-textSecondary hover:text-bolt-elements-textPrimary transition-colors"
/>
</WithTooltip>
)}
</div>
)}
</div>
</div>
</>
</div>
<Markdown append={append} chatMode={chatMode} setChatMode={setChatMode} model={model} provider={provider} html>
{content}
</Markdown>

View File

@@ -32,6 +32,7 @@ import { useStore } from '@nanostores/react';
import { StickToBottom, useStickToBottomContext } from '~/lib/hooks';
import { ChatBox } from './ChatBox';
import type { DesignScheme } from '~/types/design-scheme';
import type { ElementInfo } from '~/components/workbench/Inspector';
const TEXTAREA_MIN_HEIGHT = 76;
@@ -76,6 +77,8 @@ interface BaseChatProps {
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>(
@@ -119,6 +122,8 @@ export const BaseChat = React.forwardRef<HTMLDivElement, BaseChatProps>(
append,
designScheme,
setDesignScheme,
selectedElement,
setSelectedElement,
},
ref,
) => {
@@ -258,6 +263,7 @@ export const BaseChat = React.forwardRef<HTMLDivElement, BaseChatProps>(
const handleSendMessage = (event: React.UIEvent, messageInput?: string) => {
if (sendMessage) {
sendMessage(event, messageInput);
setSelectedElement?.(null);
if (recognition) {
recognition.abort(); // Stop current recognition
@@ -353,7 +359,7 @@ export const BaseChat = React.forwardRef<HTMLDivElement, BaseChatProps>(
resize="smooth"
initial="smooth"
>
<StickToBottom.Content className="flex flex-col gap-4">
<StickToBottom.Content className="flex flex-col gap-4 relative ">
<ClientOnly>
{() => {
return chatStarted ? (
@@ -370,6 +376,7 @@ export const BaseChat = React.forwardRef<HTMLDivElement, BaseChatProps>(
) : null;
}}
</ClientOnly>
<ScrollToBottom />
</StickToBottom.Content>
<div
className={classNames('my-auto flex flex-col gap-2 w-full max-w-chat mx-auto z-prompt mb-6', {
@@ -408,7 +415,6 @@ export const BaseChat = React.forwardRef<HTMLDivElement, BaseChatProps>(
/>
)}
</div>
<ScrollToBottom />
{progressAnnotations && <ProgressCompilation data={progressAnnotations} />}
<ChatBox
isModelSettingsCollapsed={isModelSettingsCollapsed}
@@ -449,6 +455,8 @@ export const BaseChat = React.forwardRef<HTMLDivElement, BaseChatProps>(
setChatMode={setChatMode}
designScheme={designScheme}
setDesignScheme={setDesignScheme}
selectedElement={selectedElement}
setSelectedElement={setSelectedElement}
/>
</div>
</StickToBottom>
@@ -479,6 +487,7 @@ export const BaseChat = React.forwardRef<HTMLDivElement, BaseChatProps>(
actionRunner={actionRunner ?? ({} as ActionRunner)}
chatStarted={chatStarted}
isStreaming={isStreaming}
setSelectedElement={setSelectedElement}
/>
)}
</ClientOnly>
@@ -495,13 +504,16 @@ function ScrollToBottom() {
return (
!isAtBottom && (
<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"
onClick={() => scrollToBottom()}
>
Go to last message
<span className="i-ph:arrow-down animate-bounce" />
</button>
<>
<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
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()}
>
Go to last message
<span className="i-ph:arrow-down animate-bounce" />
</button>
</>
)
);
}

View File

@@ -28,6 +28,7 @@ import { streamingState } from '~/lib/stores/streaming';
import { filesToArtifacts } from '~/utils/fileUtils';
import { supabaseConnection } from '~/lib/stores/supabase';
import { defaultDesignScheme, type DesignScheme } from '~/types/design-scheme';
import type { ElementInfo } from '~/components/workbench/Inspector';
const toastAnimation = cssTransition({
enter: 'animated fadeInRight',
@@ -126,9 +127,6 @@ export const ChatImpl = memo(
const [fakeLoading, setFakeLoading] = useState(false);
const files = useStore(workbenchStore.files);
const [designScheme, setDesignScheme] = useState<DesignScheme>(defaultDesignScheme);
console.log(designScheme);
const actionAlert = useStore(workbenchStore.alert);
const deployAlert = useStore(workbenchStore.deployAlert);
const supabaseConn = useStore(supabaseConnection); // Add this line to get Supabase connection
@@ -137,7 +135,6 @@ export const ChatImpl = memo(
);
const supabaseAlert = useStore(workbenchStore.supabaseAlert);
const { activeProviders, promptId, autoSelectTemplate, contextOptimizationEnabled } = useSettings();
const [model, setModel] = useState(() => {
const savedModel = Cookies.get('selectedModel');
return savedModel || DEFAULT_MODEL;
@@ -146,14 +143,11 @@ export const ChatImpl = memo(
const savedProvider = Cookies.get('selectedProvider');
return (PROVIDER_LIST.find((p) => p.name === savedProvider) || DEFAULT_PROVIDER) as ProviderInfo;
});
const { showChat } = useStore(chatStore);
const [animationScope, animate] = useAnimate();
const [apiKeys, setApiKeys] = useState<Record<string, string>>({});
const [chatMode, setChatMode] = useState<'discuss' | 'build'>('build');
const [selectedElement, setSelectedElement] = useState<ElementInfo | null>(null);
const {
messages,
isLoading,
@@ -318,8 +312,14 @@ export const ChatImpl = memo(
return;
}
// If no locked items, proceed normally with the original message
const finalMessageContent = messageContent;
let 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();
@@ -577,6 +577,8 @@ export const ChatImpl = memo(
append={append}
designScheme={designScheme}
setDesignScheme={setDesignScheme}
selectedElement={selectedElement}
setSelectedElement={setSelectedElement}
/>
);
},

View File

@@ -17,6 +17,7 @@ import styles from './BaseChat.module.scss';
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 {
isModelSettingsCollapsed: boolean;
@@ -57,6 +58,8 @@ interface ChatBoxProps {
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) => {
@@ -146,6 +149,22 @@ export const ChatBox: React.FC<ChatBoxProps> = (props) => {
/>
)}
</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
className={classNames('relative shadow-xs border border-bolt-elements-borderColor backdrop-blur rounded-lg')}
>

View File

@@ -42,6 +42,42 @@ export const Markdown = memo(
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__')) {
return <ThoughtBox title="Thought process">{children}</ThoughtBox>;
}
@@ -110,7 +146,12 @@ export const Markdown = memo(
} else if (type === 'message' && append) {
append({
id: `quick-action-message-${Date.now()}`,
content: `[Model: ${model}]\n\n[Provider: ${provider?.name}]\n\n${message}`,
content: [
{
type: 'text',
text: `[Model: ${model}]\n\n[Provider: ${provider?.name}]\n\n${message}`,
},
] as any, // Type assertion to bypass compiler check
role: 'user',
});
console.log('Message appended:', message);
@@ -118,7 +159,12 @@ export const Markdown = memo(
setChatMode('build');
append({
id: `quick-action-implement-${Date.now()}`,
content: `[Model: ${model}]\n\n[Provider: ${provider?.name}]\n\n${message}`,
content: [
{
type: 'text',
text: `[Model: ${model}]\n\n[Provider: ${provider?.name}]\n\n${message}`,
},
] as any, // Type assertion to bypass compiler check
role: 'user',
});
} else if (type === 'link' && typeof href === 'string') {

View File

@@ -7,8 +7,6 @@ import { useLocation } from '@remix-run/react';
import { db, chatId } from '~/lib/persistence/useChatHistory';
import { forkChat } from '~/lib/persistence/db';
import { toast } from 'react-toastify';
import { useStore } from '@nanostores/react';
import { profileStore } from '~/lib/stores/profile';
import { forwardRef } from 'react';
import type { ForwardedRef } from 'react';
import type { ProviderInfo } from '~/types/model';
@@ -29,7 +27,6 @@ export const Messages = forwardRef<HTMLDivElement, MessagesProps>(
(props: MessagesProps, ref: ForwardedRef<HTMLDivElement> | undefined) => {
const { id, isStreaming = false, messages = [] } = props;
const location = useLocation();
const profile = useStore(profileStore);
const handleRewind = (messageId: string) => {
const searchParams = new URLSearchParams(location.search);
@@ -58,7 +55,6 @@ export const Messages = forwardRef<HTMLDivElement, MessagesProps>(
const { role, content, id: messageId, annotations } = message;
const isUserMessage = role === 'user';
const isFirst = index === 0;
const isLast = index === messages.length - 1;
const isHidden = annotations?.includes('hidden');
if (isHidden) {
@@ -68,28 +64,10 @@ export const Messages = forwardRef<HTMLDivElement, MessagesProps>(
return (
<div
key={index}
className={classNames('flex gap-4 p-3 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,
className={classNames('flex gap-4 py-3 w-full rounded-lg', {
'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">
{isUserMessage ? (
<UserMessage content={content} />

View File

@@ -4,6 +4,8 @@
*/
import { MODEL_REGEX, PROVIDER_REGEX } from '~/utils/constants';
import { Markdown } from './Markdown';
import { useStore } from '@nanostores/react';
import { profileStore } from '~/lib/stores/profile';
interface UserMessageProps {
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 textContent = stripMetadata(textItem?.text || '');
const images = content.filter((item) => item.type === 'image' && item.image);
const profile = useStore(profileStore);
return (
<div className="overflow-hidden flex items-center">
<div className="flex flex-col gap-4">
<div className="overflow-hidden flex flex-col gap-3 items-center ">
<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-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>}
{images.map((item, index) => (
<img