feat: refactor layout and introduce workspace panel and fix some bugs

This commit is contained in:
Dominic Elm 2024-07-12 17:25:41 +02:00
parent 5fa2ee53cc
commit ab9d59a30d
16 changed files with 297 additions and 211 deletions

View File

@ -2,7 +2,7 @@ import { IconButton } from './ui/IconButton';
export function Header() { export function Header() {
return ( return (
<header className="flex items-center bg-white p-4 border-b border-gray-200"> <header className="flex items-center bg-white p-4 border-b border-gray-200 h-[var(--header-height)]">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<div className="text-2xl font-semibold text-accent">Bolt</div> <div className="text-2xl font-semibold text-accent">Bolt</div>
</div> </div>

View File

@ -3,17 +3,22 @@ import { workspaceStore } from '~/lib/stores/workspace';
interface ArtifactProps { interface ArtifactProps {
messageId: string; messageId: string;
onClick?: () => void;
} }
export function Artifact({ messageId, onClick }: ArtifactProps) { export function Artifact({ messageId }: ArtifactProps) {
const artifacts = useStore(workspaceStore.artifacts); const artifacts = useStore(workspaceStore.artifacts);
const artifact = artifacts[messageId]; const artifact = artifacts[messageId];
return ( return (
<button className="flex border rounded-lg overflow-hidden items-stretch bg-gray-50/25 w-full" onClick={onClick}> <button
<div className="border-r flex items-center px-6 bg-gray-50"> className="flex border rounded-lg overflow-hidden items-stretch bg-gray-50/25 w-full"
onClick={() => {
const showWorkspace = workspaceStore.showWorkspace.get();
workspaceStore.showWorkspace.set(!showWorkspace);
}}
>
<div className="border-r flex items-center px-6 bg-gray-100/50">
{!artifact?.closed ? ( {!artifact?.closed ? (
<div className="i-svg-spinners:90-ring-with-bg scale-130"></div> <div className="i-svg-spinners:90-ring-with-bg scale-130"></div>
) : ( ) : (

View File

@ -1,8 +1,11 @@
import type { Message } from 'ai';
import type { LegacyRef } from 'react'; import type { LegacyRef } from 'react';
import React from 'react'; import React from 'react';
import { ClientOnly } from 'remix-utils/client-only'; import { ClientOnly } from 'remix-utils/client-only';
import { IconButton } from '~/components/ui/IconButton'; import { IconButton } from '~/components/ui/IconButton';
import { Workspace } from '~/components/workspace/Workspace.client';
import { classNames } from '~/utils/classNames'; import { classNames } from '~/utils/classNames';
import { Messages } from './Messages.client';
import { SendButton } from './SendButton.client'; import { SendButton } from './SendButton.client';
interface BaseChatProps { interface BaseChatProps {
@ -10,6 +13,8 @@ interface BaseChatProps {
messagesSlot?: React.ReactNode; messagesSlot?: React.ReactNode;
workspaceSlot?: React.ReactNode; workspaceSlot?: React.ReactNode;
chatStarted?: boolean; chatStarted?: boolean;
isStreaming?: boolean;
messages?: Message[];
enhancingPrompt?: boolean; enhancingPrompt?: boolean;
promptEnhanced?: boolean; promptEnhanced?: boolean;
input?: string; input?: string;
@ -27,10 +32,10 @@ export const BaseChat = React.forwardRef<HTMLDivElement, BaseChatProps>(
{ {
textareaRef, textareaRef,
chatStarted = false, chatStarted = false,
isStreaming = false,
enhancingPrompt = false, enhancingPrompt = false,
promptEnhanced = false, promptEnhanced = false,
messagesSlot, messages,
workspaceSlot,
input = '', input = '',
sendMessage, sendMessage,
handleInputChange, handleInputChange,
@ -41,14 +46,14 @@ export const BaseChat = React.forwardRef<HTMLDivElement, BaseChatProps>(
const TEXTAREA_MAX_HEIGHT = chatStarted ? 400 : 200; const TEXTAREA_MAX_HEIGHT = chatStarted ? 400 : 200;
return ( return (
<div ref={ref} className="h-full flex w-full overflow-scroll px-6"> <div ref={ref} className="relative flex h-full w-full overflow-hidden ">
<div className="flex flex-col items-center w-full h-full"> <div className="flex overflow-scroll w-full h-full">
<div id="chat" className="w-full"> <div id="chat" className="flex flex-col w-full h-full px-6">
{!chatStarted && ( {!chatStarted && (
<div id="intro" className="mt-[20vh] mb-14 max-w-2xl mx-auto"> <div id="intro" className="mt-[20vh] mb-14 max-w-3xl mx-auto">
<h2 className="text-4xl text-center font-bold text-slate-800 mb-2">Where ideas begin.</h2> <h2 className="text-4xl text-center font-bold text-slate-800 mb-2">Where ideas begin.</h2>
<p className="mb-14 text-center">Bring ideas to life in seconds or get help on existing projects.</p> <p className="mb-14 text-center">Bring ideas to life in seconds or get help on existing projects.</p>
<div className="grid max-md:grid-cols-[repeat(2,1fr)] md:grid-cols-[repeat(2,minmax(200px,1fr))] gap-4"> <div className="grid max-md:grid-cols-[repeat(1,1fr)] md:grid-cols-[repeat(2,minmax(300px,1fr))] gap-4">
{EXAMPLES.map((suggestion, index) => ( {EXAMPLES.map((suggestion, index) => (
<button key={index} className="p-4 rounded-lg shadow-xs bg-white border border-gray-200 text-left"> <button key={index} className="p-4 rounded-lg shadow-xs bg-white border border-gray-200 text-left">
{suggestion.text} {suggestion.text}
@ -57,20 +62,29 @@ export const BaseChat = React.forwardRef<HTMLDivElement, BaseChatProps>(
</div> </div>
</div> </div>
)} )}
{messagesSlot}
</div>
<div <div
className={classNames('w-full md:max-w-[720px] mx-auto', { className={classNames('pt-10', {
'fixed bg-bolt-elements-app-backgroundColor bottom-0': chatStarted, 'h-full flex flex-col': chatStarted,
})}
>
<ClientOnly>
{() => {
return chatStarted ? (
<Messages
className="flex flex-col w-full flex-1 max-w-3xl px-4 pb-10 mx-auto z-1"
messages={messages}
isStreaming={isStreaming}
/>
) : null;
}}
</ClientOnly>
<div
className={classNames('relative w-full max-w-3xl md:mx-auto z-2', {
'sticky bottom-0 bg-bolt-elements-app-backgroundColor': chatStarted,
})} })}
> >
<div <div
className={classNames( className={classNames('shadow-sm mb-6 border border-gray-200 bg-white rounded-lg overflow-hidden')}
'relative shadow-sm border border-gray-200 md:mb-6 bg-white rounded-lg overflow-hidden',
{
'max-md:rounded-none max-md:border-x-none': chatStarted,
},
)}
> >
<textarea <textarea
ref={textareaRef} ref={textareaRef}
@ -102,6 +116,7 @@ export const BaseChat = React.forwardRef<HTMLDivElement, BaseChatProps>(
<div className="flex gap-1 items-center"> <div className="flex gap-1 items-center">
<IconButton icon="i-ph:microphone-duotone" className="-ml-1" /> <IconButton icon="i-ph:microphone-duotone" className="-ml-1" />
<IconButton icon="i-ph:plus-circle-duotone" /> <IconButton icon="i-ph:plus-circle-duotone" />
<IconButton icon="i-ph:pencil-simple-duotone" />
<IconButton <IconButton
disabled={input.length === 0 || enhancingPrompt} disabled={input.length === 0 || enhancingPrompt}
className={classNames({ className={classNames({
@ -133,7 +148,9 @@ export const BaseChat = React.forwardRef<HTMLDivElement, BaseChatProps>(
</div> </div>
</div> </div>
</div> </div>
{workspaceSlot} </div>
<ClientOnly>{() => <Workspace chatStarted={chatStarted} />}</ClientOnly>
</div>
</div> </div>
); );
}, },

View File

@ -1,13 +1,12 @@
import { useChat } from 'ai/react'; import { useChat } from 'ai/react';
import { cubicBezier, useAnimate } from 'framer-motion'; import { useAnimate } from 'framer-motion';
import { useEffect, useRef, useState } from 'react'; import { useEffect, useRef, useState } from 'react';
import { useMessageParser, usePromptEnhancer } from '~/lib/hooks'; import { useMessageParser, usePromptEnhancer } from '~/lib/hooks';
import { cubicEasingFn } from '~/utils/easings';
import { createScopedLogger } from '~/utils/logger'; import { createScopedLogger } from '~/utils/logger';
import { BaseChat } from './BaseChat'; import { BaseChat } from './BaseChat';
import { Messages } from './Messages';
const logger = createScopedLogger('Chat'); const logger = createScopedLogger('Chat');
const customEasingFn = cubicBezier(0.4, 0, 0.2, 1);
export function Chat() { export function Chat() {
const textareaRef = useRef<HTMLTextAreaElement>(null); const textareaRef = useRef<HTMLTextAreaElement>(null);
@ -61,10 +60,7 @@ export function Chat() {
return; return;
} }
await Promise.all([ await animate('#intro', { opacity: 0, flex: 1 }, { duration: 0.2, ease: cubicEasingFn });
animate('#chat', { height: '100%' }, { duration: 0.3, ease: customEasingFn }),
animate('#intro', { opacity: 0, display: 'none' }, { duration: 0.15, ease: customEasingFn }),
]);
setChatStarted(true); setChatStarted(true);
}; };
@ -87,17 +83,11 @@ export function Chat() {
textareaRef={textareaRef} textareaRef={textareaRef}
input={input} input={input}
chatStarted={chatStarted} chatStarted={chatStarted}
isStreaming={isLoading}
enhancingPrompt={enhancingPrompt} enhancingPrompt={enhancingPrompt}
promptEnhanced={promptEnhanced} promptEnhanced={promptEnhanced}
sendMessage={sendMessage} sendMessage={sendMessage}
handleInputChange={handleInputChange} handleInputChange={handleInputChange}
messagesSlot={
chatStarted ? (
<Messages
classNames={{
root: 'h-full pt-10',
messagesContainer: 'max-w-2xl mx-auto max-md:pb-[calc(140px+1.5rem)] md:pb-[calc(140px+3rem)]',
}}
messages={messages.map((message, i) => { messages={messages.map((message, i) => {
if (message.role === 'user') { if (message.role === 'user') {
return message; return message;
@ -108,10 +98,6 @@ export function Chat() {
content: parsedMessages[i] || '', content: parsedMessages[i] || '',
}; };
})} })}
isLoading={isLoading}
/>
) : null
}
enhancePrompt={() => { enhancePrompt={() => {
enhancePrompt(input, (input) => { enhancePrompt(input, (input) => {
setInput(input); setInput(input);

View File

@ -64,7 +64,7 @@ export const CodeBlock = memo(({ code, language, theme }: CodeBlockProps) => {
> >
<button <button
className={classNames( className={classNames(
'flex items-center p-[6px] justify-center before:bg-white before:rounded-l-md before:text-gray-500 before:border-r before:border-gray-300', 'flex items-center bg-transparent p-[6px] justify-center before:bg-white before:rounded-l-md before:text-gray-500 before:border-r before:border-gray-300',
{ {
'before:opacity-0': !copied, 'before:opacity-0': !copied,
'before:opacity-100': copied, 'before:opacity-100': copied,

View File

@ -95,7 +95,7 @@ $color-blockquote-border: #dfe2e5;
:is(ul, ol) { :is(ul, ol) {
padding-left: 2em; padding-left: 2em;
margin-top: 0; margin-top: 0;
margin-bottom: 16px; margin-bottom: 24px;
} }
ul { ul {
@ -106,6 +106,14 @@ $color-blockquote-border: #dfe2e5;
list-style-type: decimal; list-style-type: decimal;
} }
li + li {
margin-top: 8px;
}
li > *:not(:last-child) {
margin-bottom: 16px;
}
img { img {
max-width: 100%; max-width: 100%;
box-sizing: border-box; box-sizing: border-box;

View File

@ -1,5 +1,5 @@
import { memo } from 'react'; import { memo, useMemo } from 'react';
import ReactMarkdown from 'react-markdown'; import ReactMarkdown, { type Components } from 'react-markdown';
import type { BundledLanguage } from 'shiki'; import type { BundledLanguage } from 'shiki';
import { createScopedLogger } from '~/utils/logger'; import { createScopedLogger } from '~/utils/logger';
import { rehypePlugins, remarkPlugins } from '~/utils/markdown'; import { rehypePlugins, remarkPlugins } from '~/utils/markdown';
@ -16,10 +16,8 @@ interface MarkdownProps {
export const Markdown = memo(({ children }: MarkdownProps) => { export const Markdown = memo(({ children }: MarkdownProps) => {
logger.trace('Render'); logger.trace('Render');
return ( const components = useMemo<Components>(() => {
<ReactMarkdown return {
className={styles.MarkdownContent}
components={{
div: ({ className, children, node, ...props }) => { div: ({ className, children, node, ...props }) => {
if (className?.includes('__boltArtifact__')) { if (className?.includes('__boltArtifact__')) {
const messageId = node?.properties.dataMessageId as string; const messageId = node?.properties.dataMessageId as string;
@ -56,7 +54,13 @@ export const Markdown = memo(({ children }: MarkdownProps) => {
return <pre {...rest}>{children}</pre>; return <pre {...rest}>{children}</pre>;
}, },
}} };
}, []);
return (
<ReactMarkdown
className={styles.MarkdownContent}
components={components}
remarkPlugins={remarkPlugins} remarkPlugins={remarkPlugins}
rehypePlugins={rehypePlugins} rehypePlugins={rehypePlugins}
> >

View File

@ -0,0 +1,62 @@
import type { Message } from 'ai';
import { classNames } from '~/utils/classNames';
import { AssistantMessage } from './AssistantMessage';
import { UserMessage } from './UserMessage';
interface MessagesProps {
id?: string;
className?: string;
isStreaming?: boolean;
messages?: Message[];
}
export function Messages(props: MessagesProps) {
const { id, isStreaming = false, messages = [] } = props;
return (
<div id={id} className={props.className}>
{messages.length > 0
? messages.map((message, i) => {
const { role, content } = message;
const isUser = role === 'user';
const isFirst = i === 0;
const isLast = i === messages.length - 1;
const isUserMessage = message.role === 'user';
const isAssistantMessage = message.role === 'assistant';
return (
<div
key={message.id}
className={classNames('relative overflow-hidden rounded-md p-[1px]', {
'mt-4': !isFirst,
'bg-gray-200': isUserMessage || !isStreaming || (isStreaming && isAssistantMessage && !isLast),
'bg-gradient-to-b from-gray-200 to-transparent': isStreaming && isAssistantMessage && isLast,
})}
>
<div
className={classNames('flex gap-4 p-6 w-full rounded-[calc(0.375rem-1px)]', {
'bg-white': isUserMessage || !isStreaming || (isStreaming && !isLast),
'bg-gradient-to-b from-white from-30% to-transparent': isStreaming && isLast,
})}
>
<div
className={classNames(
'flex items-center justify-center min-w-[34px] min-h-[34px] text-gray-600 rounded-md p-1 self-start',
{
'bg-gray-100': isUserMessage,
'bg-accent text-xl': isAssistantMessage,
},
)}
>
<div className={isUserMessage ? 'i-ph:user-fill text-xl' : 'i-blitz:logo'}></div>
</div>
{isUser ? <UserMessage content={content} /> : <AssistantMessage content={content} />}
</div>
</div>
);
})
: null}
{isStreaming && <div className="text-center w-full i-svg-spinners:3-dots-fade text-4xl mt-4"></div>}
</div>
);
}

View File

@ -1,55 +0,0 @@
import type { Message } from 'ai';
import { useRef } from 'react';
import { classNames } from '~/utils/classNames';
import { AssistantMessage } from './AssistantMessage';
import { UserMessage } from './UserMessage';
interface MessagesProps {
id?: string;
classNames?: { root?: string; messagesContainer?: string };
isLoading?: boolean;
messages?: Message[];
}
export function Messages(props: MessagesProps) {
const { id, isLoading, messages = [] } = props;
const containerRef = useRef<HTMLDivElement>(null);
return (
<div id={id} ref={containerRef} className={props.classNames?.root}>
<div className={classNames('flex flex-col', props.classNames?.messagesContainer)}>
{messages.length > 0
? messages.map((message, i) => {
const { role, content } = message;
const isUser = role === 'user';
const isFirst = i === 0;
return (
<div
key={message.id}
className={classNames('flex gap-4 border rounded-md p-6 bg-white/80 backdrop-blur-sm', {
'mt-4': !isFirst,
})}
>
<div
className={classNames(
'flex items-center justify-center min-w-[34px] min-h-[34px] text-gray-600 rounded-md p-1 self-start',
{
'bg-gray-100': role === 'user',
'bg-accent text-xl': role === 'assistant',
},
)}
>
<div className={role === 'user' ? 'i-ph:user-fill text-xl' : 'i-blitz:logo'}></div>
</div>
{isUser ? <UserMessage content={content} /> : <AssistantMessage content={content} />}
</div>
);
})
: null}
{isLoading && <div className="text-center w-full i-svg-spinners:3-dots-fade text-4xl mt-4"></div>}
</div>
</div>
);
}

View File

@ -1,7 +1,7 @@
import { memo } from 'react'; import { memo } from 'react';
import { classNames } from '~/utils/classNames'; import { classNames } from '~/utils/classNames';
type IconSize = 'sm' | 'md' | 'xl'; type IconSize = 'sm' | 'md' | 'xl' | 'xxl';
interface BaseIconButtonProps { interface BaseIconButtonProps {
size?: IconSize; size?: IconSize;
@ -64,7 +64,9 @@ function getIconSize(size: IconSize) {
return 'text-sm'; return 'text-sm';
} else if (size === 'md') { } else if (size === 'md') {
return 'text-md'; return 'text-md';
} else { } else if (size === 'xl') {
return 'text-xl'; return 'text-xl';
} else {
return 'text-2xl';
} }
} }

View File

@ -0,0 +1,55 @@
import { useStore } from '@nanostores/react';
import { AnimatePresence, motion, type Variants } from 'framer-motion';
import { IconButton } from '~/components/ui/IconButton';
import { cubicEasingFn } from '~/utils/easings';
import { workspaceStore } from '../../lib/stores/workspace';
interface WorkspaceProps {
chatStarted?: boolean;
}
const workspaceVariants = {
closed: {
width: 0,
transition: {
duration: 0.2,
ease: cubicEasingFn,
},
},
open: {
width: '100%',
transition: {
duration: 0.5,
type: 'spring',
},
},
} satisfies Variants;
export function Workspace({ chatStarted }: WorkspaceProps) {
const showWorkspace = useStore(workspaceStore.showWorkspace);
return (
chatStarted && (
<AnimatePresence>
{showWorkspace && (
<motion.div initial="closed" animate="open" exit="closed" variants={workspaceVariants}>
<div className="fixed top-[calc(var(--header-height)+1.5rem)] bottom-6 w-[50vw] mr-4 z-0">
<div className="bg-white border border-gray-200 shadow-sm rounded-lg overflow-hidden absolute inset-0 right-8">
<header className="px-3 py-2 border-b border-gray-200">
<IconButton
icon="i-ph:x-circle"
className="ml-auto"
size="xxl"
onClick={() => {
workspaceStore.showWorkspace.set(false);
}}
/>
</header>
</div>
</div>
</motion.div>
)}
</AnimatePresence>
)
);
}

View File

@ -1,3 +0,0 @@
export function Workspace() {
return <div>WORKSPACE PANEL</div>;
}

View File

@ -19,7 +19,7 @@ export async function action({ context, request }: ActionFunctionArgs) {
{ {
role: 'user', role: 'user',
content: stripIndents` content: stripIndents`
I want you to improve the following prompt. I want you to improve the user prompt that is wrapped in \`<original_prompt>\` tags.
IMPORTANT: Only respond with the improved prompt and nothing else! IMPORTANT: Only respond with the improved prompt and nothing else!

View File

@ -14,7 +14,7 @@ body {
mask: linear-gradient(-25deg, transparent 60%, white); mask: linear-gradient(-25deg, transparent 60%, white);
pointer-events: none; pointer-events: none;
position: fixed; position: fixed;
top: 0; top: -8px;
transform-style: flat; transform-style: flat;
width: 100vw; width: 100vw;
z-index: -1; z-index: -1;

View File

@ -16,6 +16,8 @@
* Hierarchy: Element Token -> (Element Token | Color Tokens) -> Primitives * Hierarchy: Element Token -> (Element Token | Color Tokens) -> Primitives
*/ */
:root { :root {
--header-height: 65px;
/* App */ /* App */
--bolt-elements-app-backgroundColor: var(--bolt-background-primary); --bolt-elements-app-backgroundColor: var(--bolt-background-primary);
} }

View File

@ -0,0 +1,3 @@
import { cubicBezier } from 'framer-motion';
export const cubicEasingFn = cubicBezier(0.4, 0, 0.2, 1);