fix: remove monorepo

This commit is contained in:
Sam Denty
2024-09-25 19:54:09 +01:00
parent d364a6f774
commit 6fb59d2bc5
137 changed files with 194 additions and 1229 deletions

View File

@@ -0,0 +1,213 @@
import { useStore } from '@nanostores/react';
import { AnimatePresence, motion } from 'framer-motion';
import { computed } from 'nanostores';
import { memo, useEffect, useRef, useState } from 'react';
import { createHighlighter, type BundledLanguage, type BundledTheme, type HighlighterGeneric } from 'shiki';
import type { ActionState } from '~/lib/runtime/action-runner';
import { workbenchStore } from '~/lib/stores/workbench';
import { classNames } from '~/utils/classNames';
import { cubicEasingFn } from '~/utils/easings';
const highlighterOptions = {
langs: ['shell'],
themes: ['light-plus', 'dark-plus'],
};
const shellHighlighter: HighlighterGeneric<BundledLanguage, BundledTheme> =
import.meta.hot?.data.shellHighlighter ?? (await createHighlighter(highlighterOptions));
if (import.meta.hot) {
import.meta.hot.data.shellHighlighter = shellHighlighter;
}
interface ArtifactProps {
messageId: string;
}
export const Artifact = memo(({ messageId }: ArtifactProps) => {
const userToggledActions = useRef(false);
const [showActions, setShowActions] = useState(false);
const artifacts = useStore(workbenchStore.artifacts);
const artifact = artifacts[messageId];
const actions = useStore(
computed(artifact.runner.actions, (actions) => {
return Object.values(actions);
}),
);
const toggleActions = () => {
userToggledActions.current = true;
setShowActions(!showActions);
};
useEffect(() => {
if (actions.length && !showActions && !userToggledActions.current) {
setShowActions(true);
}
}, [actions]);
return (
<div className="artifact border border-bolt-elements-borderColor flex flex-col overflow-hidden rounded-lg w-full transition-border duration-150">
<div className="flex">
<button
className="flex items-stretch bg-bolt-elements-artifacts-background hover:bg-bolt-elements-artifacts-backgroundHover w-full overflow-hidden"
onClick={() => {
const showWorkbench = workbenchStore.showWorkbench.get();
workbenchStore.showWorkbench.set(!showWorkbench);
}}
>
<div className="px-5 p-3.5 w-full text-left">
<div className="w-full text-bolt-elements-textPrimary font-medium leading-5 text-sm">{artifact?.title}</div>
<div className="w-full w-full text-bolt-elements-textSecondary text-xs mt-0.5">Click to open Workbench</div>
</div>
</button>
<div className="bg-bolt-elements-artifacts-borderColor w-[1px]" />
<AnimatePresence>
{actions.length && (
<motion.button
initial={{ width: 0 }}
animate={{ width: 'auto' }}
exit={{ width: 0 }}
transition={{ duration: 0.15, ease: cubicEasingFn }}
className="bg-bolt-elements-artifacts-background hover:bg-bolt-elements-artifacts-backgroundHover"
onClick={toggleActions}
>
<div className="p-4">
<div className={showActions ? 'i-ph:caret-up-bold' : 'i-ph:caret-down-bold'}></div>
</div>
</motion.button>
)}
</AnimatePresence>
</div>
<AnimatePresence>
{showActions && actions.length > 0 && (
<motion.div
className="actions"
initial={{ height: 0 }}
animate={{ height: 'auto' }}
exit={{ height: '0px' }}
transition={{ duration: 0.15 }}
>
<div className="bg-bolt-elements-artifacts-borderColor h-[1px]" />
<div className="p-5 text-left bg-bolt-elements-actions-background">
<ActionList actions={actions} />
</div>
</motion.div>
)}
</AnimatePresence>
</div>
);
});
interface ShellCodeBlockProps {
classsName?: string;
code: string;
}
function ShellCodeBlock({ classsName, code }: ShellCodeBlockProps) {
return (
<div
className={classNames('text-xs', classsName)}
dangerouslySetInnerHTML={{
__html: shellHighlighter.codeToHtml(code, {
lang: 'shell',
theme: 'dark-plus',
}),
}}
></div>
);
}
interface ActionListProps {
actions: ActionState[];
}
const actionVariants = {
hidden: { opacity: 0, y: 20 },
visible: { opacity: 1, y: 0 },
};
const ActionList = memo(({ actions }: ActionListProps) => {
return (
<motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }} transition={{ duration: 0.15 }}>
<ul className="list-none space-y-2.5">
{actions.map((action, index) => {
const { status, type, content } = action;
const isLast = index === actions.length - 1;
return (
<motion.li
key={index}
variants={actionVariants}
initial="hidden"
animate="visible"
transition={{
duration: 0.2,
ease: cubicEasingFn,
}}
>
<div className="flex items-center gap-1.5 text-sm">
<div className={classNames('text-lg', getIconColor(action.status))}>
{status === 'running' ? (
<div className="i-svg-spinners:90-ring-with-bg"></div>
) : status === 'pending' ? (
<div className="i-ph:circle-duotone"></div>
) : status === 'complete' ? (
<div className="i-ph:check"></div>
) : status === 'failed' || status === 'aborted' ? (
<div className="i-ph:x"></div>
) : null}
</div>
{type === 'file' ? (
<div>
Create{' '}
<code className="bg-bolt-elements-artifacts-inlineCode-background text-bolt-elements-artifacts-inlineCode-text px-1.5 py-1 rounded-md">
{action.filePath}
</code>
</div>
) : type === 'shell' ? (
<div className="flex items-center w-full min-h-[28px]">
<span className="flex-1">Run command</span>
</div>
) : null}
</div>
{type === 'shell' && (
<ShellCodeBlock
classsName={classNames('mt-1', {
'mb-3.5': !isLast,
})}
code={content}
/>
)}
</motion.li>
);
})}
</ul>
</motion.div>
);
});
function getIconColor(status: ActionState['status']) {
switch (status) {
case 'pending': {
return 'text-bolt-elements-textTertiary';
}
case 'running': {
return 'text-bolt-elements-loader-progress';
}
case 'complete': {
return 'text-bolt-elements-icon-success';
}
case 'aborted': {
return 'text-bolt-elements-textSecondary';
}
case 'failed': {
return 'text-bolt-elements-icon-error';
}
default: {
return undefined;
}
}
}

View File

@@ -0,0 +1,14 @@
import { memo } from 'react';
import { Markdown } from './Markdown';
interface AssistantMessageProps {
content: string;
}
export const AssistantMessage = memo(({ content }: AssistantMessageProps) => {
return (
<div className="overflow-hidden w-full">
<Markdown html>{content}</Markdown>
</div>
);
});

View File

@@ -0,0 +1,19 @@
.BaseChat {
&[data-chat-visible='false'] {
--workbench-inner-width: 100%;
--workbench-left: 0;
.Chat {
--at-apply: bolt-ease-cubic-bezier;
transition-property: transform, opacity;
transition-duration: 0.3s;
will-change: transform, opacity;
transform: translateX(-50%);
opacity: 0;
}
}
}
.Chat {
opacity: 1;
}

View File

@@ -0,0 +1,216 @@
import type { Message } from 'ai';
import React, { type RefCallback } from 'react';
import { ClientOnly } from 'remix-utils/client-only';
import { Menu } from '~/components/sidebar/Menu.client';
import { IconButton } from '~/components/ui/IconButton';
import { Workbench } from '~/components/workbench/Workbench.client';
import { classNames } from '~/utils/classNames';
import { Messages } from './Messages.client';
import { SendButton } from './SendButton.client';
import styles from './BaseChat.module.scss';
import { useLoaderData } from '@remix-run/react';
interface BaseChatProps {
textareaRef?: React.RefObject<HTMLTextAreaElement> | undefined;
messageRef?: RefCallback<HTMLDivElement> | undefined;
scrollRef?: RefCallback<HTMLDivElement> | undefined;
showChat?: boolean;
chatStarted?: boolean;
isStreaming?: boolean;
messages?: Message[];
enhancingPrompt?: boolean;
promptEnhanced?: boolean;
input?: string;
handleStop?: () => void;
sendMessage?: (event: React.UIEvent, messageInput?: string) => void;
handleInputChange?: (event: React.ChangeEvent<HTMLTextAreaElement>) => void;
enhancePrompt?: () => void;
}
const EXAMPLE_PROMPTS = [
{ text: 'Build a todo app in React using Tailwind' },
{ text: 'Build a simple blog using Astro' },
{ text: 'Create a cookie consent form using Material UI' },
{ text: 'Make a space invaders game' },
{ text: 'How do I center a div?' },
];
const TEXTAREA_MIN_HEIGHT = 76;
export const BaseChat = React.forwardRef<HTMLDivElement, BaseChatProps>(
(
{
textareaRef,
messageRef,
scrollRef,
showChat = true,
chatStarted = false,
isStreaming = false,
enhancingPrompt = false,
promptEnhanced = false,
messages,
input = '',
sendMessage,
handleInputChange,
enhancePrompt,
handleStop,
},
ref,
) => {
const TEXTAREA_MAX_HEIGHT = chatStarted ? 400 : 200;
const { avatar } = useLoaderData<{ avatar?: string }>();
return (
<div
ref={ref}
className={classNames(
styles.BaseChat,
'relative flex h-full w-full overflow-hidden bg-bolt-elements-background-depth-1',
)}
data-chat-visible={showChat}
>
<ClientOnly>{() => <Menu />}</ClientOnly>
<div ref={scrollRef} className="flex overflow-scroll w-full h-full">
<div className={classNames(styles.Chat, 'flex flex-col flex-grow min-w-[var(--chat-min-width)] h-full')}>
{!chatStarted && (
<div id="intro" className="mt-[26vh] max-w-chat mx-auto">
<h1 className="text-5xl text-center font-bold text-bolt-elements-textPrimary mb-2">
Where ideas begin
</h1>
<p className="mb-4 text-center text-bolt-elements-textSecondary">
Bring ideas to life in seconds or get help on existing projects.
</p>
</div>
)}
<div
className={classNames('pt-6 px-6', {
'h-full flex flex-col': chatStarted,
})}
>
<ClientOnly>
{() => {
return chatStarted ? (
<Messages
ref={messageRef}
className="flex flex-col w-full flex-1 max-w-chat px-4 pb-6 mx-auto z-1"
messages={messages}
isStreaming={isStreaming}
avatar={avatar}
/>
) : null;
}}
</ClientOnly>
<div
className={classNames('relative w-full max-w-chat mx-auto z-prompt', {
'sticky bottom-0': chatStarted,
})}
>
<div
className={classNames(
'shadow-sm border border-bolt-elements-borderColor bg-bolt-elements-prompt-background backdrop-filter backdrop-blur-[8px] rounded-lg overflow-hidden',
)}
>
<textarea
ref={textareaRef}
className={`w-full pl-4 pt-4 pr-16 focus:outline-none resize-none text-md text-bolt-elements-textPrimary placeholder-bolt-elements-textTertiary bg-transparent`}
onKeyDown={(event) => {
if (event.key === 'Enter') {
if (event.shiftKey) {
return;
}
event.preventDefault();
sendMessage?.(event);
}
}}
value={input}
onChange={(event) => {
handleInputChange?.(event);
}}
style={{
minHeight: TEXTAREA_MIN_HEIGHT,
maxHeight: TEXTAREA_MAX_HEIGHT,
}}
placeholder="How can Bolt help you today?"
translate="no"
/>
<ClientOnly>
{() => (
<SendButton
show={input.length > 0 || isStreaming}
isStreaming={isStreaming}
onClick={(event) => {
if (isStreaming) {
handleStop?.();
return;
}
sendMessage?.(event);
}}
/>
)}
</ClientOnly>
<div className="flex justify-between text-sm p-4 pt-2">
<div className="flex gap-1 items-center">
<IconButton
title="Enhance prompt"
disabled={input.length === 0 || enhancingPrompt}
className={classNames({
'opacity-100!': enhancingPrompt,
'text-bolt-elements-item-contentAccent! pr-1.5 enabled:hover:bg-bolt-elements-item-backgroundAccent!':
promptEnhanced,
})}
onClick={() => enhancePrompt?.()}
>
{enhancingPrompt ? (
<>
<div className="i-svg-spinners:90-ring-with-bg text-bolt-elements-loader-progress text-xl"></div>
<div className="ml-1.5">Enhancing prompt...</div>
</>
) : (
<>
<div className="i-bolt:stars text-xl"></div>
{promptEnhanced && <div className="ml-1.5">Prompt enhanced</div>}
</>
)}
</IconButton>
</div>
{input.length > 3 ? (
<div className="text-xs text-bolt-elements-textTertiary">
Use <kbd className="kdb">Shift</kbd> + <kbd className="kdb">Return</kbd> for a new line
</div>
) : null}
</div>
</div>
<div className="bg-bolt-elements-background-depth-1 pb-6">{/* Ghost Element */}</div>
</div>
</div>
{!chatStarted && (
<div id="examples" className="relative w-full max-w-xl mx-auto mt-8 flex justify-center">
<div className="flex flex-col space-y-2 [mask-image:linear-gradient(to_bottom,black_0%,transparent_180%)] hover:[mask-image:none]">
{EXAMPLE_PROMPTS.map((examplePrompt, index) => {
return (
<button
key={index}
onClick={(event) => {
sendMessage?.(event, examplePrompt.text);
}}
className="group flex items-center w-full gap-2 justify-center bg-transparent text-bolt-elements-textTertiary hover:text-bolt-elements-textPrimary transition-theme"
>
{examplePrompt.text}
<div className="i-ph:arrow-bend-down-left" />
</button>
);
})}
</div>
</div>
)}
</div>
<ClientOnly>{() => <Workbench chatStarted={chatStarted} isStreaming={isStreaming} />}</ClientOnly>
</div>
</div>
);
},
);

View File

@@ -0,0 +1,234 @@
import { useStore } from '@nanostores/react';
import type { Message } from 'ai';
import { useChat } from 'ai/react';
import { useAnimate } from 'framer-motion';
import { memo, useEffect, useRef, useState } from 'react';
import { cssTransition, toast, ToastContainer } from 'react-toastify';
import { useMessageParser, usePromptEnhancer, useShortcuts, useSnapScroll } from '~/lib/hooks';
import { useChatHistory } from '~/lib/persistence';
import { chatStore } from '~/lib/stores/chat';
import { workbenchStore } from '~/lib/stores/workbench';
import { fileModificationsToHTML } from '~/utils/diff';
import { cubicEasingFn } from '~/utils/easings';
import { createScopedLogger, renderLogger } from '~/utils/logger';
import { BaseChat } from './BaseChat';
const toastAnimation = cssTransition({
enter: 'animated fadeInRight',
exit: 'animated fadeOutRight',
});
const logger = createScopedLogger('Chat');
export function Chat() {
renderLogger.trace('Chat');
const { ready, initialMessages, storeMessageHistory } = useChatHistory();
return (
<>
{ready && <ChatImpl initialMessages={initialMessages} storeMessageHistory={storeMessageHistory} />}
<ToastContainer
closeButton={({ closeToast }) => {
return (
<button className="Toastify__close-button" onClick={closeToast}>
<div className="i-ph:x text-lg" />
</button>
);
}}
icon={({ type }) => {
/**
* @todo Handle more types if we need them. This may require extra color palettes.
*/
switch (type) {
case 'success': {
return <div className="i-ph:check-bold text-bolt-elements-icon-success text-2xl" />;
}
case 'error': {
return <div className="i-ph:warning-circle-bold text-bolt-elements-icon-error text-2xl" />;
}
}
return undefined;
}}
position="bottom-right"
pauseOnFocusLoss
transition={toastAnimation}
/>
</>
);
}
interface ChatProps {
initialMessages: Message[];
storeMessageHistory: (messages: Message[]) => Promise<void>;
}
export const ChatImpl = memo(({ initialMessages, storeMessageHistory }: ChatProps) => {
useShortcuts();
const textareaRef = useRef<HTMLTextAreaElement>(null);
const [chatStarted, setChatStarted] = useState(initialMessages.length > 0);
const { showChat } = useStore(chatStore);
const [animationScope, animate] = useAnimate();
const { messages, isLoading, input, handleInputChange, setInput, stop, append } = useChat({
api: '/api/chat',
onError: (error) => {
logger.error('Request failed\n\n', error);
toast.error('There was an error processing your request');
},
onFinish: () => {
logger.debug('Finished streaming');
},
initialMessages,
});
const { enhancingPrompt, promptEnhanced, enhancePrompt, resetEnhancer } = usePromptEnhancer();
const { parsedMessages, parseMessages } = useMessageParser();
const TEXTAREA_MAX_HEIGHT = chatStarted ? 400 : 200;
useEffect(() => {
chatStore.setKey('started', initialMessages.length > 0);
}, []);
useEffect(() => {
parseMessages(messages, isLoading);
if (messages.length > initialMessages.length) {
storeMessageHistory(messages).catch((error) => toast.error(error.message));
}
}, [messages, isLoading, parseMessages]);
const scrollTextArea = () => {
const textarea = textareaRef.current;
if (textarea) {
textarea.scrollTop = textarea.scrollHeight;
}
};
const abort = () => {
stop();
chatStore.setKey('aborted', true);
workbenchStore.abortAllActions();
};
useEffect(() => {
const textarea = textareaRef.current;
if (textarea) {
textarea.style.height = 'auto';
const scrollHeight = textarea.scrollHeight;
textarea.style.height = `${Math.min(scrollHeight, TEXTAREA_MAX_HEIGHT)}px`;
textarea.style.overflowY = scrollHeight > TEXTAREA_MAX_HEIGHT ? 'auto' : 'hidden';
}
}, [input, textareaRef]);
const runAnimation = async () => {
if (chatStarted) {
return;
}
await Promise.all([
animate('#examples', { opacity: 0, display: 'none' }, { duration: 0.1 }),
animate('#intro', { opacity: 0, flex: 1 }, { duration: 0.2, ease: cubicEasingFn }),
]);
chatStore.setKey('started', true);
setChatStarted(true);
};
const sendMessage = async (_event: React.UIEvent, messageInput?: string) => {
const _input = messageInput || input;
if (_input.length === 0 || isLoading) {
return;
}
/**
* @note (delm) Usually saving files shouldn't take long but it may take longer if there
* many unsaved files. In that case we need to block user input and show an indicator
* of some kind so the user is aware that something is happening. But I consider the
* happy case to be no unsaved files and I would expect users to save their changes
* before they send another message.
*/
await workbenchStore.saveAllFiles();
const fileModifications = workbenchStore.getFileModifcations();
chatStore.setKey('aborted', false);
runAnimation();
if (fileModifications !== undefined) {
const diff = fileModificationsToHTML(fileModifications);
/**
* If we have file modifications we append a new user message manually since we have to prefix
* the user input with the file modifications and we don't want the new user input to appear
* in the prompt. Using `append` is almost the same as `handleSubmit` except that we have to
* manually reset the input and we'd have to manually pass in file attachments. However, those
* aren't relevant here.
*/
append({ role: 'user', content: `${diff}\n\n${_input}` });
/**
* After sending a new message we reset all modifications since the model
* should now be aware of all the changes.
*/
workbenchStore.resetAllFileModifications();
} else {
append({ role: 'user', content: _input });
}
setInput('');
resetEnhancer();
textareaRef.current?.blur();
};
const [messageRef, scrollRef] = useSnapScroll();
return (
<BaseChat
ref={animationScope}
textareaRef={textareaRef}
input={input}
showChat={showChat}
chatStarted={chatStarted}
isStreaming={isLoading}
enhancingPrompt={enhancingPrompt}
promptEnhanced={promptEnhanced}
sendMessage={sendMessage}
messageRef={messageRef}
scrollRef={scrollRef}
handleInputChange={handleInputChange}
handleStop={abort}
messages={messages.map((message, i) => {
if (message.role === 'user') {
return message;
}
return {
...message,
content: parsedMessages[i] || '',
};
})}
enhancePrompt={() => {
enhancePrompt(input, (input) => {
setInput(input);
scrollTextArea();
});
}}
/>
);
});

View File

@@ -0,0 +1,10 @@
.CopyButtonContainer {
button:before {
content: 'Copied';
font-size: 12px;
position: absolute;
left: -53px;
padding: 2px 6px;
height: 30px;
}
}

View File

@@ -0,0 +1,82 @@
import { memo, useEffect, useState } from 'react';
import { bundledLanguages, codeToHtml, isSpecialLang, type BundledLanguage, type SpecialLanguage } from 'shiki';
import { classNames } from '~/utils/classNames';
import { createScopedLogger } from '~/utils/logger';
import styles from './CodeBlock.module.scss';
const logger = createScopedLogger('CodeBlock');
interface CodeBlockProps {
className?: string;
code: string;
language?: BundledLanguage | SpecialLanguage;
theme?: 'light-plus' | 'dark-plus';
disableCopy?: boolean;
}
export const CodeBlock = memo(
({ className, code, language = 'plaintext', theme = 'dark-plus', disableCopy = false }: CodeBlockProps) => {
const [html, setHTML] = useState<string | undefined>(undefined);
const [copied, setCopied] = useState(false);
const copyToClipboard = () => {
if (copied) {
return;
}
navigator.clipboard.writeText(code);
setCopied(true);
setTimeout(() => {
setCopied(false);
}, 2000);
};
useEffect(() => {
if (language && !isSpecialLang(language) && !(language in bundledLanguages)) {
logger.warn(`Unsupported language '${language}'`);
}
logger.trace(`Language = ${language}`);
const processCode = async () => {
setHTML(await codeToHtml(code, { lang: language, theme }));
};
processCode();
}, [code]);
return (
<div className={classNames('relative group text-left', className)}>
<div
className={classNames(
styles.CopyButtonContainer,
'bg-white absolute top-[10px] right-[10px] rounded-md z-10 text-lg flex items-center justify-center opacity-0 group-hover:opacity-100',
{
'rounded-l-0 opacity-100': copied,
},
)}
>
{!disableCopy && (
<button
className={classNames(
'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-100': copied,
},
)}
title="Copy Code"
onClick={() => copyToClipboard()}
>
<div className="i-ph:clipboard-text-duotone"></div>
</button>
)}
</div>
<div dangerouslySetInnerHTML={{ __html: html ?? '' }}></div>
</div>
);
},
);

View File

@@ -0,0 +1,171 @@
$font-mono: ui-monospace, 'Fira Code', Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace;
$code-font-size: 13px;
@mixin not-inside-actions {
&:not(:has(:global(.actions)), :global(.actions *)) {
@content;
}
}
.MarkdownContent {
line-height: 1.6;
color: var(--bolt-elements-textPrimary);
> *:not(:last-child) {
margin-block-end: 16px;
}
:global(.artifact) {
margin: 1.5em 0;
}
:is(h1, h2, h3, h4, h5, h6) {
@include not-inside-actions {
margin-block-start: 24px;
margin-block-end: 16px;
font-weight: 600;
line-height: 1.25;
color: var(--bolt-elements-textPrimary);
}
}
h1 {
font-size: 2em;
border-bottom: 1px solid var(--bolt-elements-borderColor);
padding-bottom: 0.3em;
}
h2 {
font-size: 1.5em;
border-bottom: 1px solid var(--bolt-elements-borderColor);
padding-bottom: 0.3em;
}
h3 {
font-size: 1.25em;
}
h4 {
font-size: 1em;
}
h5 {
font-size: 0.875em;
}
h6 {
font-size: 0.85em;
color: #6a737d;
}
p {
white-space: pre-wrap;
&:not(:last-of-type) {
margin-block-start: 0;
margin-block-end: 16px;
}
}
a {
color: var(--bolt-elements-messages-linkColor);
text-decoration: none;
cursor: pointer;
&:hover {
text-decoration: underline;
}
}
:not(pre) > code {
font-family: $font-mono;
font-size: $code-font-size;
@include not-inside-actions {
border-radius: 6px;
padding: 0.2em 0.4em;
background-color: var(--bolt-elements-messages-inlineCode-background);
color: var(--bolt-elements-messages-inlineCode-text);
}
}
pre {
padding: 20px 16px;
border-radius: 6px;
}
pre:has(> code) {
font-family: $font-mono;
font-size: $code-font-size;
background: transparent;
overflow-x: auto;
min-width: 0;
}
blockquote {
margin: 0;
padding: 0 1em;
color: var(--bolt-elements-textTertiary);
border-left: 0.25em solid var(--bolt-elements-borderColor);
}
:is(ul, ol) {
@include not-inside-actions {
padding-left: 2em;
margin-block-start: 0;
margin-block-end: 16px;
}
}
ul {
@include not-inside-actions {
list-style-type: disc;
}
}
ol {
@include not-inside-actions {
list-style-type: decimal;
}
}
li {
@include not-inside-actions {
& + li {
margin-block-start: 8px;
}
> *:not(:last-child) {
margin-block-end: 16px;
}
}
}
img {
max-width: 100%;
box-sizing: border-box;
}
hr {
height: 0.25em;
padding: 0;
margin: 24px 0;
background-color: var(--bolt-elements-borderColor);
border: 0;
}
table {
border-collapse: collapse;
width: 100%;
margin-block-end: 16px;
:is(th, td) {
padding: 6px 13px;
border: 1px solid #dfe2e5;
}
tr:nth-child(2n) {
background-color: #f6f8fa;
}
}
}

View File

@@ -0,0 +1,74 @@
import { memo, useMemo } from 'react';
import ReactMarkdown, { type Components } from 'react-markdown';
import type { BundledLanguage } from 'shiki';
import { createScopedLogger } from '~/utils/logger';
import { rehypePlugins, remarkPlugins, allowedHTMLElements } from '~/utils/markdown';
import { Artifact } from './Artifact';
import { CodeBlock } from './CodeBlock';
import styles from './Markdown.module.scss';
const logger = createScopedLogger('MarkdownComponent');
interface MarkdownProps {
children: string;
html?: boolean;
limitedMarkdown?: boolean;
}
export const Markdown = memo(({ children, html = false, limitedMarkdown = false }: MarkdownProps) => {
logger.trace('Render');
const components = useMemo(() => {
return {
div: ({ className, children, node, ...props }) => {
if (className?.includes('__boltArtifact__')) {
const messageId = node?.properties.dataMessageId as string;
if (!messageId) {
logger.error(`Invalid message id ${messageId}`);
}
return <Artifact messageId={messageId} />;
}
return (
<div className={className} {...props}>
{children}
</div>
);
},
pre: (props) => {
const { children, node, ...rest } = props;
const [firstChild] = node?.children ?? [];
if (
firstChild &&
firstChild.type === 'element' &&
firstChild.tagName === 'code' &&
firstChild.children[0].type === 'text'
) {
const { className, ...rest } = firstChild.properties;
const [, language = 'plaintext'] = /language-(\w+)/.exec(String(className) || '') ?? [];
return <CodeBlock code={firstChild.children[0].value} language={language as BundledLanguage} {...rest} />;
}
return <pre {...rest}>{children}</pre>;
},
} satisfies Components;
}, []);
return (
<ReactMarkdown
allowedElements={allowedHTMLElements}
className={styles.MarkdownContent}
components={components}
remarkPlugins={remarkPlugins(limitedMarkdown)}
rehypePlugins={rehypePlugins(html)}
>
{children}
</ReactMarkdown>
);
});

View File

@@ -0,0 +1,58 @@
import type { Message } from 'ai';
import React from 'react';
import { classNames } from '~/utils/classNames';
import { AssistantMessage } from './AssistantMessage';
import { UserMessage } from './UserMessage';
interface MessagesProps {
id?: string;
className?: string;
isStreaming?: boolean;
messages?: Message[];
avatar?: string;
}
export const Messages = React.forwardRef<HTMLDivElement, MessagesProps>((props: MessagesProps, ref) => {
const { id, isStreaming = false, messages = [], avatar } = props;
return (
<div id={id} ref={ref} className={props.className}>
{messages.length > 0
? messages.map((message, index) => {
const { role, content } = message;
const isUserMessage = role === 'user';
const isFirst = index === 0;
const isLast = index === messages.length - 1;
return (
<div
key={index}
className={classNames('flex gap-4 p-6 w-full rounded-[calc(0.75rem-1px)]', {
'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,
})}
>
{isUserMessage && (
<div className="flex items-center justify-center w-[34px] h-[34px] overflow-hidden bg-white text-gray-600 rounded-full shrink-0 self-start">
{avatar ? (
<img className="w-full h-full object-cover" src={avatar} />
) : (
<div className="i-ph:user-fill text-xl"></div>
)}
</div>
)}
<div className="grid grid-col-1 w-full">
{isUserMessage ? <UserMessage content={content} /> : <AssistantMessage content={content} />}
</div>
</div>
);
})
: null}
{isStreaming && (
<div className="text-center w-full text-bolt-elements-textSecondary i-svg-spinners:3-dots-fade text-4xl mt-4"></div>
)}
</div>
);
});

View File

@@ -0,0 +1,33 @@
import { AnimatePresence, cubicBezier, motion } from 'framer-motion';
interface SendButtonProps {
show: boolean;
isStreaming?: boolean;
onClick?: (event: React.MouseEvent<HTMLButtonElement, MouseEvent>) => void;
}
const customEasingFn = cubicBezier(0.4, 0, 0.2, 1);
export function SendButton({ show, isStreaming, onClick }: SendButtonProps) {
return (
<AnimatePresence>
{show ? (
<motion.button
className="absolute flex justify-center items-center top-[18px] right-[22px] p-1 bg-accent-500 hover:brightness-94 color-white rounded-md w-[34px] h-[34px] transition-theme"
transition={{ ease: customEasingFn, duration: 0.17 }}
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: 10 }}
onClick={(event) => {
event.preventDefault();
onClick?.(event);
}}
>
<div className="text-lg">
{!isStreaming ? <div className="i-ph:arrow-right"></div> : <div className="i-ph:stop-circle-bold"></div>}
</div>
</motion.button>
) : null}
</AnimatePresence>
);
}

View File

@@ -0,0 +1,18 @@
import { modificationsRegex } from '~/utils/diff';
import { Markdown } from './Markdown';
interface UserMessageProps {
content: string;
}
export function UserMessage({ content }: UserMessageProps) {
return (
<div className="overflow-hidden pt-[4px]">
<Markdown limitedMarkdown>{sanitizeUserMessage(content)}</Markdown>
</div>
);
}
function sanitizeUserMessage(content: string) {
return content.replace(modificationsRegex, '').trim();
}

View File

@@ -0,0 +1,7 @@
export function BinaryContent() {
return (
<div className="flex items-center justify-center absolute inset-0 z-10 text-sm bg-tk-elements-app-backgroundColor text-tk-elements-app-textColor">
File format cannot be displayed.
</div>
);
}

View File

@@ -0,0 +1,461 @@
import { acceptCompletion, autocompletion, closeBrackets } from '@codemirror/autocomplete';
import { defaultKeymap, history, historyKeymap } from '@codemirror/commands';
import { bracketMatching, foldGutter, indentOnInput, indentUnit } from '@codemirror/language';
import { searchKeymap } from '@codemirror/search';
import { Compartment, EditorSelection, EditorState, StateEffect, StateField, type Extension } from '@codemirror/state';
import {
drawSelection,
dropCursor,
EditorView,
highlightActiveLine,
highlightActiveLineGutter,
keymap,
lineNumbers,
scrollPastEnd,
showTooltip,
tooltips,
type Tooltip,
} from '@codemirror/view';
import { memo, useEffect, useRef, useState, type MutableRefObject } from 'react';
import type { Theme } from '~/types/theme';
import { classNames } from '~/utils/classNames';
import { debounce } from '~/utils/debounce';
import { createScopedLogger, renderLogger } from '~/utils/logger';
import { BinaryContent } from './BinaryContent';
import { getTheme, reconfigureTheme } from './cm-theme';
import { indentKeyBinding } from './indent';
import { getLanguage } from './languages';
const logger = createScopedLogger('CodeMirrorEditor');
export interface EditorDocument {
value: string;
isBinary: boolean;
filePath: string;
scroll?: ScrollPosition;
}
export interface EditorSettings {
fontSize?: string;
gutterFontSize?: string;
tabSize?: number;
}
type TextEditorDocument = EditorDocument & {
value: string;
};
export interface ScrollPosition {
top: number;
left: number;
}
export interface EditorUpdate {
selection: EditorSelection;
content: string;
}
export type OnChangeCallback = (update: EditorUpdate) => void;
export type OnScrollCallback = (position: ScrollPosition) => void;
export type OnSaveCallback = () => void;
interface Props {
theme: Theme;
id?: unknown;
doc?: EditorDocument;
editable?: boolean;
debounceChange?: number;
debounceScroll?: number;
autoFocusOnDocumentChange?: boolean;
onChange?: OnChangeCallback;
onScroll?: OnScrollCallback;
onSave?: OnSaveCallback;
className?: string;
settings?: EditorSettings;
}
type EditorStates = Map<string, EditorState>;
const readOnlyTooltipStateEffect = StateEffect.define<boolean>();
const editableTooltipField = StateField.define<readonly Tooltip[]>({
create: () => [],
update(_tooltips, transaction) {
if (!transaction.state.readOnly) {
return [];
}
for (const effect of transaction.effects) {
if (effect.is(readOnlyTooltipStateEffect) && effect.value) {
return getReadOnlyTooltip(transaction.state);
}
}
return [];
},
provide: (field) => {
return showTooltip.computeN([field], (state) => state.field(field));
},
});
const editableStateEffect = StateEffect.define<boolean>();
const editableStateField = StateField.define<boolean>({
create() {
return true;
},
update(value, transaction) {
for (const effect of transaction.effects) {
if (effect.is(editableStateEffect)) {
return effect.value;
}
}
return value;
},
});
export const CodeMirrorEditor = memo(
({
id,
doc,
debounceScroll = 100,
debounceChange = 150,
autoFocusOnDocumentChange = false,
editable = true,
onScroll,
onChange,
onSave,
theme,
settings,
className = '',
}: Props) => {
renderLogger.trace('CodeMirrorEditor');
const [languageCompartment] = useState(new Compartment());
const containerRef = useRef<HTMLDivElement | null>(null);
const viewRef = useRef<EditorView>();
const themeRef = useRef<Theme>();
const docRef = useRef<EditorDocument>();
const editorStatesRef = useRef<EditorStates>();
const onScrollRef = useRef(onScroll);
const onChangeRef = useRef(onChange);
const onSaveRef = useRef(onSave);
/**
* This effect is used to avoid side effects directly in the render function
* and instead the refs are updated after each render.
*/
useEffect(() => {
onScrollRef.current = onScroll;
onChangeRef.current = onChange;
onSaveRef.current = onSave;
docRef.current = doc;
themeRef.current = theme;
});
useEffect(() => {
const onUpdate = debounce((update: EditorUpdate) => {
onChangeRef.current?.(update);
}, debounceChange);
const view = new EditorView({
parent: containerRef.current!,
dispatchTransactions(transactions) {
const previousSelection = view.state.selection;
view.update(transactions);
const newSelection = view.state.selection;
const selectionChanged =
newSelection !== previousSelection &&
(newSelection === undefined || previousSelection === undefined || !newSelection.eq(previousSelection));
if (docRef.current && (transactions.some((transaction) => transaction.docChanged) || selectionChanged)) {
onUpdate({
selection: view.state.selection,
content: view.state.doc.toString(),
});
editorStatesRef.current!.set(docRef.current.filePath, view.state);
}
},
});
viewRef.current = view;
return () => {
viewRef.current?.destroy();
viewRef.current = undefined;
};
}, []);
useEffect(() => {
if (!viewRef.current) {
return;
}
viewRef.current.dispatch({
effects: [reconfigureTheme(theme)],
});
}, [theme]);
useEffect(() => {
editorStatesRef.current = new Map<string, EditorState>();
}, [id]);
useEffect(() => {
const editorStates = editorStatesRef.current!;
const view = viewRef.current!;
const theme = themeRef.current!;
if (!doc) {
const state = newEditorState('', theme, settings, onScrollRef, debounceScroll, onSaveRef, [
languageCompartment.of([]),
]);
view.setState(state);
setNoDocument(view);
return;
}
if (doc.isBinary) {
return;
}
if (doc.filePath === '') {
logger.warn('File path should not be empty');
}
let state = editorStates.get(doc.filePath);
if (!state) {
state = newEditorState(doc.value, theme, settings, onScrollRef, debounceScroll, onSaveRef, [
languageCompartment.of([]),
]);
editorStates.set(doc.filePath, state);
}
view.setState(state);
setEditorDocument(
view,
theme,
editable,
languageCompartment,
autoFocusOnDocumentChange,
doc as TextEditorDocument,
);
}, [doc?.value, editable, doc?.filePath, autoFocusOnDocumentChange]);
return (
<div className={classNames('relative h-full', className)}>
{doc?.isBinary && <BinaryContent />}
<div className="h-full overflow-hidden" ref={containerRef} />
</div>
);
},
);
export default CodeMirrorEditor;
CodeMirrorEditor.displayName = 'CodeMirrorEditor';
function newEditorState(
content: string,
theme: Theme,
settings: EditorSettings | undefined,
onScrollRef: MutableRefObject<OnScrollCallback | undefined>,
debounceScroll: number,
onFileSaveRef: MutableRefObject<OnSaveCallback | undefined>,
extensions: Extension[],
) {
return EditorState.create({
doc: content,
extensions: [
EditorView.domEventHandlers({
scroll: debounce((event, view) => {
if (event.target !== view.scrollDOM) {
return;
}
onScrollRef.current?.({ left: view.scrollDOM.scrollLeft, top: view.scrollDOM.scrollTop });
}, debounceScroll),
keydown: (event, view) => {
if (view.state.readOnly) {
view.dispatch({
effects: [readOnlyTooltipStateEffect.of(event.key !== 'Escape')],
});
return true;
}
return false;
},
}),
getTheme(theme, settings),
history(),
keymap.of([
...defaultKeymap,
...historyKeymap,
...searchKeymap,
{ key: 'Tab', run: acceptCompletion },
{
key: 'Mod-s',
preventDefault: true,
run: () => {
onFileSaveRef.current?.();
return true;
},
},
indentKeyBinding,
]),
indentUnit.of('\t'),
autocompletion({
closeOnBlur: false,
}),
tooltips({
position: 'absolute',
parent: document.body,
tooltipSpace: (view) => {
const rect = view.dom.getBoundingClientRect();
return {
top: rect.top - 50,
left: rect.left,
bottom: rect.bottom,
right: rect.right + 10,
};
},
}),
closeBrackets(),
lineNumbers(),
scrollPastEnd(),
dropCursor(),
drawSelection(),
bracketMatching(),
EditorState.tabSize.of(settings?.tabSize ?? 2),
indentOnInput(),
editableTooltipField,
editableStateField,
EditorState.readOnly.from(editableStateField, (editable) => !editable),
highlightActiveLineGutter(),
highlightActiveLine(),
foldGutter({
markerDOM: (open) => {
const icon = document.createElement('div');
icon.className = `fold-icon ${open ? 'i-ph-caret-down-bold' : 'i-ph-caret-right-bold'}`;
return icon;
},
}),
...extensions,
],
});
}
function setNoDocument(view: EditorView) {
view.dispatch({
selection: { anchor: 0 },
changes: {
from: 0,
to: view.state.doc.length,
insert: '',
},
});
view.scrollDOM.scrollTo(0, 0);
}
function setEditorDocument(
view: EditorView,
theme: Theme,
editable: boolean,
languageCompartment: Compartment,
autoFocus: boolean,
doc: TextEditorDocument,
) {
if (doc.value !== view.state.doc.toString()) {
view.dispatch({
selection: { anchor: 0 },
changes: {
from: 0,
to: view.state.doc.length,
insert: doc.value,
},
});
}
view.dispatch({
effects: [editableStateEffect.of(editable && !doc.isBinary)],
});
getLanguage(doc.filePath).then((languageSupport) => {
if (!languageSupport) {
return;
}
view.dispatch({
effects: [languageCompartment.reconfigure([languageSupport]), reconfigureTheme(theme)],
});
requestAnimationFrame(() => {
const currentLeft = view.scrollDOM.scrollLeft;
const currentTop = view.scrollDOM.scrollTop;
const newLeft = doc.scroll?.left ?? 0;
const newTop = doc.scroll?.top ?? 0;
const needsScrolling = currentLeft !== newLeft || currentTop !== newTop;
if (autoFocus && editable) {
if (needsScrolling) {
// we have to wait until the scroll position was changed before we can set the focus
view.scrollDOM.addEventListener(
'scroll',
() => {
view.focus();
},
{ once: true },
);
} else {
// if the scroll position is still the same we can focus immediately
view.focus();
}
}
view.scrollDOM.scrollTo(newLeft, newTop);
});
});
}
function getReadOnlyTooltip(state: EditorState) {
if (!state.readOnly) {
return [];
}
return state.selection.ranges
.filter((range) => {
return range.empty;
})
.map((range) => {
return {
pos: range.head,
above: true,
strictSide: true,
arrow: true,
create: () => {
const divElement = document.createElement('div');
divElement.className = 'cm-readonly-tooltip';
divElement.textContent = 'Cannot edit file while AI response is being generated';
return { dom: divElement };
},
};
});
}

View File

@@ -0,0 +1,192 @@
import { Compartment, type Extension } from '@codemirror/state';
import { EditorView } from '@codemirror/view';
import { vscodeDark, vscodeLight } from '@uiw/codemirror-theme-vscode';
import type { Theme } from '~/types/theme.js';
import type { EditorSettings } from './CodeMirrorEditor.js';
export const darkTheme = EditorView.theme({}, { dark: true });
export const themeSelection = new Compartment();
export function getTheme(theme: Theme, settings: EditorSettings = {}): Extension {
return [
getEditorTheme(settings),
theme === 'dark' ? themeSelection.of([getDarkTheme()]) : themeSelection.of([getLightTheme()]),
];
}
export function reconfigureTheme(theme: Theme) {
return themeSelection.reconfigure(theme === 'dark' ? getDarkTheme() : getLightTheme());
}
function getEditorTheme(settings: EditorSettings) {
return EditorView.theme({
'&': {
fontSize: settings.fontSize ?? '12px',
},
'&.cm-editor': {
height: '100%',
background: 'var(--cm-backgroundColor)',
color: 'var(--cm-textColor)',
},
'.cm-cursor': {
borderLeft: 'var(--cm-cursor-width) solid var(--cm-cursor-backgroundColor)',
},
'.cm-scroller': {
lineHeight: '1.5',
'&:focus-visible': {
outline: 'none',
},
},
'.cm-line': {
padding: '0 0 0 4px',
},
'&.cm-focused > .cm-scroller > .cm-selectionLayer .cm-selectionBackground': {
backgroundColor: 'var(--cm-selection-backgroundColorFocused) !important',
opacity: 'var(--cm-selection-backgroundOpacityFocused, 0.3)',
},
'&:not(.cm-focused) > .cm-scroller > .cm-selectionLayer .cm-selectionBackground': {
backgroundColor: 'var(--cm-selection-backgroundColorBlured)',
opacity: 'var(--cm-selection-backgroundOpacityBlured, 0.3)',
},
'&.cm-focused > .cm-scroller .cm-matchingBracket': {
backgroundColor: 'var(--cm-matching-bracket)',
},
'.cm-activeLine': {
background: 'var(--cm-activeLineBackgroundColor)',
},
'.cm-gutters': {
background: 'var(--cm-gutter-backgroundColor)',
borderRight: 0,
color: 'var(--cm-gutter-textColor)',
},
'.cm-gutter': {
'&.cm-lineNumbers': {
fontFamily: 'Roboto Mono, monospace',
fontSize: settings.gutterFontSize ?? settings.fontSize ?? '12px',
minWidth: '40px',
},
'& .cm-activeLineGutter': {
background: 'transparent',
color: 'var(--cm-gutter-activeLineTextColor)',
},
'&.cm-foldGutter .cm-gutterElement > .fold-icon': {
cursor: 'pointer',
color: 'var(--cm-foldGutter-textColor)',
transform: 'translateY(2px)',
'&:hover': {
color: 'var(--cm-foldGutter-textColorHover)',
},
},
},
'.cm-foldGutter .cm-gutterElement': {
padding: '0 4px',
},
'.cm-tooltip-autocomplete > ul > li': {
minHeight: '18px',
},
'.cm-panel.cm-search label': {
marginLeft: '2px',
fontSize: '12px',
},
'.cm-panel.cm-search .cm-button': {
fontSize: '12px',
},
'.cm-panel.cm-search .cm-textfield': {
fontSize: '12px',
},
'.cm-panel.cm-search input[type=checkbox]': {
position: 'relative',
transform: 'translateY(2px)',
marginRight: '4px',
},
'.cm-panels': {
borderColor: 'var(--cm-panels-borderColor)',
},
'.cm-panels-bottom': {
borderTop: '1px solid var(--cm-panels-borderColor)',
backgroundColor: 'transparent',
},
'.cm-panel.cm-search': {
background: 'var(--cm-search-backgroundColor)',
color: 'var(--cm-search-textColor)',
padding: '8px',
},
'.cm-search .cm-button': {
background: 'var(--cm-search-button-backgroundColor)',
borderColor: 'var(--cm-search-button-borderColor)',
color: 'var(--cm-search-button-textColor)',
borderRadius: '4px',
'&:hover': {
color: 'var(--cm-search-button-textColorHover)',
},
'&:focus-visible': {
outline: 'none',
borderColor: 'var(--cm-search-button-borderColorFocused)',
},
'&:hover:not(:focus-visible)': {
background: 'var(--cm-search-button-backgroundColorHover)',
borderColor: 'var(--cm-search-button-borderColorHover)',
},
'&:hover:focus-visible': {
background: 'var(--cm-search-button-backgroundColorHover)',
borderColor: 'var(--cm-search-button-borderColorFocused)',
},
},
'.cm-panel.cm-search [name=close]': {
top: '6px',
right: '6px',
padding: '0 6px',
fontSize: '1rem',
backgroundColor: 'var(--cm-search-closeButton-backgroundColor)',
color: 'var(--cm-search-closeButton-textColor)',
'&:hover': {
'border-radius': '6px',
color: 'var(--cm-search-closeButton-textColorHover)',
backgroundColor: 'var(--cm-search-closeButton-backgroundColorHover)',
},
},
'.cm-search input': {
background: 'var(--cm-search-input-backgroundColor)',
borderColor: 'var(--cm-search-input-borderColor)',
color: 'var(--cm-search-input-textColor)',
outline: 'none',
borderRadius: '4px',
'&:focus-visible': {
borderColor: 'var(--cm-search-input-borderColorFocused)',
},
},
'.cm-tooltip': {
background: 'var(--cm-tooltip-backgroundColor)',
border: '1px solid transparent',
borderColor: 'var(--cm-tooltip-borderColor)',
color: 'var(--cm-tooltip-textColor)',
},
'.cm-tooltip.cm-tooltip-autocomplete ul li[aria-selected]': {
background: 'var(--cm-tooltip-backgroundColorSelected)',
color: 'var(--cm-tooltip-textColorSelected)',
},
'.cm-searchMatch': {
backgroundColor: 'var(--cm-searchMatch-backgroundColor)',
},
'.cm-tooltip.cm-readonly-tooltip': {
padding: '4px',
whiteSpace: 'nowrap',
backgroundColor: 'var(--bolt-elements-bg-depth-2)',
borderColor: 'var(--bolt-elements-borderColorActive)',
'& .cm-tooltip-arrow:before': {
borderTopColor: 'var(--bolt-elements-borderColorActive)',
},
'& .cm-tooltip-arrow:after': {
borderTopColor: 'transparent',
},
},
});
}
function getLightTheme() {
return vscodeLight;
}
function getDarkTheme() {
return vscodeDark;
}

View File

@@ -0,0 +1,68 @@
import { indentLess } from '@codemirror/commands';
import { indentUnit } from '@codemirror/language';
import { EditorSelection, EditorState, Line, type ChangeSpec } from '@codemirror/state';
import { EditorView, type KeyBinding } from '@codemirror/view';
export const indentKeyBinding: KeyBinding = {
key: 'Tab',
run: indentMore,
shift: indentLess,
};
function indentMore({ state, dispatch }: EditorView) {
if (state.readOnly) {
return false;
}
dispatch(
state.update(
changeBySelectedLine(state, (from, to, changes) => {
changes.push({ from, to, insert: state.facet(indentUnit) });
}),
{ userEvent: 'input.indent' },
),
);
return true;
}
function changeBySelectedLine(
state: EditorState,
cb: (from: number, to: number | undefined, changes: ChangeSpec[], line: Line) => void,
) {
return state.changeByRange((range) => {
const changes: ChangeSpec[] = [];
const line = state.doc.lineAt(range.from);
// just insert single indent unit at the current cursor position
if (range.from === range.to) {
cb(range.from, undefined, changes, line);
}
// handle the case when multiple characters are selected in a single line
else if (range.from < range.to && range.to <= line.to) {
cb(range.from, range.to, changes, line);
} else {
let atLine = -1;
// handle the case when selection spans multiple lines
for (let pos = range.from; pos <= range.to; ) {
const line = state.doc.lineAt(pos);
if (line.number > atLine && (range.empty || range.to > line.from)) {
cb(line.from, undefined, changes, line);
atLine = line.number;
}
pos = line.to + 1;
}
}
const changeSet = state.changes(changes);
return {
changes,
range: EditorSelection.range(changeSet.mapPos(range.anchor, 1), changeSet.mapPos(range.head, 1)),
};
});
}

View File

@@ -0,0 +1,105 @@
import { LanguageDescription } from '@codemirror/language';
export const supportedLanguages = [
LanguageDescription.of({
name: 'TS',
extensions: ['ts'],
async load() {
return import('@codemirror/lang-javascript').then((module) => module.javascript({ typescript: true }));
},
}),
LanguageDescription.of({
name: 'JS',
extensions: ['js', 'mjs', 'cjs'],
async load() {
return import('@codemirror/lang-javascript').then((module) => module.javascript());
},
}),
LanguageDescription.of({
name: 'TSX',
extensions: ['tsx'],
async load() {
return import('@codemirror/lang-javascript').then((module) => module.javascript({ jsx: true, typescript: true }));
},
}),
LanguageDescription.of({
name: 'JSX',
extensions: ['jsx'],
async load() {
return import('@codemirror/lang-javascript').then((module) => module.javascript({ jsx: true }));
},
}),
LanguageDescription.of({
name: 'HTML',
extensions: ['html'],
async load() {
return import('@codemirror/lang-html').then((module) => module.html());
},
}),
LanguageDescription.of({
name: 'CSS',
extensions: ['css'],
async load() {
return import('@codemirror/lang-css').then((module) => module.css());
},
}),
LanguageDescription.of({
name: 'SASS',
extensions: ['sass'],
async load() {
return import('@codemirror/lang-sass').then((module) => module.sass({ indented: true }));
},
}),
LanguageDescription.of({
name: 'SCSS',
extensions: ['scss'],
async load() {
return import('@codemirror/lang-sass').then((module) => module.sass({ indented: false }));
},
}),
LanguageDescription.of({
name: 'JSON',
extensions: ['json'],
async load() {
return import('@codemirror/lang-json').then((module) => module.json());
},
}),
LanguageDescription.of({
name: 'Markdown',
extensions: ['md'],
async load() {
return import('@codemirror/lang-markdown').then((module) => module.markdown());
},
}),
LanguageDescription.of({
name: 'Wasm',
extensions: ['wat'],
async load() {
return import('@codemirror/lang-wast').then((module) => module.wast());
},
}),
LanguageDescription.of({
name: 'Python',
extensions: ['py'],
async load() {
return import('@codemirror/lang-python').then((module) => module.python());
},
}),
LanguageDescription.of({
name: 'C++',
extensions: ['cpp'],
async load() {
return import('@codemirror/lang-cpp').then((module) => module.cpp());
},
}),
];
export async function getLanguage(fileName: string) {
const languageDescription = LanguageDescription.matchFilename(supportedLanguages, fileName);
if (languageDescription) {
return await languageDescription.load();
}
return undefined;
}

View File

@@ -0,0 +1,41 @@
import { useStore } from '@nanostores/react';
import { ClientOnly } from 'remix-utils/client-only';
import { chatStore } from '~/lib/stores/chat';
import { classNames } from '~/utils/classNames';
import { HeaderActionButtons } from './HeaderActionButtons.client';
import { ChatDescription } from '~/lib/persistence/ChatDescription.client';
export function Header() {
const chat = useStore(chatStore);
return (
<header
className={classNames(
'flex items-center bg-bolt-elements-background-depth-1 p-5 border-b h-[var(--header-height)]',
{
'border-transparent': !chat.started,
'border-bolt-elements-borderColor': chat.started,
},
)}
>
<div className="flex items-center gap-2 z-logo text-bolt-elements-textPrimary cursor-pointer">
<div className="i-ph:sidebar-simple-duotone text-xl" />
<a href="/" className="text-2xl font-semibold text-accent flex items-center">
<span className="i-bolt:logo-text?mask w-[46px] inline-block" />
</a>
</div>
<span className="flex-1 px-4 truncate text-center text-bolt-elements-textPrimary">
<ClientOnly>{() => <ChatDescription />}</ClientOnly>
</span>
{chat.started && (
<ClientOnly>
{() => (
<div className="mr-1">
<HeaderActionButtons />
</div>
)}
</ClientOnly>
)}
</header>
);
}

View File

@@ -0,0 +1,68 @@
import { useStore } from '@nanostores/react';
import { chatStore } from '~/lib/stores/chat';
import { workbenchStore } from '~/lib/stores/workbench';
import { classNames } from '~/utils/classNames';
interface HeaderActionButtonsProps {}
export function HeaderActionButtons({}: HeaderActionButtonsProps) {
const showWorkbench = useStore(workbenchStore.showWorkbench);
const { showChat } = useStore(chatStore);
const canHideChat = showWorkbench || !showChat;
return (
<div className="flex">
<div className="flex border border-bolt-elements-borderColor rounded-md overflow-hidden">
<Button
active={showChat}
disabled={!canHideChat}
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>
);
}
interface ButtonProps {
active?: boolean;
disabled?: boolean;
children?: any;
onClick?: VoidFunction;
}
function Button({ active = false, disabled = false, children, onClick }: 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,
})}
onClick={onClick}
>
{children}
</button>
);
}

View File

@@ -0,0 +1,64 @@
import * as Dialog from '@radix-ui/react-dialog';
import { useEffect, useRef, useState } from 'react';
import { type ChatHistoryItem } from '~/lib/persistence';
interface HistoryItemProps {
item: ChatHistoryItem;
onDelete?: (event: React.UIEvent) => void;
}
export function HistoryItem({ item, onDelete }: HistoryItemProps) {
const [hovering, setHovering] = useState(false);
const hoverRef = useRef<HTMLDivElement>(null);
useEffect(() => {
let timeout: NodeJS.Timeout | undefined;
function mouseEnter() {
setHovering(true);
if (timeout) {
clearTimeout(timeout);
}
}
function mouseLeave() {
setHovering(false);
}
hoverRef.current?.addEventListener('mouseenter', mouseEnter);
hoverRef.current?.addEventListener('mouseleave', mouseLeave);
return () => {
hoverRef.current?.removeEventListener('mouseenter', mouseEnter);
hoverRef.current?.removeEventListener('mouseleave', mouseLeave);
};
}, []);
return (
<div
ref={hoverRef}
className="group rounded-md text-bolt-elements-textSecondary hover:text-bolt-elements-textPrimary hover:bg-bolt-elements-background-depth-3 overflow-hidden flex justify-between items-center px-2 py-1"
>
<a href={`/chat/${item.urlId}`} className="flex w-full relative truncate block">
{item.description}
<div className="absolute right-0 z-1 top-0 bottom-0 bg-gradient-to-l from-bolt-elements-background-depth-2 group-hover:from-bolt-elements-background-depth-3 to-transparent w-10 flex justify-end group-hover:w-15 group-hover:from-45%">
{hovering && (
<div className="flex items-center p-1 text-bolt-elements-textSecondary hover:text-bolt-elements-item-contentDanger">
<Dialog.Trigger asChild>
<button
className="i-ph:trash scale-110"
onClick={(event) => {
// we prevent the default so we don't trigger the anchor above
event.preventDefault();
onDelete?.(event);
}}
/>
</Dialog.Trigger>
</div>
)}
</div>
</a>
</div>
);
}

View File

@@ -0,0 +1,179 @@
import { motion, type Variants } from 'framer-motion';
import { useCallback, useEffect, useRef, useState } from 'react';
import { toast } from 'react-toastify';
import { Dialog, DialogButton, DialogDescription, DialogRoot, DialogTitle } from '~/components/ui/Dialog';
import { IconButton } from '~/components/ui/IconButton';
import { ThemeSwitch } from '~/components/ui/ThemeSwitch';
import { db, deleteById, getAll, chatId, type ChatHistoryItem } from '~/lib/persistence';
import { cubicEasingFn } from '~/utils/easings';
import { logger } from '~/utils/logger';
import { HistoryItem } from './HistoryItem';
import { binDates } from './date-binning';
const menuVariants = {
closed: {
opacity: 0,
visibility: 'hidden',
left: '-150px',
transition: {
duration: 0.2,
ease: cubicEasingFn,
},
},
open: {
opacity: 1,
visibility: 'initial',
left: 0,
transition: {
duration: 0.2,
ease: cubicEasingFn,
},
},
} satisfies Variants;
type DialogContent = { type: 'delete'; item: ChatHistoryItem } | null;
export function Menu() {
const menuRef = useRef<HTMLDivElement>(null);
const [list, setList] = useState<ChatHistoryItem[]>([]);
const [open, setOpen] = useState(false);
const [dialogContent, setDialogContent] = useState<DialogContent>(null);
const loadEntries = useCallback(() => {
if (db) {
getAll(db)
.then((list) => list.filter((item) => item.urlId && item.description))
.then(setList)
.catch((error) => toast.error(error.message));
}
}, []);
const deleteItem = useCallback((event: React.UIEvent, item: ChatHistoryItem) => {
event.preventDefault();
if (db) {
deleteById(db, item.id)
.then(() => {
loadEntries();
if (chatId.get() === item.id) {
// hard page navigation to clear the stores
window.location.pathname = '/';
}
})
.catch((error) => {
toast.error('Failed to delete conversation');
logger.error(error);
});
}
}, []);
const closeDialog = () => {
setDialogContent(null);
};
useEffect(() => {
if (open) {
loadEntries();
}
}, [open]);
useEffect(() => {
const enterThreshold = 40;
const exitThreshold = 40;
function onMouseMove(event: MouseEvent) {
if (event.pageX < enterThreshold) {
setOpen(true);
}
if (menuRef.current && event.clientX > menuRef.current.getBoundingClientRect().right + exitThreshold) {
setOpen(false);
}
}
window.addEventListener('mousemove', onMouseMove);
return () => {
window.removeEventListener('mousemove', onMouseMove);
};
}, []);
return (
<motion.div
ref={menuRef}
initial="closed"
animate={open ? 'open' : 'closed'}
variants={menuVariants}
className="flex flex-col side-menu fixed top-0 w-[350px] h-full bg-bolt-elements-background-depth-2 border-r rounded-r-3xl border-bolt-elements-borderColor z-sidebar shadow-xl shadow-bolt-elements-sidebar-dropdownShadow text-sm"
>
<div className="flex items-center h-[var(--header-height)]">{/* Placeholder */}</div>
<div className="flex-1 flex flex-col h-full w-full overflow-hidden">
<div className="p-4">
<a
href="/"
className="flex gap-2 items-center bg-bolt-elements-sidebar-buttonBackgroundDefault text-bolt-elements-sidebar-buttonText hover:bg-bolt-elements-sidebar-buttonBackgroundHover rounded-md p-2 transition-theme"
>
<span className="inline-block i-bolt:chat scale-110" />
Start new chat
</a>
</div>
<div className="text-bolt-elements-textPrimary font-medium pl-6 pr-5 my-2">Your Chats</div>
<div className="flex-1 overflow-scroll pl-4 pr-5 pb-5">
{list.length === 0 && <div className="pl-2 text-bolt-elements-textTertiary">No previous conversations</div>}
<DialogRoot open={dialogContent !== null}>
{binDates(list).map(({ category, items }) => (
<div key={category} className="mt-4 first:mt-0 space-y-1">
<div className="text-bolt-elements-textTertiary sticky top-0 z-1 bg-bolt-elements-background-depth-2 pl-2 pt-2 pb-1">
{category}
</div>
{items.map((item) => (
<HistoryItem key={item.id} item={item} onDelete={() => setDialogContent({ type: 'delete', item })} />
))}
</div>
))}
<Dialog onBackdrop={closeDialog} onClose={closeDialog}>
{dialogContent?.type === 'delete' && (
<>
<DialogTitle>Delete Chat?</DialogTitle>
<DialogDescription asChild>
<div>
<p>
You are about to delete <strong>{dialogContent.item.description}</strong>.
</p>
<p className="mt-1">Are you sure you want to delete this chat?</p>
</div>
</DialogDescription>
<div className="px-5 pb-4 bg-bolt-elements-background-depth-2 flex gap-2 justify-end">
<DialogButton type="secondary" onClick={closeDialog}>
Cancel
</DialogButton>
<DialogButton
type="danger"
onClick={(event) => {
deleteItem(event, dialogContent.item);
closeDialog();
}}
>
Delete
</DialogButton>
</div>
</>
)}
</Dialog>
</DialogRoot>
</div>
<div className="flex items-center border-t border-bolt-elements-borderColor p-4">
<a href="/logout">
<IconButton className="p-1.5 gap-1.5">
<>
Logout <span className="i-ph:sign-out text-lg" />
</>
</IconButton>
</a>
<ThemeSwitch className="ml-auto" />
</div>
</div>
</motion.div>
);
}

View File

@@ -0,0 +1,59 @@
import { format, isAfter, isThisWeek, isThisYear, isToday, isYesterday, subDays } from 'date-fns';
import type { ChatHistoryItem } from '~/lib/persistence';
type Bin = { category: string; items: ChatHistoryItem[] };
export function binDates(_list: ChatHistoryItem[]) {
const list = _list.toSorted((a, b) => Date.parse(b.timestamp) - Date.parse(a.timestamp));
const binLookup: Record<string, Bin> = {};
const bins: Array<Bin> = [];
list.forEach((item) => {
const category = dateCategory(new Date(item.timestamp));
if (!(category in binLookup)) {
const bin = {
category,
items: [item],
};
binLookup[category] = bin;
bins.push(bin);
} else {
binLookup[category].items.push(item);
}
});
return bins;
}
function dateCategory(date: Date) {
if (isToday(date)) {
return 'Today';
}
if (isYesterday(date)) {
return 'Yesterday';
}
if (isThisWeek(date)) {
// e.g., "Monday"
return format(date, 'eeee');
}
const thirtyDaysAgo = subDays(new Date(), 30);
if (isAfter(date, thirtyDaysAgo)) {
return 'Last 30 Days';
}
if (isThisYear(date)) {
// e.g., "July"
return format(date, 'MMMM');
}
// e.g., "July 2023"
return format(date, 'MMMM yyyy');
}

View File

@@ -0,0 +1,133 @@
import * as RadixDialog from '@radix-ui/react-dialog';
import { motion, type Variants } from 'framer-motion';
import React, { memo, type ReactNode } from 'react';
import { classNames } from '~/utils/classNames';
import { cubicEasingFn } from '~/utils/easings';
import { IconButton } from './IconButton';
export { Close as DialogClose, Root as DialogRoot } from '@radix-ui/react-dialog';
const transition = {
duration: 0.15,
ease: cubicEasingFn,
};
export const dialogBackdropVariants = {
closed: {
opacity: 0,
transition,
},
open: {
opacity: 1,
transition,
},
} satisfies Variants;
export const dialogVariants = {
closed: {
x: '-50%',
y: '-40%',
scale: 0.96,
opacity: 0,
transition,
},
open: {
x: '-50%',
y: '-50%',
scale: 1,
opacity: 1,
transition,
},
} satisfies Variants;
interface DialogButtonProps {
type: 'primary' | 'secondary' | 'danger';
children: ReactNode;
onClick?: (event: React.UIEvent) => void;
}
export const DialogButton = memo(({ type, children, onClick }: DialogButtonProps) => {
return (
<button
className={classNames(
'inline-flex h-[35px] items-center justify-center rounded-lg px-4 text-sm leading-none focus:outline-none',
{
'bg-bolt-elements-button-primary-background text-bolt-elements-button-primary-text hover:bg-bolt-elements-button-primary-backgroundHover':
type === 'primary',
'bg-bolt-elements-button-secondary-background text-bolt-elements-button-secondary-text hover:bg-bolt-elements-button-secondary-backgroundHover':
type === 'secondary',
'bg-bolt-elements-button-danger-background text-bolt-elements-button-danger-text hover:bg-bolt-elements-button-danger-backgroundHover':
type === 'danger',
},
)}
onClick={onClick}
>
{children}
</button>
);
});
export const DialogTitle = memo(({ className, children, ...props }: RadixDialog.DialogTitleProps) => {
return (
<RadixDialog.Title
className={classNames(
'px-5 py-4 flex items-center justify-between border-b border-bolt-elements-borderColor text-lg font-semibold leading-6 text-bolt-elements-textPrimary',
className,
)}
{...props}
>
{children}
</RadixDialog.Title>
);
});
export const DialogDescription = memo(({ className, children, ...props }: RadixDialog.DialogDescriptionProps) => {
return (
<RadixDialog.Description
className={classNames('px-5 py-4 text-bolt-elements-textPrimary text-md', className)}
{...props}
>
{children}
</RadixDialog.Description>
);
});
interface DialogProps {
children: ReactNode | ReactNode[];
className?: string;
onBackdrop?: (event: React.UIEvent) => void;
onClose?: (event: React.UIEvent) => void;
}
export const Dialog = memo(({ className, children, onBackdrop, onClose }: DialogProps) => {
return (
<RadixDialog.Portal>
<RadixDialog.Overlay onClick={onBackdrop} asChild>
<motion.div
className="bg-black/50 fixed inset-0 z-max"
initial="closed"
animate="open"
exit="closed"
variants={dialogBackdropVariants}
/>
</RadixDialog.Overlay>
<RadixDialog.Content asChild>
<motion.div
className={classNames(
'fixed top-[50%] left-[50%] z-max max-h-[85vh] w-[90vw] max-w-[450px] translate-x-[-50%] translate-y-[-50%] border border-bolt-elements-borderColor rounded-lg bg-bolt-elements-background-depth-2 shadow-lg focus:outline-none overflow-hidden',
className,
)}
initial="closed"
animate="open"
exit="closed"
variants={dialogVariants}
>
{children}
<RadixDialog.Close asChild onClick={onClose}>
<IconButton icon="i-ph:x" className="absolute top-[10px] right-[10px]" />
</RadixDialog.Close>
</motion.div>
</RadixDialog.Content>
</RadixDialog.Portal>
);
});

View File

@@ -0,0 +1,77 @@
import { memo } from 'react';
import { classNames } from '~/utils/classNames';
type IconSize = 'sm' | 'md' | 'lg' | 'xl' | 'xxl';
interface BaseIconButtonProps {
size?: IconSize;
className?: string;
iconClassName?: string;
disabledClassName?: string;
title?: string;
disabled?: boolean;
onClick?: (event: React.MouseEvent<HTMLButtonElement, MouseEvent>) => void;
}
type IconButtonWithoutChildrenProps = {
icon: string;
children?: undefined;
} & BaseIconButtonProps;
type IconButtonWithChildrenProps = {
icon?: undefined;
children: string | JSX.Element | JSX.Element[];
} & BaseIconButtonProps;
type IconButtonProps = IconButtonWithoutChildrenProps | IconButtonWithChildrenProps;
export const IconButton = memo(
({
icon,
size = 'xl',
className,
iconClassName,
disabledClassName,
disabled = false,
title,
onClick,
children,
}: IconButtonProps) => {
return (
<button
className={classNames(
'flex items-center 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',
{
[classNames('opacity-30', disabledClassName)]: disabled,
},
className,
)}
title={title}
disabled={disabled}
onClick={(event) => {
if (disabled) {
return;
}
onClick?.(event);
}}
>
{children ? children : <div className={classNames(icon, getIconSize(size), iconClassName)}></div>}
</button>
);
},
);
function getIconSize(size: IconSize) {
if (size === 'sm') {
return 'text-sm';
} else if (size === 'md') {
return 'text-md';
} else if (size === 'lg') {
return 'text-lg';
} else if (size === 'xl') {
return 'text-xl';
} else {
return 'text-2xl';
}
}

View File

@@ -0,0 +1,27 @@
import { memo, useEffect, useState } from 'react';
interface LoadingDotsProps {
text: string;
}
export const LoadingDots = memo(({ text }: LoadingDotsProps) => {
const [dotCount, setDotCount] = useState(0);
useEffect(() => {
const interval = setInterval(() => {
setDotCount((prevDotCount) => (prevDotCount + 1) % 4);
}, 500);
return () => clearInterval(interval);
}, []);
return (
<div className="flex justify-center items-center h-full">
<div className="relative">
<span>{text}</span>
<span className="absolute left-[calc(100%-12px)]">{'.'.repeat(dotCount)}</span>
<span className="invisible">...</span>
</div>
</div>
);
});

View File

@@ -0,0 +1,20 @@
import { memo } from 'react';
import { classNames } from '~/utils/classNames';
interface PanelHeaderProps {
className?: string;
children: React.ReactNode;
}
export const PanelHeader = memo(({ className, children }: PanelHeaderProps) => {
return (
<div
className={classNames(
'flex items-center gap-2 bg-bolt-elements-background-depth-2 text-bolt-elements-textSecondary border-b border-bolt-elements-borderColor px-4 py-1 min-h-[34px] text-sm',
className,
)}
>
{children}
</div>
);
});

View File

@@ -0,0 +1,36 @@
import { memo } from 'react';
import { classNames } from '~/utils/classNames';
interface PanelHeaderButtonProps {
className?: string;
disabledClassName?: string;
disabled?: boolean;
children: string | JSX.Element | Array<JSX.Element | string>;
onClick?: (event: React.MouseEvent<HTMLButtonElement, MouseEvent>) => void;
}
export const PanelHeaderButton = memo(
({ className, disabledClassName, disabled = false, children, onClick }: PanelHeaderButtonProps) => {
return (
<button
className={classNames(
'flex items-center shrink-0 gap-1.5 px-1.5 rounded-md py-0.5 text-bolt-elements-item-contentDefault bg-transparent enabled:hover:text-bolt-elements-item-contentActive enabled:hover:bg-bolt-elements-item-backgroundActive disabled:cursor-not-allowed',
{
[classNames('opacity-30', disabledClassName)]: disabled,
},
className,
)}
disabled={disabled}
onClick={(event) => {
if (disabled) {
return;
}
onClick?.(event);
}}
>
{children}
</button>
);
},
);

View File

@@ -0,0 +1,65 @@
import { motion } from 'framer-motion';
import { memo } from 'react';
import { classNames } from '~/utils/classNames';
import { cubicEasingFn } from '~/utils/easings';
import { genericMemo } from '~/utils/react';
interface SliderOption<T> {
value: T;
text: string;
}
export interface SliderOptions<T> {
left: SliderOption<T>;
right: SliderOption<T>;
}
interface SliderProps<T> {
selected: T;
options: SliderOptions<T>;
setSelected?: (selected: T) => void;
}
export const Slider = genericMemo(<T,>({ selected, options, setSelected }: SliderProps<T>) => {
const isLeftSelected = selected === options.left.value;
return (
<div className="flex items-center flex-wrap shrink-0 gap-1 bg-bolt-elements-background-depth-1 overflow-hidden rounded-full p-1">
<SliderButton selected={isLeftSelected} setSelected={() => setSelected?.(options.left.value)}>
{options.left.text}
</SliderButton>
<SliderButton selected={!isLeftSelected} setSelected={() => setSelected?.(options.right.value)}>
{options.right.text}
</SliderButton>
</div>
);
});
interface SliderButtonProps {
selected: boolean;
children: string | JSX.Element | Array<JSX.Element | string>;
setSelected: () => void;
}
const SliderButton = memo(({ selected, children, setSelected }: SliderButtonProps) => {
return (
<button
onClick={setSelected}
className={classNames(
'bg-transparent text-sm px-2.5 py-0.5 rounded-full relative',
selected
? 'text-bolt-elements-item-contentAccent'
: 'text-bolt-elements-item-contentDefault hover:text-bolt-elements-item-contentActive',
)}
>
<span className="relative z-10">{children}</span>
{selected && (
<motion.span
layoutId="pill-tab"
transition={{ duration: 0.2, ease: cubicEasingFn }}
className="absolute inset-0 z-0 bg-bolt-elements-item-backgroundAccent rounded-full"
></motion.span>
)}
</button>
);
});

View File

@@ -0,0 +1,29 @@
import { useStore } from '@nanostores/react';
import { memo, useEffect, useState } from 'react';
import { themeStore, toggleTheme } from '~/lib/stores/theme';
import { IconButton } from './IconButton';
interface ThemeSwitchProps {
className?: string;
}
export const ThemeSwitch = memo(({ className }: ThemeSwitchProps) => {
const theme = useStore(themeStore);
const [domLoaded, setDomLoaded] = useState(false);
useEffect(() => {
setDomLoaded(true);
}, []);
return (
domLoaded && (
<IconButton
className={className}
icon={theme === 'dark' ? 'i-ph-sun-dim-duotone' : 'i-ph-moon-stars-duotone'}
size="xl"
title="Toggle Theme"
onClick={toggleTheme}
/>
)
);
});

View File

@@ -0,0 +1,256 @@
import { useStore } from '@nanostores/react';
import { memo, useEffect, useMemo, useRef, useState } from 'react';
import { Panel, PanelGroup, PanelResizeHandle, type ImperativePanelHandle } from 'react-resizable-panels';
import {
CodeMirrorEditor,
type EditorDocument,
type EditorSettings,
type OnChangeCallback as OnEditorChange,
type OnSaveCallback as OnEditorSave,
type OnScrollCallback as OnEditorScroll,
} from '~/components/editor/codemirror/CodeMirrorEditor';
import { IconButton } from '~/components/ui/IconButton';
import { PanelHeader } from '~/components/ui/PanelHeader';
import { PanelHeaderButton } from '~/components/ui/PanelHeaderButton';
import { shortcutEventEmitter } from '~/lib/hooks';
import type { FileMap } from '~/lib/stores/files';
import { themeStore } from '~/lib/stores/theme';
import { workbenchStore } from '~/lib/stores/workbench';
import { classNames } from '~/utils/classNames';
import { WORK_DIR } from '~/utils/constants';
import { renderLogger } from '~/utils/logger';
import { isMobile } from '~/utils/mobile';
import { FileBreadcrumb } from './FileBreadcrumb';
import { FileTree } from './FileTree';
import { Terminal, type TerminalRef } from './terminal/Terminal';
interface EditorPanelProps {
files?: FileMap;
unsavedFiles?: Set<string>;
editorDocument?: EditorDocument;
selectedFile?: string | undefined;
isStreaming?: boolean;
onEditorChange?: OnEditorChange;
onEditorScroll?: OnEditorScroll;
onFileSelect?: (value?: string) => void;
onFileSave?: OnEditorSave;
onFileReset?: () => void;
}
const MAX_TERMINALS = 3;
const DEFAULT_TERMINAL_SIZE = 25;
const DEFAULT_EDITOR_SIZE = 100 - DEFAULT_TERMINAL_SIZE;
const editorSettings: EditorSettings = { tabSize: 2 };
export const EditorPanel = memo(
({
files,
unsavedFiles,
editorDocument,
selectedFile,
isStreaming,
onFileSelect,
onEditorChange,
onEditorScroll,
onFileSave,
onFileReset,
}: EditorPanelProps) => {
renderLogger.trace('EditorPanel');
const theme = useStore(themeStore);
const showTerminal = useStore(workbenchStore.showTerminal);
const terminalRefs = useRef<Array<TerminalRef | null>>([]);
const terminalPanelRef = useRef<ImperativePanelHandle>(null);
const terminalToggledByShortcut = useRef(false);
const [activeTerminal, setActiveTerminal] = useState(0);
const [terminalCount, setTerminalCount] = useState(1);
const activeFileSegments = useMemo(() => {
if (!editorDocument) {
return undefined;
}
return editorDocument.filePath.split('/');
}, [editorDocument]);
const activeFileUnsaved = useMemo(() => {
return editorDocument !== undefined && unsavedFiles?.has(editorDocument.filePath);
}, [editorDocument, unsavedFiles]);
useEffect(() => {
const unsubscribeFromEventEmitter = shortcutEventEmitter.on('toggleTerminal', () => {
terminalToggledByShortcut.current = true;
});
const unsubscribeFromThemeStore = themeStore.subscribe(() => {
for (const ref of Object.values(terminalRefs.current)) {
ref?.reloadStyles();
}
});
return () => {
unsubscribeFromEventEmitter();
unsubscribeFromThemeStore();
};
}, []);
useEffect(() => {
const { current: terminal } = terminalPanelRef;
if (!terminal) {
return;
}
const isCollapsed = terminal.isCollapsed();
if (!showTerminal && !isCollapsed) {
terminal.collapse();
} else if (showTerminal && isCollapsed) {
terminal.resize(DEFAULT_TERMINAL_SIZE);
}
terminalToggledByShortcut.current = false;
}, [showTerminal]);
const addTerminal = () => {
if (terminalCount < MAX_TERMINALS) {
setTerminalCount(terminalCount + 1);
setActiveTerminal(terminalCount);
}
};
return (
<PanelGroup direction="vertical">
<Panel defaultSize={showTerminal ? DEFAULT_EDITOR_SIZE : 100} minSize={20}>
<PanelGroup direction="horizontal">
<Panel defaultSize={20} minSize={10} collapsible>
<div className="flex flex-col border-r border-bolt-elements-borderColor h-full">
<PanelHeader>
<div className="i-ph:tree-structure-duotone shrink-0" />
Files
</PanelHeader>
<FileTree
className="h-full"
files={files}
hideRoot
unsavedFiles={unsavedFiles}
rootFolder={WORK_DIR}
selectedFile={selectedFile}
onFileSelect={onFileSelect}
/>
</div>
</Panel>
<PanelResizeHandle />
<Panel className="flex flex-col" defaultSize={80} minSize={20}>
<PanelHeader className="overflow-x-auto">
{activeFileSegments?.length && (
<div className="flex items-center flex-1 text-sm">
<FileBreadcrumb pathSegments={activeFileSegments} files={files} onFileSelect={onFileSelect} />
{activeFileUnsaved && (
<div className="flex gap-1 ml-auto -mr-1.5">
<PanelHeaderButton onClick={onFileSave}>
<div className="i-ph:floppy-disk-duotone" />
Save
</PanelHeaderButton>
<PanelHeaderButton onClick={onFileReset}>
<div className="i-ph:clock-counter-clockwise-duotone" />
Reset
</PanelHeaderButton>
</div>
)}
</div>
)}
</PanelHeader>
<div className="h-full flex-1 overflow-hidden">
<CodeMirrorEditor
theme={theme}
editable={!isStreaming && editorDocument !== undefined}
settings={editorSettings}
doc={editorDocument}
autoFocusOnDocumentChange={!isMobile()}
onScroll={onEditorScroll}
onChange={onEditorChange}
onSave={onFileSave}
/>
</div>
</Panel>
</PanelGroup>
</Panel>
<PanelResizeHandle />
<Panel
ref={terminalPanelRef}
defaultSize={showTerminal ? DEFAULT_TERMINAL_SIZE : 0}
minSize={10}
collapsible
onExpand={() => {
if (!terminalToggledByShortcut.current) {
workbenchStore.toggleTerminal(true);
}
}}
onCollapse={() => {
if (!terminalToggledByShortcut.current) {
workbenchStore.toggleTerminal(false);
}
}}
>
<div className="h-full">
<div className="bg-bolt-elements-terminals-background h-full flex flex-col">
<div className="flex items-center bg-bolt-elements-background-depth-2 border-y border-bolt-elements-borderColor gap-1.5 min-h-[34px] p-2">
{Array.from({ length: terminalCount }, (_, index) => {
const isActive = activeTerminal === index;
return (
<button
key={index}
className={classNames(
'flex items-center text-sm cursor-pointer gap-1.5 px-3 py-2 h-full whitespace-nowrap rounded-full',
{
'bg-bolt-elements-terminals-buttonBackground text-bolt-elements-textPrimary': isActive,
'bg-bolt-elements-background-depth-2 text-bolt-elements-textSecondary hover:bg-bolt-elements-terminals-buttonBackground':
!isActive,
},
)}
onClick={() => setActiveTerminal(index)}
>
<div className="i-ph:terminal-window-duotone text-lg" />
Terminal {terminalCount > 1 && index + 1}
</button>
);
})}
{terminalCount < MAX_TERMINALS && <IconButton icon="i-ph:plus" size="md" onClick={addTerminal} />}
<IconButton
className="ml-auto"
icon="i-ph:caret-down"
title="Close"
size="md"
onClick={() => workbenchStore.toggleTerminal(false)}
/>
</div>
{Array.from({ length: terminalCount }, (_, index) => {
const isActive = activeTerminal === index;
return (
<Terminal
key={index}
className={classNames('h-full overflow-hidden', {
hidden: !isActive,
})}
ref={(ref) => {
terminalRefs.current.push(ref);
}}
onTerminalReady={(terminal) => workbenchStore.attachTerminal(terminal)}
onTerminalResize={(cols, rows) => workbenchStore.onTerminalResize(cols, rows)}
theme={theme}
/>
);
})}
</div>
</div>
</Panel>
</PanelGroup>
);
},
);

View File

@@ -0,0 +1,148 @@
import * as DropdownMenu from '@radix-ui/react-dropdown-menu';
import { AnimatePresence, motion, type Variants } from 'framer-motion';
import { memo, useEffect, useRef, useState } from 'react';
import type { FileMap } from '~/lib/stores/files';
import { classNames } from '~/utils/classNames';
import { WORK_DIR } from '~/utils/constants';
import { cubicEasingFn } from '~/utils/easings';
import { renderLogger } from '~/utils/logger';
import FileTree from './FileTree';
const WORK_DIR_REGEX = new RegExp(`^${WORK_DIR.split('/').slice(0, -1).join('/').replaceAll('/', '\\/')}/`);
interface FileBreadcrumbProps {
files?: FileMap;
pathSegments?: string[];
onFileSelect?: (filePath: string) => void;
}
const contextMenuVariants = {
open: {
y: 0,
opacity: 1,
transition: {
duration: 0.15,
ease: cubicEasingFn,
},
},
close: {
y: 6,
opacity: 0,
transition: {
duration: 0.15,
ease: cubicEasingFn,
},
},
} satisfies Variants;
export const FileBreadcrumb = memo<FileBreadcrumbProps>(({ files, pathSegments = [], onFileSelect }) => {
renderLogger.trace('FileBreadcrumb');
const [activeIndex, setActiveIndex] = useState<number | null>(null);
const contextMenuRef = useRef<HTMLDivElement | null>(null);
const segmentRefs = useRef<(HTMLSpanElement | null)[]>([]);
const handleSegmentClick = (index: number) => {
setActiveIndex((prevIndex) => (prevIndex === index ? null : index));
};
useEffect(() => {
const handleOutsideClick = (event: MouseEvent) => {
if (
activeIndex !== null &&
!contextMenuRef.current?.contains(event.target as Node) &&
!segmentRefs.current.some((ref) => ref?.contains(event.target as Node))
) {
setActiveIndex(null);
}
};
document.addEventListener('mousedown', handleOutsideClick);
return () => {
document.removeEventListener('mousedown', handleOutsideClick);
};
}, [activeIndex]);
if (files === undefined || pathSegments.length === 0) {
return null;
}
return (
<div className="flex">
{pathSegments.map((segment, index) => {
const isLast = index === pathSegments.length - 1;
const path = pathSegments.slice(0, index).join('/');
if (!WORK_DIR_REGEX.test(path)) {
return null;
}
const isActive = activeIndex === index;
return (
<div key={index} className="relative flex items-center">
<DropdownMenu.Root open={isActive} modal={false}>
<DropdownMenu.Trigger asChild>
<span
ref={(ref) => (segmentRefs.current[index] = ref)}
className={classNames('flex items-center gap-1.5 cursor-pointer shrink-0', {
'text-bolt-elements-textTertiary hover:text-bolt-elements-textPrimary': !isActive,
'text-bolt-elements-textPrimary underline': isActive,
'pr-4': isLast,
})}
onClick={() => handleSegmentClick(index)}
>
{isLast && <div className="i-ph:file-duotone" />}
{segment}
</span>
</DropdownMenu.Trigger>
{index > 0 && !isLast && <span className="i-ph:caret-right inline-block mx-1" />}
<AnimatePresence>
{isActive && (
<DropdownMenu.Portal>
<DropdownMenu.Content
className="z-file-tree-breadcrumb"
asChild
align="start"
side="bottom"
avoidCollisions={false}
>
<motion.div
ref={contextMenuRef}
initial="close"
animate="open"
exit="close"
variants={contextMenuVariants}
>
<div className="rounded-lg overflow-hidden">
<div className="max-h-[50vh] min-w-[300px] overflow-scroll bg-bolt-elements-background-depth-1 border border-bolt-elements-borderColor shadow-sm rounded-lg">
<FileTree
files={files}
hideRoot
rootFolder={path}
collapsed
allowFolderSelection
selectedFile={`${path}/${segment}`}
onFileSelect={(filePath) => {
setActiveIndex(null);
onFileSelect?.(filePath);
}}
/>
</div>
</div>
<DropdownMenu.Arrow className="fill-bolt-elements-borderColor" />
</motion.div>
</DropdownMenu.Content>
</DropdownMenu.Portal>
)}
</AnimatePresence>
</DropdownMenu.Root>
</div>
);
})}
</div>
);
});

View File

@@ -0,0 +1,409 @@
import { memo, useEffect, useMemo, useState, type ReactNode } from 'react';
import type { FileMap } from '~/lib/stores/files';
import { classNames } from '~/utils/classNames';
import { createScopedLogger, renderLogger } from '~/utils/logger';
const logger = createScopedLogger('FileTree');
const NODE_PADDING_LEFT = 8;
const DEFAULT_HIDDEN_FILES = [/\/node_modules\//, /\/\.next/, /\/\.astro/];
interface Props {
files?: FileMap;
selectedFile?: string;
onFileSelect?: (filePath: string) => void;
rootFolder?: string;
hideRoot?: boolean;
collapsed?: boolean;
allowFolderSelection?: boolean;
hiddenFiles?: Array<string | RegExp>;
unsavedFiles?: Set<string>;
className?: string;
}
export const FileTree = memo(
({
files = {},
onFileSelect,
selectedFile,
rootFolder,
hideRoot = false,
collapsed = false,
allowFolderSelection = false,
hiddenFiles,
className,
unsavedFiles,
}: Props) => {
renderLogger.trace('FileTree');
const computedHiddenFiles = useMemo(() => [...DEFAULT_HIDDEN_FILES, ...(hiddenFiles ?? [])], [hiddenFiles]);
const fileList = useMemo(() => {
return buildFileList(files, rootFolder, hideRoot, computedHiddenFiles);
}, [files, rootFolder, hideRoot, computedHiddenFiles]);
const [collapsedFolders, setCollapsedFolders] = useState(() => {
return collapsed
? new Set(fileList.filter((item) => item.kind === 'folder').map((item) => item.fullPath))
: new Set<string>();
});
useEffect(() => {
if (collapsed) {
setCollapsedFolders(new Set(fileList.filter((item) => item.kind === 'folder').map((item) => item.fullPath)));
return;
}
setCollapsedFolders((prevCollapsed) => {
const newCollapsed = new Set<string>();
for (const folder of fileList) {
if (folder.kind === 'folder' && prevCollapsed.has(folder.fullPath)) {
newCollapsed.add(folder.fullPath);
}
}
return newCollapsed;
});
}, [fileList, collapsed]);
const filteredFileList = useMemo(() => {
const list = [];
let lastDepth = Number.MAX_SAFE_INTEGER;
for (const fileOrFolder of fileList) {
const depth = fileOrFolder.depth;
// if the depth is equal we reached the end of the collaped group
if (lastDepth === depth) {
lastDepth = Number.MAX_SAFE_INTEGER;
}
// ignore collapsed folders
if (collapsedFolders.has(fileOrFolder.fullPath)) {
lastDepth = Math.min(lastDepth, depth);
}
// ignore files and folders below the last collapsed folder
if (lastDepth < depth) {
continue;
}
list.push(fileOrFolder);
}
return list;
}, [fileList, collapsedFolders]);
const toggleCollapseState = (fullPath: string) => {
setCollapsedFolders((prevSet) => {
const newSet = new Set(prevSet);
if (newSet.has(fullPath)) {
newSet.delete(fullPath);
} else {
newSet.add(fullPath);
}
return newSet;
});
};
return (
<div className={classNames('text-sm', className)}>
{filteredFileList.map((fileOrFolder) => {
switch (fileOrFolder.kind) {
case 'file': {
return (
<File
key={fileOrFolder.id}
selected={selectedFile === fileOrFolder.fullPath}
file={fileOrFolder}
unsavedChanges={unsavedFiles?.has(fileOrFolder.fullPath)}
onClick={() => {
onFileSelect?.(fileOrFolder.fullPath);
}}
/>
);
}
case 'folder': {
return (
<Folder
key={fileOrFolder.id}
folder={fileOrFolder}
selected={allowFolderSelection && selectedFile === fileOrFolder.fullPath}
collapsed={collapsedFolders.has(fileOrFolder.fullPath)}
onClick={() => {
toggleCollapseState(fileOrFolder.fullPath);
}}
/>
);
}
default: {
return undefined;
}
}
})}
</div>
);
},
);
export default FileTree;
interface FolderProps {
folder: FolderNode;
collapsed: boolean;
selected?: boolean;
onClick: () => void;
}
function Folder({ folder: { depth, name }, collapsed, selected = false, onClick }: FolderProps) {
return (
<NodeButton
className={classNames('group', {
'bg-transparent text-bolt-elements-item-contentDefault hover:text-bolt-elements-item-contentActive hover:bg-bolt-elements-item-backgroundActive':
!selected,
'bg-bolt-elements-item-backgroundAccent text-bolt-elements-item-contentAccent': selected,
})}
depth={depth}
iconClasses={classNames({
'i-ph:caret-right scale-98': collapsed,
'i-ph:caret-down scale-98': !collapsed,
})}
onClick={onClick}
>
{name}
</NodeButton>
);
}
interface FileProps {
file: FileNode;
selected: boolean;
unsavedChanges?: boolean;
onClick: () => void;
}
function File({ file: { depth, name }, onClick, selected, unsavedChanges = false }: FileProps) {
return (
<NodeButton
className={classNames('group', {
'bg-transparent hover:bg-bolt-elements-item-backgroundActive text-bolt-elements-item-contentDefault': !selected,
'bg-bolt-elements-item-backgroundAccent text-bolt-elements-item-contentAccent': selected,
})}
depth={depth}
iconClasses={classNames('i-ph:file-duotone scale-98', {
'group-hover:text-bolt-elements-item-contentActive': !selected,
})}
onClick={onClick}
>
<div
className={classNames('flex items-center', {
'group-hover:text-bolt-elements-item-contentActive': !selected,
})}
>
<div className="flex-1 truncate pr-2">{name}</div>
{unsavedChanges && <span className="i-ph:circle-fill scale-68 shrink-0 text-orange-500" />}
</div>
</NodeButton>
);
}
interface ButtonProps {
depth: number;
iconClasses: string;
children: ReactNode;
className?: string;
onClick?: () => void;
}
function NodeButton({ depth, iconClasses, onClick, className, children }: ButtonProps) {
return (
<button
className={classNames(
'flex items-center gap-1.5 w-full pr-2 border-2 border-transparent text-faded py-0.5',
className,
)}
style={{ paddingLeft: `${6 + depth * NODE_PADDING_LEFT}px` }}
onClick={() => onClick?.()}
>
<div className={classNames('scale-120 shrink-0', iconClasses)}></div>
<div className="truncate w-full text-left">{children}</div>
</button>
);
}
type Node = FileNode | FolderNode;
interface BaseNode {
id: number;
depth: number;
name: string;
fullPath: string;
}
interface FileNode extends BaseNode {
kind: 'file';
}
interface FolderNode extends BaseNode {
kind: 'folder';
}
function buildFileList(
files: FileMap,
rootFolder = '/',
hideRoot: boolean,
hiddenFiles: Array<string | RegExp>,
): Node[] {
const folderPaths = new Set<string>();
const fileList: Node[] = [];
let defaultDepth = 0;
if (rootFolder === '/' && !hideRoot) {
defaultDepth = 1;
fileList.push({ kind: 'folder', name: '/', depth: 0, id: 0, fullPath: '/' });
}
for (const [filePath, dirent] of Object.entries(files)) {
const segments = filePath.split('/').filter((segment) => segment);
const fileName = segments.at(-1);
if (!fileName || isHiddenFile(filePath, fileName, hiddenFiles)) {
continue;
}
let currentPath = '';
let i = 0;
let depth = 0;
while (i < segments.length) {
const name = segments[i];
const fullPath = (currentPath += `/${name}`);
if (!fullPath.startsWith(rootFolder) || (hideRoot && fullPath === rootFolder)) {
i++;
continue;
}
if (i === segments.length - 1 && dirent?.type === 'file') {
fileList.push({
kind: 'file',
id: fileList.length,
name,
fullPath,
depth: depth + defaultDepth,
});
} else if (!folderPaths.has(fullPath)) {
folderPaths.add(fullPath);
fileList.push({
kind: 'folder',
id: fileList.length,
name,
fullPath,
depth: depth + defaultDepth,
});
}
i++;
depth++;
}
}
return sortFileList(rootFolder, fileList, hideRoot);
}
function isHiddenFile(filePath: string, fileName: string, hiddenFiles: Array<string | RegExp>) {
return hiddenFiles.some((pathOrRegex) => {
if (typeof pathOrRegex === 'string') {
return fileName === pathOrRegex;
}
return pathOrRegex.test(filePath);
});
}
/**
* Sorts the given list of nodes into a tree structure (still a flat list).
*
* This function organizes the nodes into a hierarchical structure based on their paths,
* with folders appearing before files and all items sorted alphabetically within their level.
*
* @note This function mutates the given `nodeList` array for performance reasons.
*
* @param rootFolder - The path of the root folder to start the sorting from.
* @param nodeList - The list of nodes to be sorted.
*
* @returns A new array of nodes sorted in depth-first order.
*/
function sortFileList(rootFolder: string, nodeList: Node[], hideRoot: boolean): Node[] {
logger.trace('sortFileList');
const nodeMap = new Map<string, Node>();
const childrenMap = new Map<string, Node[]>();
// pre-sort nodes by name and type
nodeList.sort((a, b) => compareNodes(a, b));
for (const node of nodeList) {
nodeMap.set(node.fullPath, node);
const parentPath = node.fullPath.slice(0, node.fullPath.lastIndexOf('/'));
if (parentPath !== rootFolder.slice(0, rootFolder.lastIndexOf('/'))) {
if (!childrenMap.has(parentPath)) {
childrenMap.set(parentPath, []);
}
childrenMap.get(parentPath)?.push(node);
}
}
const sortedList: Node[] = [];
const depthFirstTraversal = (path: string): void => {
const node = nodeMap.get(path);
if (node) {
sortedList.push(node);
}
const children = childrenMap.get(path);
if (children) {
for (const child of children) {
if (child.kind === 'folder') {
depthFirstTraversal(child.fullPath);
} else {
sortedList.push(child);
}
}
}
};
if (hideRoot) {
// if root is hidden, start traversal from its immediate children
const rootChildren = childrenMap.get(rootFolder) || [];
for (const child of rootChildren) {
depthFirstTraversal(child.fullPath);
}
} else {
depthFirstTraversal(rootFolder);
}
return sortedList;
}
function compareNodes(a: Node, b: Node): number {
if (a.kind !== b.kind) {
return a.kind === 'folder' ? -1 : 1;
}
return a.name.localeCompare(b.name, undefined, { numeric: true, sensitivity: 'base' });
}

View File

@@ -0,0 +1,83 @@
import { memo, useEffect, useRef } from 'react';
import { IconButton } from '~/components/ui/IconButton';
import type { PreviewInfo } from '~/lib/stores/previews';
interface PortDropdownProps {
activePreviewIndex: number;
setActivePreviewIndex: (index: number) => void;
isDropdownOpen: boolean;
setIsDropdownOpen: (value: boolean) => void;
setHasSelectedPreview: (value: boolean) => void;
previews: PreviewInfo[];
}
export const PortDropdown = memo(
({
activePreviewIndex,
setActivePreviewIndex,
isDropdownOpen,
setIsDropdownOpen,
setHasSelectedPreview,
previews,
}: PortDropdownProps) => {
const dropdownRef = useRef<HTMLDivElement>(null);
// sort previews, preserving original index
const sortedPreviews = previews
.map((previewInfo, index) => ({ ...previewInfo, index }))
.sort((a, b) => a.port - b.port);
// close dropdown if user clicks outside
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
setIsDropdownOpen(false);
}
};
if (isDropdownOpen) {
window.addEventListener('mousedown', handleClickOutside);
} else {
window.removeEventListener('mousedown', handleClickOutside);
}
return () => {
window.removeEventListener('mousedown', handleClickOutside);
};
}, [isDropdownOpen]);
return (
<div className="relative z-port-dropdown" ref={dropdownRef}>
<IconButton icon="i-ph:plug" onClick={() => setIsDropdownOpen(!isDropdownOpen)} />
{isDropdownOpen && (
<div className="absolute right-0 mt-2 bg-bolt-elements-background-depth-2 border border-bolt-elements-borderColor rounded shadow-sm min-w-[140px] dropdown-animation">
<div className="px-4 py-2 border-b border-bolt-elements-borderColor text-sm font-semibold text-bolt-elements-textPrimary">
Ports
</div>
{sortedPreviews.map((preview) => (
<div
key={preview.port}
className="flex items-center px-4 py-2 cursor-pointer hover:bg-bolt-elements-item-backgroundActive"
onClick={() => {
setActivePreviewIndex(preview.index);
setIsDropdownOpen(false);
setHasSelectedPreview(true);
}}
>
<span
className={
activePreviewIndex === preview.index
? 'text-bolt-elements-item-contentAccent'
: 'text-bolt-elements-item-contentDefault group-hover:text-bolt-elements-item-contentActive'
}
>
{preview.port}
</span>
</div>
))}
</div>
)}
</div>
);
},
);

View File

@@ -0,0 +1,124 @@
import { useStore } from '@nanostores/react';
import { memo, useCallback, useEffect, useRef, useState } from 'react';
import { IconButton } from '~/components/ui/IconButton';
import { workbenchStore } from '~/lib/stores/workbench';
import { PortDropdown } from './PortDropdown';
export const Preview = memo(() => {
const iframeRef = useRef<HTMLIFrameElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
const [activePreviewIndex, setActivePreviewIndex] = useState(0);
const [isPortDropdownOpen, setIsPortDropdownOpen] = useState(false);
const hasSelectedPreview = useRef(false);
const previews = useStore(workbenchStore.previews);
const activePreview = previews[activePreviewIndex];
const [url, setUrl] = useState('');
const [iframeUrl, setIframeUrl] = useState<string | undefined>();
useEffect(() => {
if (!activePreview) {
setUrl('');
setIframeUrl(undefined);
return;
}
const { baseUrl } = activePreview;
setUrl(baseUrl);
setIframeUrl(baseUrl);
}, [activePreview, iframeUrl]);
const validateUrl = useCallback(
(value: string) => {
if (!activePreview) {
return false;
}
const { baseUrl } = activePreview;
if (value === baseUrl) {
return true;
} else if (value.startsWith(baseUrl)) {
return ['/', '?', '#'].includes(value.charAt(baseUrl.length));
}
return false;
},
[activePreview],
);
const findMinPortIndex = useCallback(
(minIndex: number, preview: { port: number }, index: number, array: { port: number }[]) => {
return preview.port < array[minIndex].port ? index : minIndex;
},
[],
);
// when previews change, display the lowest port if user hasn't selected a preview
useEffect(() => {
if (previews.length > 1 && !hasSelectedPreview.current) {
const minPortIndex = previews.reduce(findMinPortIndex, 0);
setActivePreviewIndex(minPortIndex);
}
}, [previews]);
const reloadPreview = () => {
if (iframeRef.current) {
iframeRef.current.src = iframeRef.current.src;
}
};
return (
<div className="w-full h-full flex flex-col">
{isPortDropdownOpen && (
<div className="z-iframe-overlay w-full h-full absolute" onClick={() => setIsPortDropdownOpen(false)} />
)}
<div className="bg-bolt-elements-background-depth-2 p-2 flex items-center gap-1.5">
<IconButton icon="i-ph:arrow-clockwise" onClick={reloadPreview} />
<div
className="flex items-center gap-1 flex-grow bg-bolt-elements-preview-addressBar-background border border-bolt-elements-borderColor text-bolt-elements-preview-addressBar-text rounded-full px-3 py-1 text-sm hover:bg-bolt-elements-preview-addressBar-backgroundHover hover:focus-within:bg-bolt-elements-preview-addressBar-backgroundActive focus-within:bg-bolt-elements-preview-addressBar-backgroundActive
focus-within-border-bolt-elements-borderColorActive focus-within:text-bolt-elements-preview-addressBar-textActive"
>
<input
ref={inputRef}
className="w-full bg-transparent outline-none"
type="text"
value={url}
onChange={(event) => {
setUrl(event.target.value);
}}
onKeyDown={(event) => {
if (event.key === 'Enter' && validateUrl(url)) {
setIframeUrl(url);
if (inputRef.current) {
inputRef.current.blur();
}
}
}}
/>
</div>
{previews.length > 1 && (
<PortDropdown
activePreviewIndex={activePreviewIndex}
setActivePreviewIndex={setActivePreviewIndex}
isDropdownOpen={isPortDropdownOpen}
setHasSelectedPreview={(value) => (hasSelectedPreview.current = value)}
setIsDropdownOpen={setIsPortDropdownOpen}
previews={previews}
/>
)}
</div>
<div className="flex-1 border-t border-bolt-elements-borderColor">
{activePreview ? (
<iframe ref={iframeRef} className="border-none w-full h-full bg-white" src={iframeUrl} />
) : (
<div className="flex w-full h-full justify-center items-center bg-white">No preview available</div>
)}
</div>
</div>
);
});

View File

@@ -0,0 +1,187 @@
import { useStore } from '@nanostores/react';
import { motion, type HTMLMotionProps, type Variants } from 'framer-motion';
import { computed } from 'nanostores';
import { memo, useCallback, useEffect } from 'react';
import { toast } from 'react-toastify';
import {
type OnChangeCallback as OnEditorChange,
type OnScrollCallback as OnEditorScroll,
} from '~/components/editor/codemirror/CodeMirrorEditor';
import { IconButton } from '~/components/ui/IconButton';
import { PanelHeaderButton } from '~/components/ui/PanelHeaderButton';
import { Slider, type SliderOptions } from '~/components/ui/Slider';
import { workbenchStore, type WorkbenchViewType } from '~/lib/stores/workbench';
import { classNames } from '~/utils/classNames';
import { cubicEasingFn } from '~/utils/easings';
import { renderLogger } from '~/utils/logger';
import { EditorPanel } from './EditorPanel';
import { Preview } from './Preview';
interface WorkspaceProps {
chatStarted?: boolean;
isStreaming?: boolean;
}
const viewTransition = { ease: cubicEasingFn };
const sliderOptions: SliderOptions<WorkbenchViewType> = {
left: {
value: 'code',
text: 'Code',
},
right: {
value: 'preview',
text: 'Preview',
},
};
const workbenchVariants = {
closed: {
width: 0,
transition: {
duration: 0.2,
ease: cubicEasingFn,
},
},
open: {
width: 'var(--workbench-width)',
transition: {
duration: 0.2,
ease: cubicEasingFn,
},
},
} satisfies Variants;
export const Workbench = memo(({ chatStarted, isStreaming }: WorkspaceProps) => {
renderLogger.trace('Workbench');
const hasPreview = useStore(computed(workbenchStore.previews, (previews) => previews.length > 0));
const showWorkbench = useStore(workbenchStore.showWorkbench);
const selectedFile = useStore(workbenchStore.selectedFile);
const currentDocument = useStore(workbenchStore.currentDocument);
const unsavedFiles = useStore(workbenchStore.unsavedFiles);
const files = useStore(workbenchStore.files);
const selectedView = useStore(workbenchStore.currentView);
const setSelectedView = (view: WorkbenchViewType) => {
workbenchStore.currentView.set(view);
};
useEffect(() => {
if (hasPreview) {
setSelectedView('preview');
}
}, [hasPreview]);
useEffect(() => {
workbenchStore.setDocuments(files);
}, [files]);
const onEditorChange = useCallback<OnEditorChange>((update) => {
workbenchStore.setCurrentDocumentContent(update.content);
}, []);
const onEditorScroll = useCallback<OnEditorScroll>((position) => {
workbenchStore.setCurrentDocumentScrollPosition(position);
}, []);
const onFileSelect = useCallback((filePath: string | undefined) => {
workbenchStore.setSelectedFile(filePath);
}, []);
const onFileSave = useCallback(() => {
workbenchStore.saveCurrentDocument().catch(() => {
toast.error('Failed to update file content');
});
}, []);
const onFileReset = useCallback(() => {
workbenchStore.resetCurrentDocument();
}, []);
return (
chatStarted && (
<motion.div
initial="closed"
animate={showWorkbench ? 'open' : 'closed'}
variants={workbenchVariants}
className="z-workbench"
>
<div
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',
{
'left-[var(--workbench-left)]': showWorkbench,
'left-[100%]': !showWorkbench,
},
)}
>
<div className="absolute inset-0 px-6">
<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">
<Slider selected={selectedView} options={sliderOptions} setSelected={setSelectedView} />
<div className="ml-auto" />
{selectedView === 'code' && (
<PanelHeaderButton
className="mr-1 text-sm"
onClick={() => {
workbenchStore.toggleTerminal(!workbenchStore.showTerminal.get());
}}
>
<div className="i-ph:terminal" />
Toggle Terminal
</PanelHeaderButton>
)}
<IconButton
icon="i-ph:x-circle"
className="-mr-1"
size="xl"
onClick={() => {
workbenchStore.showWorkbench.set(false);
}}
/>
</div>
<div className="relative flex-1 overflow-hidden">
<View
initial={{ x: selectedView === 'code' ? 0 : '-100%' }}
animate={{ x: selectedView === 'code' ? 0 : '-100%' }}
>
<EditorPanel
editorDocument={currentDocument}
isStreaming={isStreaming}
selectedFile={selectedFile}
files={files}
unsavedFiles={unsavedFiles}
onFileSelect={onFileSelect}
onEditorScroll={onEditorScroll}
onEditorChange={onEditorChange}
onFileSave={onFileSave}
onFileReset={onFileReset}
/>
</View>
<View
initial={{ x: selectedView === 'preview' ? 0 : '100%' }}
animate={{ x: selectedView === 'preview' ? 0 : '100%' }}
>
<Preview />
</View>
</div>
</div>
</div>
</div>
</motion.div>
)
);
});
interface ViewProps extends HTMLMotionProps<'div'> {
children: JSX.Element;
}
const View = memo(({ children, ...props }: ViewProps) => {
return (
<motion.div className="absolute inset-0" transition={viewTransition} {...props}>
{children}
</motion.div>
);
});

View File

@@ -0,0 +1,86 @@
import { FitAddon } from '@xterm/addon-fit';
import { WebLinksAddon } from '@xterm/addon-web-links';
import { Terminal as XTerm } from '@xterm/xterm';
import { forwardRef, memo, useEffect, useImperativeHandle, useRef } from 'react';
import type { Theme } from '~/lib/stores/theme';
import { createScopedLogger } from '~/utils/logger';
import { getTerminalTheme } from './theme';
const logger = createScopedLogger('Terminal');
export interface TerminalRef {
reloadStyles: () => void;
}
export interface TerminalProps {
className?: string;
theme: Theme;
readonly?: boolean;
onTerminalReady?: (terminal: XTerm) => void;
onTerminalResize?: (cols: number, rows: number) => void;
}
export const Terminal = memo(
forwardRef<TerminalRef, TerminalProps>(({ className, theme, readonly, onTerminalReady, onTerminalResize }, ref) => {
const terminalElementRef = useRef<HTMLDivElement>(null);
const terminalRef = useRef<XTerm>();
useEffect(() => {
const element = terminalElementRef.current!;
const fitAddon = new FitAddon();
const webLinksAddon = new WebLinksAddon();
const terminal = new XTerm({
cursorBlink: true,
convertEol: true,
disableStdin: readonly,
theme: getTerminalTheme(readonly ? { cursor: '#00000000' } : {}),
fontSize: 12,
fontFamily: 'Menlo, courier-new, courier, monospace',
});
terminalRef.current = terminal;
terminal.loadAddon(fitAddon);
terminal.loadAddon(webLinksAddon);
terminal.open(element);
const resizeObserver = new ResizeObserver(() => {
fitAddon.fit();
onTerminalResize?.(terminal.cols, terminal.rows);
});
resizeObserver.observe(element);
logger.info('Attach terminal');
onTerminalReady?.(terminal);
return () => {
resizeObserver.disconnect();
terminal.dispose();
};
}, []);
useEffect(() => {
const terminal = terminalRef.current!;
// we render a transparent cursor in case the terminal is readonly
terminal.options.theme = getTerminalTheme(readonly ? { cursor: '#00000000' } : {});
terminal.options.disableStdin = readonly;
}, [theme, readonly]);
useImperativeHandle(ref, () => {
return {
reloadStyles: () => {
const terminal = terminalRef.current!;
terminal.options.theme = getTerminalTheme(readonly ? { cursor: '#00000000' } : {});
},
};
}, []);
return <div className={className} ref={terminalElementRef} />;
}),
);

View File

@@ -0,0 +1,36 @@
import type { ITheme } from '@xterm/xterm';
const style = getComputedStyle(document.documentElement);
const cssVar = (token: string) => style.getPropertyValue(token) || undefined;
export function getTerminalTheme(overrides?: ITheme): ITheme {
return {
cursor: cssVar('--bolt-elements-terminal-cursorColor'),
cursorAccent: cssVar('--bolt-elements-terminal-cursorColorAccent'),
foreground: cssVar('--bolt-elements-terminal-textColor'),
background: cssVar('--bolt-elements-terminal-backgroundColor'),
selectionBackground: cssVar('--bolt-elements-terminal-selection-backgroundColor'),
selectionForeground: cssVar('--bolt-elements-terminal-selection-textColor'),
selectionInactiveBackground: cssVar('--bolt-elements-terminal-selection-backgroundColorInactive'),
// ansi escape code colors
black: cssVar('--bolt-elements-terminal-color-black'),
red: cssVar('--bolt-elements-terminal-color-red'),
green: cssVar('--bolt-elements-terminal-color-green'),
yellow: cssVar('--bolt-elements-terminal-color-yellow'),
blue: cssVar('--bolt-elements-terminal-color-blue'),
magenta: cssVar('--bolt-elements-terminal-color-magenta'),
cyan: cssVar('--bolt-elements-terminal-color-cyan'),
white: cssVar('--bolt-elements-terminal-color-white'),
brightBlack: cssVar('--bolt-elements-terminal-color-brightBlack'),
brightRed: cssVar('--bolt-elements-terminal-color-brightRed'),
brightGreen: cssVar('--bolt-elements-terminal-color-brightGreen'),
brightYellow: cssVar('--bolt-elements-terminal-color-brightYellow'),
brightBlue: cssVar('--bolt-elements-terminal-color-brightBlue'),
brightMagenta: cssVar('--bolt-elements-terminal-color-brightMagenta'),
brightCyan: cssVar('--bolt-elements-terminal-color-brightCyan'),
brightWhite: cssVar('--bolt-elements-terminal-color-brightWhite'),
...overrides,
};
}