Merge pull request #1651 from xKevIsDev/improvements
feat: add expo app creation, enhance ui, and refactor code
This commit is contained in:
@@ -413,7 +413,7 @@ export const ControlPanel = ({ open, onClose }: ControlPanelProps) => {
|
||||
return (
|
||||
<RadixDialog.Root open={open}>
|
||||
<RadixDialog.Portal>
|
||||
<div className="fixed inset-0 flex items-center justify-center z-[100]">
|
||||
<div className="fixed inset-0 flex items-center justify-center z-[100] modern-scrollbar">
|
||||
<RadixDialog.Overlay asChild>
|
||||
<motion.div
|
||||
className="absolute inset-0 bg-black/70 dark:bg-black/80 backdrop-blur-sm"
|
||||
|
||||
@@ -313,7 +313,7 @@ export default function ConnectionDiagnostics() {
|
||||
{/* Netlify Connection Card */}
|
||||
<div className="p-4 rounded-lg bg-bolt-elements-background dark:bg-bolt-elements-background-depth-2 border border-bolt-elements-borderColor dark:border-bolt-elements-borderColor hover:border-bolt-elements-borderColorActive/70 dark:hover:border-bolt-elements-borderColorActive/70 transition-all duration-200 h-[180px] flex flex-col">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="i-si:netlify text-bolt-elements-item-contentAccent dark:text-bolt-elements-item-contentAccent w-4 h-4" />
|
||||
<div className="i-bolt:netlify text-bolt-elements-item-contentAccent dark:text-bolt-elements-item-contentAccent w-4 h-4" />
|
||||
<div className="text-sm font-medium text-bolt-elements-textPrimary dark:text-bolt-elements-textPrimary">
|
||||
Netlify Connection
|
||||
</div>
|
||||
|
||||
@@ -688,7 +688,7 @@ export default function GitHubConnection() {
|
||||
onClick={() => window.open('https://github.com/dashboard', '_blank', 'noopener,noreferrer')}
|
||||
className="flex items-center gap-2 hover:bg-bolt-elements-item-backgroundActive/10 hover:text-bolt-elements-textPrimary dark:hover:text-bolt-elements-textPrimary transition-colors"
|
||||
>
|
||||
<div className="i-ph:layout-dashboard w-4 h-4" />
|
||||
<div className="i-ph:layout w-4 h-4" />
|
||||
Dashboard
|
||||
</Button>
|
||||
<Button
|
||||
@@ -912,7 +912,7 @@ export default function GitHubConnection() {
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="i-ph:git-repository w-4 h-4 text-bolt-elements-icon-info dark:text-bolt-elements-icon-info" />
|
||||
<div className="i-ph:git-branch w-4 h-4 text-bolt-elements-icon-info dark:text-bolt-elements-icon-info" />
|
||||
<h5 className="text-sm font-medium text-bolt-elements-textPrimary group-hover:text-bolt-elements-item-contentAccent transition-colors">
|
||||
{repo.name}
|
||||
</h5>
|
||||
|
||||
@@ -431,7 +431,7 @@ export function PushToGitHubDialog({ isOpen, onClose, onPush }: PushToGitHubDial
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="i-ph:git-repository w-4 h-4 text-purple-500" />
|
||||
<div className="i-ph:git-branch w-4 h-4 text-purple-500" />
|
||||
<span className="text-sm font-medium text-gray-900 dark:text-white group-hover:text-purple-500">
|
||||
{repo.name}
|
||||
</span>
|
||||
|
||||
@@ -1058,7 +1058,7 @@ function RepositoryCard({ repo, onSelect }: { repo: GitHubRepoInfo; onSelect: ()
|
||||
<div className="p-4 rounded-lg bg-bolt-elements-background-depth-1 border border-bolt-elements-borderColor hover:border-bolt-elements-borderColorActive/70 transition-colors">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="i-ph:git-repository text-bolt-elements-textTertiary" />
|
||||
<span className="i-ph:git-branch text-bolt-elements-textTertiary" />
|
||||
<h3 className="font-medium text-bolt-elements-textPrimary dark:text-white">{repo.name}</h3>
|
||||
</div>
|
||||
<button
|
||||
|
||||
@@ -355,7 +355,7 @@ export function DataTab() {
|
||||
<CardHeader>
|
||||
<div className="flex items-center mb-2">
|
||||
<motion.div className="text-accent-500 mr-2" whileHover={{ scale: 1.1 }} whileTap={{ scale: 0.9 }}>
|
||||
<div className="i-ph-filter-duotone w-5 h-5" />
|
||||
<div className="i-ph:list-checks w-5 h-5" />
|
||||
</motion.div>
|
||||
<CardTitle className="text-lg group-hover:text-bolt-elements-item-contentAccent transition-colors">
|
||||
Export Selected Chats
|
||||
|
||||
@@ -1142,7 +1142,7 @@ export default function DebugTab() {
|
||||
{
|
||||
id: 'json',
|
||||
label: 'Export as JSON',
|
||||
icon: 'i-ph:file-json',
|
||||
icon: 'i-ph:file-js',
|
||||
handler: exportDebugInfo,
|
||||
},
|
||||
{
|
||||
@@ -1652,7 +1652,7 @@ export default function DebugTab() {
|
||||
<span className="text-bolt-elements-textPrimary">{systemInfo.platform}</span>
|
||||
</div>
|
||||
<div className="text-sm flex items-center gap-2">
|
||||
<div className="i-ph:microchip text-bolt-elements-textSecondary w-4 h-4" />
|
||||
<div className="i-ph:circuitry text-bolt-elements-textSecondary w-4 h-4" />
|
||||
<span className="text-bolt-elements-textSecondary">Architecture: </span>
|
||||
<span className="text-bolt-elements-textPrimary">{systemInfo.arch}</span>
|
||||
</div>
|
||||
@@ -1662,7 +1662,7 @@ export default function DebugTab() {
|
||||
<span className="text-bolt-elements-textPrimary">{systemInfo.cpus}</span>
|
||||
</div>
|
||||
<div className="text-sm flex items-center gap-2">
|
||||
<div className="i-ph:node text-bolt-elements-textSecondary w-4 h-4" />
|
||||
<div className="i-ph:graph text-bolt-elements-textSecondary w-4 h-4" />
|
||||
<span className="text-bolt-elements-textSecondary">Node Version: </span>
|
||||
<span className="text-bolt-elements-textPrimary">{systemInfo.node}</span>
|
||||
</div>
|
||||
@@ -1917,7 +1917,7 @@ export default function DebugTab() {
|
||||
<span className="text-bolt-elements-textPrimary">{webAppInfo.environment}</span>
|
||||
</div>
|
||||
<div className="text-sm flex items-center gap-2">
|
||||
<div className="i-ph:node text-bolt-elements-textSecondary w-4 h-4" />
|
||||
<div className="i-ph:graph text-bolt-elements-textSecondary w-4 h-4" />
|
||||
<span className="text-bolt-elements-textSecondary">Node Version:</span>
|
||||
<span className="text-bolt-elements-textPrimary">{webAppInfo.runtimeInfo.nodeVersion}</span>
|
||||
</div>
|
||||
@@ -1952,7 +1952,7 @@ export default function DebugTab() {
|
||||
<>
|
||||
<div className="mt-4 pt-4 border-t border-gray-200 dark:border-gray-800">
|
||||
<div className="text-sm flex items-center gap-2">
|
||||
<div className="i-ph:git-repository text-bolt-elements-textSecondary w-4 h-4" />
|
||||
<div className="i-ph:git-fork text-bolt-elements-textSecondary w-4 h-4" />
|
||||
<span className="text-bolt-elements-textSecondary">Repository:</span>
|
||||
<span className="text-bolt-elements-textPrimary">
|
||||
{webAppInfo.gitInfo.github.currentRepo.fullName}
|
||||
|
||||
@@ -763,7 +763,7 @@ export function EventLogsTab() {
|
||||
{
|
||||
id: 'json',
|
||||
label: 'Export as JSON',
|
||||
icon: 'i-ph:file-json',
|
||||
icon: 'i-ph:file-js',
|
||||
handler: exportAsJSON,
|
||||
},
|
||||
{
|
||||
|
||||
@@ -54,77 +54,105 @@ export const Artifact = memo(({ messageId }: ArtifactProps) => {
|
||||
}
|
||||
|
||||
if (actions.length !== 0 && artifact.type === 'bundled') {
|
||||
const finished = !actions.find((action) => action.status !== 'complete');
|
||||
const finished = !actions.find(
|
||||
(action) => action.status !== 'complete' && !(action.type === 'start' && action.status === 'running'),
|
||||
);
|
||||
|
||||
if (allActionFinished !== finished) {
|
||||
setAllActionFinished(finished);
|
||||
}
|
||||
}
|
||||
}, [actions]);
|
||||
}, [actions, artifact.type, allActionFinished]);
|
||||
|
||||
// Determine the dynamic title based on state for bundled artifacts
|
||||
const dynamicTitle =
|
||||
artifact?.type === 'bundled'
|
||||
? allActionFinished
|
||||
? artifact.id === 'restored-project-setup'
|
||||
? 'Project Restored' // Title when restore is complete
|
||||
: 'Project Created' // Title when initial creation is complete
|
||||
: artifact.id === 'restored-project-setup'
|
||||
? 'Restoring Project...' // Title during restore
|
||||
: 'Creating Project...' // Title during initial creation
|
||||
: artifact?.title; // Fallback to original title for non-bundled or if artifact is missing
|
||||
|
||||
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);
|
||||
}}
|
||||
>
|
||||
{artifact.type == 'bundled' && (
|
||||
<>
|
||||
<div className="p-4">
|
||||
{allActionFinished ? (
|
||||
<div className={'i-ph:files-light'} style={{ fontSize: '2rem' }}></div>
|
||||
) : (
|
||||
<div className={'i-svg-spinners:90-ring-with-bg'} style={{ fontSize: '2rem' }}></div>
|
||||
)}
|
||||
<>
|
||||
<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">
|
||||
{/* Use the dynamic title here */}
|
||||
{dynamicTitle}
|
||||
</div>
|
||||
<div className="bg-bolt-elements-artifacts-borderColor w-[1px]" />
|
||||
</>
|
||||
)}
|
||||
<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 className="w-full w-full text-bolt-elements-textSecondary text-xs mt-0.5">
|
||||
Click to open Workbench
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
{artifact.type !== 'bundled' && <div className="bg-bolt-elements-artifacts-borderColor w-[1px]" />}
|
||||
<AnimatePresence>
|
||||
{actions.length && artifact.type !== 'bundled' && (
|
||||
<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>
|
||||
{artifact.type === 'bundled' && (
|
||||
<div className="flex items-center gap-1.5 p-5 bg-bolt-elements-actions-background border-t border-bolt-elements-artifacts-borderColor">
|
||||
<div className={classNames('text-lg', getIconColor(allActionFinished ? 'complete' : 'running'))}>
|
||||
{allActionFinished ? (
|
||||
<div className="i-ph:check"></div>
|
||||
) : (
|
||||
<div className="i-svg-spinners:90-ring-with-bg"></div>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-bolt-elements-textPrimary font-medium leading-5 text-sm">
|
||||
{/* This status text remains the same */}
|
||||
{allActionFinished
|
||||
? artifact.id === 'restored-project-setup'
|
||||
? 'Restore files from snapshot'
|
||||
: 'Initial files created'
|
||||
: 'Creating initial files'}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
<div className="bg-bolt-elements-artifacts-borderColor w-[1px]" />
|
||||
)}
|
||||
<AnimatePresence>
|
||||
{actions.length && artifact.type !== 'bundled' && (
|
||||
<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}
|
||||
{artifact.type !== 'bundled' && showActions && actions.length > 0 && (
|
||||
<motion.div
|
||||
className="actions"
|
||||
initial={{ height: 0 }}
|
||||
animate={{ height: 'auto' }}
|
||||
exit={{ height: '0px' }}
|
||||
transition={{ duration: 0.15 }}
|
||||
>
|
||||
<div className="p-4">
|
||||
<div className={showActions ? 'i-ph:caret-up-bold' : 'i-ph:caret-down-bold'}></div>
|
||||
<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.button>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
<AnimatePresence>
|
||||
{artifact.type !== 'bundled' && 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>
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -4,10 +4,14 @@ import type { JSONValue } from 'ai';
|
||||
import Popover from '~/components/ui/Popover';
|
||||
import { workbenchStore } from '~/lib/stores/workbench';
|
||||
import { WORK_DIR } from '~/utils/constants';
|
||||
import WithTooltip from '~/components/ui/Tooltip';
|
||||
|
||||
interface AssistantMessageProps {
|
||||
content: string;
|
||||
annotations?: JSONValue[];
|
||||
messageId?: string;
|
||||
onRewind?: (messageId: string) => void;
|
||||
onFork?: (messageId: string) => void;
|
||||
}
|
||||
|
||||
function openArtifactInWorkbench(filePath: string) {
|
||||
@@ -34,7 +38,7 @@ function normalizedFilePath(path: string) {
|
||||
return normalizedPath;
|
||||
}
|
||||
|
||||
export const AssistantMessage = memo(({ content, annotations }: AssistantMessageProps) => {
|
||||
export const AssistantMessage = memo(({ content, annotations, messageId, onRewind, onFork }: AssistantMessageProps) => {
|
||||
const filteredAnnotations = (annotations?.filter(
|
||||
(annotation: JSONValue) => annotation && typeof annotation === 'object' && Object.keys(annotation).includes('type'),
|
||||
) || []) as { type: string; value: any } & { [key: string]: any }[];
|
||||
@@ -100,11 +104,35 @@ export const AssistantMessage = memo(({ content, annotations }: AssistantMessage
|
||||
<div className="context"></div>
|
||||
</Popover>
|
||||
)}
|
||||
{usage && (
|
||||
<div>
|
||||
Tokens: {usage.totalTokens} (prompt: {usage.promptTokens}, completion: {usage.completionTokens})
|
||||
</div>
|
||||
)}
|
||||
<div className="flex w-full items-center justify-between">
|
||||
{usage && (
|
||||
<div>
|
||||
Tokens: {usage.totalTokens} (prompt: {usage.promptTokens}, completion: {usage.completionTokens})
|
||||
</div>
|
||||
)}
|
||||
{(onRewind || onFork) && messageId && (
|
||||
<div className="flex gap-2 flex-col lg:flex-row ml-auto">
|
||||
{onRewind && (
|
||||
<WithTooltip tooltip="Revert to this message">
|
||||
<button
|
||||
onClick={() => onRewind(messageId)}
|
||||
key="i-ph:arrow-u-up-left"
|
||||
className="i-ph:arrow-u-up-left text-xl text-bolt-elements-textSecondary hover:text-bolt-elements-textPrimary transition-colors"
|
||||
/>
|
||||
</WithTooltip>
|
||||
)}
|
||||
{onFork && (
|
||||
<WithTooltip tooltip="Fork chat from this message">
|
||||
<button
|
||||
onClick={() => onFork(messageId)}
|
||||
key="i-ph:git-fork"
|
||||
className="i-ph:git-fork text-xl text-bolt-elements-textSecondary hover:text-bolt-elements-textPrimary transition-colors"
|
||||
/>
|
||||
</WithTooltip>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
<Markdown html>{content}</Markdown>
|
||||
|
||||
@@ -39,6 +39,10 @@ import type { ActionRunner } from '~/lib/runtime/action-runner';
|
||||
import { LOCAL_PROVIDERS } from '~/lib/stores/settings';
|
||||
import { SupabaseChatAlert } from '~/components/chat/SupabaseAlert';
|
||||
import { SupabaseConnection } from './SupabaseConnection';
|
||||
import { ExpoQrModal } from '~/components/workbench/ExpoQrModal';
|
||||
import { expoUrlAtom } from '~/lib/stores/qrCodeStore';
|
||||
import { useStore } from '@nanostores/react';
|
||||
import { StickToBottom, useStickToBottomContext } from '~/lib/hooks';
|
||||
|
||||
const TEXTAREA_MIN_HEIGHT = 76;
|
||||
|
||||
@@ -84,8 +88,6 @@ export const BaseChat = React.forwardRef<HTMLDivElement, BaseChatProps>(
|
||||
(
|
||||
{
|
||||
textareaRef,
|
||||
messageRef,
|
||||
scrollRef,
|
||||
showChat = true,
|
||||
chatStarted = false,
|
||||
isStreaming = false,
|
||||
@@ -130,6 +132,15 @@ export const BaseChat = React.forwardRef<HTMLDivElement, BaseChatProps>(
|
||||
const [transcript, setTranscript] = useState('');
|
||||
const [isModelLoading, setIsModelLoading] = useState<string | undefined>('all');
|
||||
const [progressAnnotations, setProgressAnnotations] = useState<ProgressAnnotation[]>([]);
|
||||
const expoUrl = useStore(expoUrlAtom);
|
||||
const [qrModalOpen, setQrModalOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (expoUrl) {
|
||||
setQrModalOpen(true);
|
||||
}
|
||||
}, [expoUrl]);
|
||||
|
||||
useEffect(() => {
|
||||
if (data) {
|
||||
const progressList = data.filter(
|
||||
@@ -324,7 +335,7 @@ export const BaseChat = React.forwardRef<HTMLDivElement, BaseChatProps>(
|
||||
data-chat-visible={showChat}
|
||||
>
|
||||
<ClientOnly>{() => <Menu />}</ClientOnly>
|
||||
<div ref={scrollRef} className="flex flex-col lg:flex-row overflow-y-auto w-full h-full">
|
||||
<div className="flex flex-col lg:flex-row overflow-y-auto w-full h-full">
|
||||
<div className={classNames(styles.Chat, 'flex flex-col flex-grow lg:min-w-[var(--chat-min-width)] h-full')}>
|
||||
{!chatStarted && (
|
||||
<div id="intro" className="mt-[16vh] max-w-chat mx-auto text-center px-4 lg:px-0">
|
||||
@@ -336,50 +347,52 @@ export const BaseChat = React.forwardRef<HTMLDivElement, BaseChatProps>(
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
className={classNames('pt-6 px-2 sm:px-6', {
|
||||
'h-full flex flex-col': chatStarted,
|
||||
<StickToBottom
|
||||
className={classNames('pt-6 px-2 sm:px-6 relative', {
|
||||
'h-full flex flex-col modern-scrollbar': chatStarted,
|
||||
})}
|
||||
ref={scrollRef}
|
||||
resize="smooth"
|
||||
initial="smooth"
|
||||
>
|
||||
<ClientOnly>
|
||||
{() => {
|
||||
return chatStarted ? (
|
||||
<Messages
|
||||
ref={messageRef}
|
||||
className="flex flex-col w-full flex-1 max-w-chat pb-6 mx-auto z-1"
|
||||
messages={messages}
|
||||
isStreaming={isStreaming}
|
||||
/>
|
||||
) : null;
|
||||
}}
|
||||
</ClientOnly>
|
||||
{deployAlert && (
|
||||
<DeployChatAlert
|
||||
alert={deployAlert}
|
||||
clearAlert={() => clearDeployAlert?.()}
|
||||
postMessage={(message: string | undefined) => {
|
||||
sendMessage?.({} as any, message);
|
||||
clearSupabaseAlert?.();
|
||||
<StickToBottom.Content className="flex flex-col gap-4">
|
||||
<ClientOnly>
|
||||
{() => {
|
||||
return chatStarted ? (
|
||||
<Messages
|
||||
className="flex flex-col w-full flex-1 max-w-chat pb-6 mx-auto z-1"
|
||||
messages={messages}
|
||||
isStreaming={isStreaming}
|
||||
/>
|
||||
) : null;
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{supabaseAlert && (
|
||||
<SupabaseChatAlert
|
||||
alert={supabaseAlert}
|
||||
clearAlert={() => clearSupabaseAlert?.()}
|
||||
postMessage={(message) => {
|
||||
sendMessage?.({} as any, message);
|
||||
clearSupabaseAlert?.();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</ClientOnly>
|
||||
</StickToBottom.Content>
|
||||
<div
|
||||
className={classNames('flex flex-col gap-4 w-full max-w-chat mx-auto z-prompt mb-6', {
|
||||
className={classNames('my-auto flex flex-col gap-2 w-full max-w-chat mx-auto z-prompt mb-6', {
|
||||
'sticky bottom-2': chatStarted,
|
||||
})}
|
||||
>
|
||||
<div className="bg-bolt-elements-background-depth-2">
|
||||
<div className="flex flex-col gap-2">
|
||||
{deployAlert && (
|
||||
<DeployChatAlert
|
||||
alert={deployAlert}
|
||||
clearAlert={() => clearDeployAlert?.()}
|
||||
postMessage={(message: string | undefined) => {
|
||||
sendMessage?.({} as any, message);
|
||||
clearSupabaseAlert?.();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{supabaseAlert && (
|
||||
<SupabaseChatAlert
|
||||
alert={supabaseAlert}
|
||||
clearAlert={() => clearSupabaseAlert?.()}
|
||||
postMessage={(message) => {
|
||||
sendMessage?.({} as any, message);
|
||||
clearSupabaseAlert?.();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{actionAlert && (
|
||||
<ChatAlert
|
||||
alert={actionAlert}
|
||||
@@ -391,10 +404,11 @@ export const BaseChat = React.forwardRef<HTMLDivElement, BaseChatProps>(
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<ScrollToBottom />
|
||||
{progressAnnotations && <ProgressCompilation data={progressAnnotations} />}
|
||||
<div
|
||||
className={classNames(
|
||||
'bg-bolt-elements-background-depth-2 p-3 rounded-lg border border-bolt-elements-borderColor relative w-full max-w-chat mx-auto z-prompt',
|
||||
'relative bg-bolt-elements-background-depth-2 p-3 rounded-lg border border-bolt-elements-borderColor relative w-full max-w-chat mx-auto z-prompt',
|
||||
|
||||
/*
|
||||
* {
|
||||
@@ -622,28 +636,31 @@ export const BaseChat = React.forwardRef<HTMLDivElement, BaseChatProps>(
|
||||
</div>
|
||||
) : null}
|
||||
<SupabaseConnection />
|
||||
<ExpoQrModal open={qrModalOpen} onClose={() => setQrModalOpen(false)} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col justify-center gap-5">
|
||||
</StickToBottom>
|
||||
<div className="flex flex-col justify-center">
|
||||
{!chatStarted && (
|
||||
<div className="flex justify-center gap-2">
|
||||
{ImportButtons(importChat)}
|
||||
<GitCloneButton importChat={importChat} />
|
||||
</div>
|
||||
)}
|
||||
{!chatStarted &&
|
||||
ExamplePrompts((event, messageInput) => {
|
||||
if (isStreaming) {
|
||||
handleStop?.();
|
||||
return;
|
||||
}
|
||||
<div className="flex flex-col gap-5">
|
||||
{!chatStarted &&
|
||||
ExamplePrompts((event, messageInput) => {
|
||||
if (isStreaming) {
|
||||
handleStop?.();
|
||||
return;
|
||||
}
|
||||
|
||||
handleSendMessage?.(event, messageInput);
|
||||
})}
|
||||
{!chatStarted && <StarterTemplates />}
|
||||
handleSendMessage?.(event, messageInput);
|
||||
})}
|
||||
{!chatStarted && <StarterTemplates />}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<ClientOnly>
|
||||
@@ -662,3 +679,19 @@ export const BaseChat = React.forwardRef<HTMLDivElement, BaseChatProps>(
|
||||
return <Tooltip.Provider delayDuration={200}>{baseChat}</Tooltip.Provider>;
|
||||
},
|
||||
);
|
||||
|
||||
function ScrollToBottom() {
|
||||
const { isAtBottom, scrollToBottom } = useStickToBottomContext();
|
||||
|
||||
return (
|
||||
!isAtBottom && (
|
||||
<button
|
||||
className="absolute z-50 top-[0%] translate-y-[-100%] text-4xl rounded-lg left-[50%] translate-x-[-50%] px-1.5 py-0.5 flex items-center gap-2 bg-bolt-elements-background-depth-3 border border-bolt-elements-borderColor text-bolt-elements-textPrimary text-sm"
|
||||
onClick={() => scrollToBottom()}
|
||||
>
|
||||
Go to last message
|
||||
<span className="i-ph:arrow-down animate-bounce" />
|
||||
</button>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ import { useChat } from 'ai/react';
|
||||
import { useAnimate } from 'framer-motion';
|
||||
import { memo, useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { cssTransition, toast, ToastContainer } from 'react-toastify';
|
||||
import { useMessageParser, usePromptEnhancer, useShortcuts, useSnapScroll } from '~/lib/hooks';
|
||||
import { useMessageParser, usePromptEnhancer, useShortcuts } from '~/lib/hooks';
|
||||
import { description, useChatHistory } from '~/lib/persistence';
|
||||
import { chatStore } from '~/lib/stores/chat';
|
||||
import { workbenchStore } from '~/lib/stores/workbench';
|
||||
@@ -483,8 +483,6 @@ export const ChatImpl = memo(
|
||||
[],
|
||||
);
|
||||
|
||||
const [messageRef, scrollRef] = useSnapScroll();
|
||||
|
||||
useEffect(() => {
|
||||
const storedApiKeys = Cookies.get('apiKeys');
|
||||
|
||||
@@ -522,8 +520,6 @@ export const ChatImpl = memo(
|
||||
provider={provider}
|
||||
setProvider={handleProviderChange}
|
||||
providerList={activeProviders}
|
||||
messageRef={messageRef}
|
||||
scrollRef={scrollRef}
|
||||
handleInputChange={(e) => {
|
||||
onTextareaChange(e);
|
||||
debouncedCachePrompt(e);
|
||||
|
||||
@@ -35,18 +35,21 @@ export const CodeBlock = memo(
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
let effectiveLanguage = language;
|
||||
|
||||
if (language && !isSpecialLang(language) && !(language in bundledLanguages)) {
|
||||
logger.warn(`Unsupported language '${language}'`);
|
||||
logger.warn(`Unsupported language '${language}', falling back to plaintext`);
|
||||
effectiveLanguage = 'plaintext';
|
||||
}
|
||||
|
||||
logger.trace(`Language = ${language}`);
|
||||
logger.trace(`Language = ${effectiveLanguage}`);
|
||||
|
||||
const processCode = async () => {
|
||||
setHTML(await codeToHtml(code, { lang: language, theme }));
|
||||
setHTML(await codeToHtml(code, { lang: effectiveLanguage, theme }));
|
||||
};
|
||||
|
||||
processCode();
|
||||
}, [code]);
|
||||
}, [code, language, theme]);
|
||||
|
||||
return (
|
||||
<div className={classNames('relative group text-left', className)}>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React from 'react';
|
||||
|
||||
const EXAMPLE_PROMPTS = [
|
||||
{ text: 'Create a mobile app about bolt.diy' },
|
||||
{ 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' },
|
||||
|
||||
@@ -12,18 +12,21 @@ const FilePreview: React.FC<FilePreviewProps> = ({ files, imageDataList, onRemov
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-row overflow-x-auto -mt-2">
|
||||
<div className="flex flex-row overflow-x-auto mx-2 -mt-1 p-2 bg-bolt-elements-background-depth-3 border border-b-none border-bolt-elements-borderColor rounded-lg rounded-b-none">
|
||||
{files.map((file, index) => (
|
||||
<div key={file.name + file.size} className="mr-2 relative">
|
||||
{imageDataList[index] && (
|
||||
<div className="relative pt-4 pr-4">
|
||||
<img src={imageDataList[index]} alt={file.name} className="max-h-20" />
|
||||
<div className="relative">
|
||||
<img src={imageDataList[index]} alt={file.name} className="max-h-20 rounded-lg" />
|
||||
<button
|
||||
onClick={() => onRemove(index)}
|
||||
className="absolute top-1 right-1 z-10 bg-black rounded-full w-5 h-5 shadow-md hover:bg-gray-900 transition-colors flex items-center justify-center"
|
||||
className="absolute -top-1 -right-1 z-10 bg-black rounded-full w-5 h-5 shadow-md hover:bg-gray-900 transition-colors flex items-center justify-center"
|
||||
>
|
||||
<div className="i-ph:x w-3 h-3 text-gray-200" />
|
||||
</button>
|
||||
<div className="absolute bottom-0 w-full h-5 flex items-center px-2 rounded-b-lg text-bolt-elements-textTertiary font-thin text-xs bg-bolt-elements-background-depth-2">
|
||||
<span className="truncate">{file.name}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -156,13 +156,13 @@ ${escapeBoltTags(file.content)}
|
||||
<Button
|
||||
onClick={() => setIsDialogOpen(true)}
|
||||
title="Clone a Git Repo"
|
||||
variant="outline"
|
||||
variant="default"
|
||||
size="lg"
|
||||
className={classNames(
|
||||
'gap-2 bg-bolt-elements-background-depth-1',
|
||||
'text-bolt-elements-textPrimary',
|
||||
'hover:bg-bolt-elements-background-depth-2',
|
||||
'border-[rgba(0,0,0,0.08)] dark:border-[rgba(255,255,255,0.08)]',
|
||||
'border border-bolt-elements-borderColor',
|
||||
'h-10 px-4 py-2 min-w-[120px] justify-center',
|
||||
'transition-all duration-200 ease-in-out',
|
||||
className,
|
||||
|
||||
@@ -120,13 +120,13 @@ export const ImportFolderButton: React.FC<ImportFolderButtonProps> = ({ classNam
|
||||
input?.click();
|
||||
}}
|
||||
title="Import Folder"
|
||||
variant="outline"
|
||||
variant="default"
|
||||
size="lg"
|
||||
className={classNames(
|
||||
'gap-2 bg-bolt-elements-background-depth-1',
|
||||
'text-bolt-elements-textPrimary',
|
||||
'hover:bg-bolt-elements-background-depth-2',
|
||||
'border-[rgba(0,0,0,0.08)] dark:border-[rgba(255,255,255,0.08)]',
|
||||
'border border-bolt-elements-borderColor',
|
||||
'h-10 px-4 py-2 min-w-[120px] justify-center',
|
||||
'transition-all duration-200 ease-in-out',
|
||||
className,
|
||||
|
||||
@@ -7,7 +7,6 @@ import { useLocation } from '@remix-run/react';
|
||||
import { db, chatId } from '~/lib/persistence/useChatHistory';
|
||||
import { forkChat } from '~/lib/persistence/db';
|
||||
import { toast } from 'react-toastify';
|
||||
import WithTooltip from '~/components/ui/Tooltip';
|
||||
import { useStore } from '@nanostores/react';
|
||||
import { profileStore } from '~/lib/stores/profile';
|
||||
import { forwardRef } from 'react';
|
||||
@@ -63,7 +62,7 @@ export const Messages = forwardRef<HTMLDivElement, MessagesProps>(
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
className={classNames('flex gap-4 p-6 w-full rounded-[calc(0.75rem-1px)]', {
|
||||
className={classNames('flex gap-4 p-6 py-5 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,
|
||||
@@ -89,42 +88,21 @@ export const Messages = forwardRef<HTMLDivElement, MessagesProps>(
|
||||
{isUserMessage ? (
|
||||
<UserMessage content={content} />
|
||||
) : (
|
||||
<AssistantMessage content={content} annotations={message.annotations} />
|
||||
<AssistantMessage
|
||||
content={content}
|
||||
annotations={message.annotations}
|
||||
messageId={messageId}
|
||||
onRewind={handleRewind}
|
||||
onFork={handleFork}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{!isUserMessage && (
|
||||
<div className="flex gap-2 flex-col lg:flex-row">
|
||||
{messageId && (
|
||||
<WithTooltip tooltip="Revert to this message">
|
||||
<button
|
||||
onClick={() => handleRewind(messageId)}
|
||||
key="i-ph:arrow-u-up-left"
|
||||
className={classNames(
|
||||
'i-ph:arrow-u-up-left',
|
||||
'text-xl text-bolt-elements-textSecondary hover:text-bolt-elements-textPrimary transition-colors',
|
||||
)}
|
||||
/>
|
||||
</WithTooltip>
|
||||
)}
|
||||
|
||||
<WithTooltip tooltip="Fork chat from this message">
|
||||
<button
|
||||
onClick={() => handleFork(messageId)}
|
||||
key="i-ph:git-fork"
|
||||
className={classNames(
|
||||
'i-ph:git-fork',
|
||||
'text-xl text-bolt-elements-textSecondary hover:text-bolt-elements-textPrimary transition-colors',
|
||||
)}
|
||||
/>
|
||||
</WithTooltip>
|
||||
</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 className="text-center w-full text-bolt-elements-item-contentAccent i-svg-spinners:3-dots-fade text-4xl mt-4"></div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -3,7 +3,6 @@ import { useEffect, useState, useRef } from 'react';
|
||||
import type { KeyboardEvent } from 'react';
|
||||
import type { ModelInfo } from '~/lib/modules/llm/types';
|
||||
import { classNames } from '~/utils/classNames';
|
||||
import * as React from 'react';
|
||||
|
||||
interface ModelSelectorProps {
|
||||
model?: string;
|
||||
@@ -27,17 +26,28 @@ export const ModelSelector = ({
|
||||
}: ModelSelectorProps) => {
|
||||
const [modelSearchQuery, setModelSearchQuery] = useState('');
|
||||
const [isModelDropdownOpen, setIsModelDropdownOpen] = useState(false);
|
||||
const [focusedIndex, setFocusedIndex] = useState(-1);
|
||||
const searchInputRef = useRef<HTMLInputElement>(null);
|
||||
const optionsRef = useRef<(HTMLDivElement | null)[]>([]);
|
||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||
const [focusedModelIndex, setFocusedModelIndex] = useState(-1);
|
||||
const modelSearchInputRef = useRef<HTMLInputElement>(null);
|
||||
const modelOptionsRef = useRef<(HTMLDivElement | null)[]>([]);
|
||||
const modelDropdownRef = useRef<HTMLDivElement>(null);
|
||||
const [providerSearchQuery, setProviderSearchQuery] = useState('');
|
||||
const [isProviderDropdownOpen, setIsProviderDropdownOpen] = useState(false);
|
||||
const [focusedProviderIndex, setFocusedProviderIndex] = useState(-1);
|
||||
const providerSearchInputRef = useRef<HTMLInputElement>(null);
|
||||
const providerOptionsRef = useRef<(HTMLDivElement | null)[]>([]);
|
||||
const providerDropdownRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
|
||||
if (modelDropdownRef.current && !modelDropdownRef.current.contains(event.target as Node)) {
|
||||
setIsModelDropdownOpen(false);
|
||||
setModelSearchQuery('');
|
||||
}
|
||||
|
||||
if (providerDropdownRef.current && !providerDropdownRef.current.contains(event.target as Node)) {
|
||||
setIsProviderDropdownOpen(false);
|
||||
setProviderSearchQuery('');
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
@@ -45,7 +55,6 @@ export const ModelSelector = ({
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||
}, []);
|
||||
|
||||
// Filter models based on search query
|
||||
const filteredModels = [...modelList]
|
||||
.filter((e) => e.provider === provider?.name && e.name)
|
||||
.filter(
|
||||
@@ -54,20 +63,31 @@ export const ModelSelector = ({
|
||||
model.name.toLowerCase().includes(modelSearchQuery.toLowerCase()),
|
||||
);
|
||||
|
||||
// Reset focused index when search query changes or dropdown opens/closes
|
||||
const filteredProviders = providerList.filter((p) =>
|
||||
p.name.toLowerCase().includes(providerSearchQuery.toLowerCase()),
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setFocusedIndex(-1);
|
||||
setFocusedModelIndex(-1);
|
||||
}, [modelSearchQuery, isModelDropdownOpen]);
|
||||
|
||||
// Focus search input when dropdown opens
|
||||
useEffect(() => {
|
||||
if (isModelDropdownOpen && searchInputRef.current) {
|
||||
searchInputRef.current.focus();
|
||||
setFocusedProviderIndex(-1);
|
||||
}, [providerSearchQuery, isProviderDropdownOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isModelDropdownOpen && modelSearchInputRef.current) {
|
||||
modelSearchInputRef.current.focus();
|
||||
}
|
||||
}, [isModelDropdownOpen]);
|
||||
|
||||
// Handle keyboard navigation
|
||||
const handleKeyDown = (e: KeyboardEvent<HTMLDivElement>) => {
|
||||
useEffect(() => {
|
||||
if (isProviderDropdownOpen && providerSearchInputRef.current) {
|
||||
providerSearchInputRef.current.focus();
|
||||
}
|
||||
}, [isProviderDropdownOpen]);
|
||||
|
||||
const handleModelKeyDown = (e: KeyboardEvent<HTMLDivElement>) => {
|
||||
if (!isModelDropdownOpen) {
|
||||
return;
|
||||
}
|
||||
@@ -75,50 +95,30 @@ export const ModelSelector = ({
|
||||
switch (e.key) {
|
||||
case 'ArrowDown':
|
||||
e.preventDefault();
|
||||
setFocusedIndex((prev) => {
|
||||
const next = prev + 1;
|
||||
|
||||
if (next >= filteredModels.length) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return next;
|
||||
});
|
||||
setFocusedModelIndex((prev) => (prev + 1 >= filteredModels.length ? 0 : prev + 1));
|
||||
break;
|
||||
|
||||
case 'ArrowUp':
|
||||
e.preventDefault();
|
||||
setFocusedIndex((prev) => {
|
||||
const next = prev - 1;
|
||||
|
||||
if (next < 0) {
|
||||
return filteredModels.length - 1;
|
||||
}
|
||||
|
||||
return next;
|
||||
});
|
||||
setFocusedModelIndex((prev) => (prev - 1 < 0 ? filteredModels.length - 1 : prev - 1));
|
||||
break;
|
||||
|
||||
case 'Enter':
|
||||
e.preventDefault();
|
||||
|
||||
if (focusedIndex >= 0 && focusedIndex < filteredModels.length) {
|
||||
const selectedModel = filteredModels[focusedIndex];
|
||||
if (focusedModelIndex >= 0 && focusedModelIndex < filteredModels.length) {
|
||||
const selectedModel = filteredModels[focusedModelIndex];
|
||||
setModel?.(selectedModel.name);
|
||||
setIsModelDropdownOpen(false);
|
||||
setModelSearchQuery('');
|
||||
}
|
||||
|
||||
break;
|
||||
|
||||
case 'Escape':
|
||||
e.preventDefault();
|
||||
setIsModelDropdownOpen(false);
|
||||
setModelSearchQuery('');
|
||||
break;
|
||||
|
||||
case 'Tab':
|
||||
if (!e.shiftKey && focusedIndex === filteredModels.length - 1) {
|
||||
if (!e.shiftKey && focusedModelIndex === filteredModels.length - 1) {
|
||||
setIsModelDropdownOpen(false);
|
||||
}
|
||||
|
||||
@@ -126,25 +126,76 @@ export const ModelSelector = ({
|
||||
}
|
||||
};
|
||||
|
||||
// Focus the selected option
|
||||
useEffect(() => {
|
||||
if (focusedIndex >= 0 && optionsRef.current[focusedIndex]) {
|
||||
optionsRef.current[focusedIndex]?.scrollIntoView({ block: 'nearest' });
|
||||
const handleProviderKeyDown = (e: KeyboardEvent<HTMLDivElement>) => {
|
||||
if (!isProviderDropdownOpen) {
|
||||
return;
|
||||
}
|
||||
}, [focusedIndex]);
|
||||
|
||||
// Update enabled providers when cookies change
|
||||
switch (e.key) {
|
||||
case 'ArrowDown':
|
||||
e.preventDefault();
|
||||
setFocusedProviderIndex((prev) => (prev + 1 >= filteredProviders.length ? 0 : prev + 1));
|
||||
break;
|
||||
case 'ArrowUp':
|
||||
e.preventDefault();
|
||||
setFocusedProviderIndex((prev) => (prev - 1 < 0 ? filteredProviders.length - 1 : prev - 1));
|
||||
break;
|
||||
case 'Enter':
|
||||
e.preventDefault();
|
||||
|
||||
if (focusedProviderIndex >= 0 && focusedProviderIndex < filteredProviders.length) {
|
||||
const selectedProvider = filteredProviders[focusedProviderIndex];
|
||||
|
||||
if (setProvider) {
|
||||
setProvider(selectedProvider);
|
||||
|
||||
const firstModel = modelList.find((m) => m.provider === selectedProvider.name);
|
||||
|
||||
if (firstModel && setModel) {
|
||||
setModel(firstModel.name);
|
||||
}
|
||||
}
|
||||
|
||||
setIsProviderDropdownOpen(false);
|
||||
setProviderSearchQuery('');
|
||||
}
|
||||
|
||||
break;
|
||||
case 'Escape':
|
||||
e.preventDefault();
|
||||
setIsProviderDropdownOpen(false);
|
||||
setProviderSearchQuery('');
|
||||
break;
|
||||
case 'Tab':
|
||||
if (!e.shiftKey && focusedProviderIndex === filteredProviders.length - 1) {
|
||||
setIsProviderDropdownOpen(false);
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (focusedModelIndex >= 0 && modelOptionsRef.current[focusedModelIndex]) {
|
||||
modelOptionsRef.current[focusedModelIndex]?.scrollIntoView({ block: 'nearest' });
|
||||
}
|
||||
}, [focusedModelIndex]);
|
||||
|
||||
useEffect(() => {
|
||||
if (focusedProviderIndex >= 0 && providerOptionsRef.current[focusedProviderIndex]) {
|
||||
providerOptionsRef.current[focusedProviderIndex]?.scrollIntoView({ block: 'nearest' });
|
||||
}
|
||||
}, [focusedProviderIndex]);
|
||||
|
||||
useEffect(() => {
|
||||
// If current provider is disabled, switch to first enabled provider
|
||||
if (providerList.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (provider && !providerList.map((p) => p.name).includes(provider.name)) {
|
||||
if (provider && !providerList.some((p) => p.name === provider.name)) {
|
||||
const firstEnabledProvider = providerList[0];
|
||||
setProvider?.(firstEnabledProvider);
|
||||
|
||||
// Also update the model to the first available one for the new provider
|
||||
const firstModel = modelList.find((m) => m.provider === firstEnabledProvider.name);
|
||||
|
||||
if (firstModel) {
|
||||
@@ -165,32 +216,136 @@ export const ModelSelector = ({
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mb-2 flex gap-2 flex-col sm:flex-row">
|
||||
<select
|
||||
value={provider?.name ?? ''}
|
||||
onChange={(e) => {
|
||||
const newProvider = providerList.find((p: ProviderInfo) => p.name === e.target.value);
|
||||
<div className="flex gap-2 flex-col sm:flex-row">
|
||||
{/* Provider Combobox */}
|
||||
<div className="relative flex w-full" onKeyDown={handleProviderKeyDown} ref={providerDropdownRef}>
|
||||
<div
|
||||
className={classNames(
|
||||
'w-full p-2 rounded-lg border border-bolt-elements-borderColor',
|
||||
'bg-bolt-elements-prompt-background text-bolt-elements-textPrimary',
|
||||
'focus-within:outline-none focus-within:ring-2 focus-within:ring-bolt-elements-focus',
|
||||
'transition-all cursor-pointer',
|
||||
isProviderDropdownOpen ? 'ring-2 ring-bolt-elements-focus' : undefined,
|
||||
)}
|
||||
onClick={() => setIsProviderDropdownOpen(!isProviderDropdownOpen)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
setIsProviderDropdownOpen(!isProviderDropdownOpen);
|
||||
}
|
||||
}}
|
||||
role="combobox"
|
||||
aria-expanded={isProviderDropdownOpen}
|
||||
aria-controls="provider-listbox"
|
||||
aria-haspopup="listbox"
|
||||
tabIndex={0}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="truncate">{provider?.name || 'Select provider'}</div>
|
||||
<div
|
||||
className={classNames(
|
||||
'i-ph:caret-down w-4 h-4 text-bolt-elements-textSecondary opacity-75',
|
||||
isProviderDropdownOpen ? 'rotate-180' : undefined,
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
if (newProvider && setProvider) {
|
||||
setProvider(newProvider);
|
||||
}
|
||||
{isProviderDropdownOpen && (
|
||||
<div
|
||||
className="absolute z-20 w-full mt-1 py-1 rounded-lg border border-bolt-elements-borderColor bg-bolt-elements-background-depth-2 shadow-lg"
|
||||
role="listbox"
|
||||
id="provider-listbox"
|
||||
>
|
||||
<div className="px-2 pb-2">
|
||||
<div className="relative">
|
||||
<input
|
||||
ref={providerSearchInputRef}
|
||||
type="text"
|
||||
value={providerSearchQuery}
|
||||
onChange={(e) => setProviderSearchQuery(e.target.value)}
|
||||
placeholder="Search providers..."
|
||||
className={classNames(
|
||||
'w-full pl-2 py-1.5 rounded-md text-sm',
|
||||
'bg-bolt-elements-background-depth-2 border border-bolt-elements-borderColor',
|
||||
'text-bolt-elements-textPrimary placeholder:text-bolt-elements-textTertiary',
|
||||
'focus:outline-none focus:ring-2 focus:ring-bolt-elements-focus',
|
||||
'transition-all',
|
||||
)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
role="searchbox"
|
||||
aria-label="Search providers"
|
||||
/>
|
||||
<div className="absolute left-2.5 top-1/2 -translate-y-1/2">
|
||||
<span className="i-ph:magnifying-glass text-bolt-elements-textTertiary" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
const firstModel = [...modelList].find((m) => m.provider === e.target.value);
|
||||
<div
|
||||
className={classNames(
|
||||
'max-h-60 overflow-y-auto',
|
||||
'sm:scrollbar-none',
|
||||
'[&::-webkit-scrollbar]:w-2 [&::-webkit-scrollbar]:h-2',
|
||||
'[&::-webkit-scrollbar-thumb]:bg-bolt-elements-borderColor',
|
||||
'[&::-webkit-scrollbar-thumb]:hover:bg-bolt-elements-borderColorHover',
|
||||
'[&::-webkit-scrollbar-thumb]:rounded-full',
|
||||
'[&::-webkit-scrollbar-track]:bg-bolt-elements-background-depth-2',
|
||||
'[&::-webkit-scrollbar-track]:rounded-full',
|
||||
'sm:[&::-webkit-scrollbar]:w-1.5 sm:[&::-webkit-scrollbar]:h-1.5',
|
||||
'sm:hover:[&::-webkit-scrollbar-thumb]:bg-bolt-elements-borderColor/50',
|
||||
'sm:hover:[&::-webkit-scrollbar-thumb:hover]:bg-bolt-elements-borderColor',
|
||||
'sm:[&::-webkit-scrollbar-track]:bg-transparent',
|
||||
)}
|
||||
>
|
||||
{filteredProviders.length === 0 ? (
|
||||
<div className="px-3 py-2 text-sm text-bolt-elements-textTertiary">No providers found</div>
|
||||
) : (
|
||||
filteredProviders.map((providerOption, index) => (
|
||||
<div
|
||||
ref={(el) => (providerOptionsRef.current[index] = el)}
|
||||
key={providerOption.name}
|
||||
role="option"
|
||||
aria-selected={provider?.name === providerOption.name}
|
||||
className={classNames(
|
||||
'px-3 py-2 text-sm cursor-pointer',
|
||||
'hover:bg-bolt-elements-background-depth-3',
|
||||
'text-bolt-elements-textPrimary',
|
||||
'outline-none',
|
||||
provider?.name === providerOption.name || focusedProviderIndex === index
|
||||
? 'bg-bolt-elements-background-depth-2'
|
||||
: undefined,
|
||||
focusedProviderIndex === index ? 'ring-1 ring-inset ring-bolt-elements-focus' : undefined,
|
||||
)}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
|
||||
if (firstModel && setModel) {
|
||||
setModel(firstModel.name);
|
||||
}
|
||||
}}
|
||||
className="flex-1 p-2 rounded-lg border border-bolt-elements-borderColor bg-bolt-elements-prompt-background text-bolt-elements-textPrimary focus:outline-none focus:ring-2 focus:ring-bolt-elements-focus transition-all"
|
||||
>
|
||||
{providerList.map((provider: ProviderInfo) => (
|
||||
<option key={provider.name} value={provider.name}>
|
||||
{provider.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
if (setProvider) {
|
||||
setProvider(providerOption);
|
||||
|
||||
<div className="relative flex-1 lg:max-w-[70%]" onKeyDown={handleKeyDown} ref={dropdownRef}>
|
||||
const firstModel = modelList.find((m) => m.provider === providerOption.name);
|
||||
|
||||
if (firstModel && setModel) {
|
||||
setModel(firstModel.name);
|
||||
}
|
||||
}
|
||||
|
||||
setIsProviderDropdownOpen(false);
|
||||
setProviderSearchQuery('');
|
||||
}}
|
||||
tabIndex={focusedProviderIndex === index ? 0 : -1}
|
||||
>
|
||||
{providerOption.name}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Model Combobox */}
|
||||
<div className="relative flex w-full min-w-[70%]" onKeyDown={handleModelKeyDown} ref={modelDropdownRef}>
|
||||
<div
|
||||
className={classNames(
|
||||
'w-full p-2 rounded-lg border border-bolt-elements-borderColor',
|
||||
@@ -225,20 +380,20 @@ export const ModelSelector = ({
|
||||
|
||||
{isModelDropdownOpen && (
|
||||
<div
|
||||
className="absolute z-10 w-full mt-1 py-1 rounded-lg border border-bolt-elements-borderColor bg-bolt-elements-background-depth-2 shadow-lg"
|
||||
className="absolute z-10 w-full mt-1 py-1 rounded-lg border border-bolt-elements-borderColor bg-bolt-elements-background-depth-2 shadow-lg"
|
||||
role="listbox"
|
||||
id="model-listbox"
|
||||
>
|
||||
<div className="px-2 pb-2">
|
||||
<div className="relative">
|
||||
<input
|
||||
ref={searchInputRef}
|
||||
ref={modelSearchInputRef}
|
||||
type="text"
|
||||
value={modelSearchQuery}
|
||||
onChange={(e) => setModelSearchQuery(e.target.value)}
|
||||
placeholder="Search models..."
|
||||
className={classNames(
|
||||
'w-full pl-8 pr-3 py-1.5 rounded-md text-sm',
|
||||
'w-full pl-2 py-1.5 rounded-md text-sm',
|
||||
'bg-bolt-elements-background-depth-2 border border-bolt-elements-borderColor',
|
||||
'text-bolt-elements-textPrimary placeholder:text-bolt-elements-textTertiary',
|
||||
'focus:outline-none focus:ring-2 focus:ring-bolt-elements-focus',
|
||||
@@ -277,8 +432,8 @@ export const ModelSelector = ({
|
||||
) : (
|
||||
filteredModels.map((modelOption, index) => (
|
||||
<div
|
||||
ref={(el) => (optionsRef.current[index] = el)}
|
||||
key={index}
|
||||
ref={(el) => (modelOptionsRef.current[index] = el)}
|
||||
key={index} // Consider using modelOption.name if unique
|
||||
role="option"
|
||||
aria-selected={model === modelOption.name}
|
||||
className={classNames(
|
||||
@@ -286,10 +441,10 @@ export const ModelSelector = ({
|
||||
'hover:bg-bolt-elements-background-depth-3',
|
||||
'text-bolt-elements-textPrimary',
|
||||
'outline-none',
|
||||
model === modelOption.name || focusedIndex === index
|
||||
model === modelOption.name || focusedModelIndex === index
|
||||
? 'bg-bolt-elements-background-depth-2'
|
||||
: undefined,
|
||||
focusedIndex === index ? 'ring-1 ring-inset ring-bolt-elements-focus' : undefined,
|
||||
focusedModelIndex === index ? 'ring-1 ring-inset ring-bolt-elements-focus' : undefined,
|
||||
)}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
@@ -297,7 +452,7 @@ export const ModelSelector = ({
|
||||
setIsModelDropdownOpen(false);
|
||||
setModelSearchQuery('');
|
||||
}}
|
||||
tabIndex={focusedIndex === index ? 0 : -1}
|
||||
tabIndex={focusedModelIndex === index ? 0 : -1}
|
||||
>
|
||||
{modelOption.label}
|
||||
</div>
|
||||
|
||||
@@ -21,19 +21,11 @@ const FrameworkLink: React.FC<FrameworkLinkProps> = ({ template }) => (
|
||||
);
|
||||
|
||||
const StarterTemplates: React.FC = () => {
|
||||
// Debug: Log available templates and their icons
|
||||
React.useEffect(() => {
|
||||
console.log(
|
||||
'Available templates:',
|
||||
STARTER_TEMPLATES.map((t) => ({ name: t.name, icon: t.icon })),
|
||||
);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<span className="text-sm text-gray-500">or start a blank app with your favorite stack</span>
|
||||
<div className="flex justify-center">
|
||||
<div className="flex w-70 flex-wrap items-center justify-center gap-4">
|
||||
<div className="flex flex-wrap justify-center items-center gap-4 max-w-sm">
|
||||
{STARTER_TEMPLATES.map((template) => (
|
||||
<FrameworkLink key={template.name} template={template} />
|
||||
))}
|
||||
|
||||
@@ -99,7 +99,7 @@ export function SupabaseChatAlert({ alert, clearAlert, postMessage }: Props) {
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -20 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
className="max-w-chat rounded-lg border-l-2 border-l-[#098F5F] border-bolt-elements-borderColor bg-bolt-elements-background-depth-2"
|
||||
className="max-w-chat rounded-lg border-l-2 border-l-[#098F5F] border border-bolt-elements-borderColor bg-bolt-elements-background-depth-2"
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="p-4 pb-2">
|
||||
|
||||
@@ -296,7 +296,7 @@ export function SupabaseConnection() {
|
||||
<DialogButton type="secondary">Close</DialogButton>
|
||||
</DialogClose>
|
||||
<DialogButton type="danger" onClick={handleDisconnect}>
|
||||
<div className="i-ph:plug-x w-4 h-4" />
|
||||
<div className="i-ph:plugs w-4 h-4" />
|
||||
Disconnect
|
||||
</DialogButton>
|
||||
</div>
|
||||
|
||||
@@ -16,7 +16,7 @@ export function UserMessage({ content }: UserMessageProps) {
|
||||
const images = content.filter((item) => item.type === 'image' && item.image);
|
||||
|
||||
return (
|
||||
<div className="overflow-hidden pt-[4px]">
|
||||
<div className="overflow-hidden flex items-center">
|
||||
<div className="flex flex-col gap-4">
|
||||
{textContent && <Markdown html>{textContent}</Markdown>}
|
||||
{images.map((item, index) => (
|
||||
|
||||
@@ -64,13 +64,13 @@ export function ImportButtons(importChat: ((description: string, messages: Messa
|
||||
const input = document.getElementById('chat-import');
|
||||
input?.click();
|
||||
}}
|
||||
variant="outline"
|
||||
variant="default"
|
||||
size="lg"
|
||||
className={classNames(
|
||||
'gap-2 bg-bolt-elements-background-depth-1',
|
||||
'text-bolt-elements-textPrimary',
|
||||
'hover:bg-bolt-elements-background-depth-2',
|
||||
'border-[rgba(0,0,0,0.08)] dark:border-[rgba(255,255,255,0.08)]',
|
||||
'border border-bolt-elements-borderColor',
|
||||
'h-10 px-4 py-2 min-w-[120px] justify-center',
|
||||
'transition-all duration-200 ease-in-out',
|
||||
)}
|
||||
|
||||
@@ -116,7 +116,7 @@ export const Dialog = memo(({ children, className, showCloseButton = true, onClo
|
||||
<RadixDialog.Content asChild>
|
||||
<motion.div
|
||||
className={classNames(
|
||||
'fixed top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 bg-white dark:bg-gray-950 rounded-lg shadow-xl border border-bolt-elements-borderColor z-[9999] w-[520px]',
|
||||
'fixed top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 bg-white dark:bg-gray-950 rounded-lg shadow-xl border border-bolt-elements-borderColor z-[9999] w-[520px] focus:outline-none',
|
||||
className,
|
||||
)}
|
||||
initial="closed"
|
||||
|
||||
@@ -46,7 +46,7 @@ export const IconButton = memo(
|
||||
<button
|
||||
ref={ref}
|
||||
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',
|
||||
'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 focus:outline-none',
|
||||
{
|
||||
[classNames('opacity-30', disabledClassName)]: disabled,
|
||||
},
|
||||
|
||||
@@ -114,7 +114,7 @@ export const EditorPanel = memo(
|
||||
</div>
|
||||
)}
|
||||
</PanelHeader>
|
||||
<div className="h-full flex-1 overflow-hidden">
|
||||
<div className="h-full flex-1 overflow-hidden modern-scrollbar">
|
||||
<CodeMirrorEditor
|
||||
theme={theme}
|
||||
editable={!isStreaming && editorDocument !== undefined}
|
||||
|
||||
55
app/components/workbench/ExpoQrModal.tsx
Normal file
55
app/components/workbench/ExpoQrModal.tsx
Normal file
@@ -0,0 +1,55 @@
|
||||
import React from 'react';
|
||||
import { Dialog, DialogTitle, DialogDescription, DialogRoot } from '~/components/ui/Dialog';
|
||||
import { useStore } from '@nanostores/react';
|
||||
import { expoUrlAtom } from '~/lib/stores/qrCodeStore';
|
||||
import { QRCode } from 'react-qrcode-logo';
|
||||
|
||||
interface ExpoQrModalProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export const ExpoQrModal: React.FC<ExpoQrModalProps> = ({ open, onClose }) => {
|
||||
const expoUrl = useStore(expoUrlAtom);
|
||||
|
||||
return (
|
||||
<DialogRoot open={open} onOpenChange={(v) => !v && onClose()}>
|
||||
<Dialog
|
||||
className="text-center !flex-col !mx-auto !text-center !max-w-md"
|
||||
showCloseButton={true}
|
||||
onClose={onClose}
|
||||
>
|
||||
<div className="border !border-bolt-elements-borderColor flex flex-col gap-5 justify-center items-center p-6 bg-bolt-elements-background-depth-2 rounded-md">
|
||||
<div className="i-bolt:expo-brand h-10 w-full invert dark:invert-none"></div>
|
||||
<DialogTitle className="text-bolt-elements-textTertiary text-lg font-semibold leading-6">
|
||||
Preview on your own mobile device
|
||||
</DialogTitle>
|
||||
<DialogDescription className="bg-bolt-elements-background-depth-3 max-w-sm rounded-md p-1 border border-bolt-elements-borderColor">
|
||||
Scan this QR code with the Expo Go app on your mobile device to open your project.
|
||||
</DialogDescription>
|
||||
<div className="my-6 flex flex-col items-center">
|
||||
{expoUrl ? (
|
||||
<QRCode
|
||||
logoImage="/favicon.svg"
|
||||
removeQrCodeBehindLogo={true}
|
||||
logoPadding={3}
|
||||
logoHeight={50}
|
||||
logoWidth={50}
|
||||
logoPaddingStyle="square"
|
||||
style={{
|
||||
borderRadius: 16,
|
||||
padding: 2,
|
||||
backgroundColor: '#8a5fff',
|
||||
}}
|
||||
value={expoUrl}
|
||||
size={200}
|
||||
/>
|
||||
) : (
|
||||
<div className="text-gray-500 text-center">No Expo URL detected.</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
</DialogRoot>
|
||||
);
|
||||
};
|
||||
@@ -143,7 +143,7 @@ export const FileTree = memo(
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={classNames('text-sm', className, 'overflow-y-auto')}>
|
||||
<div className={classNames('text-sm', className, 'overflow-y-auto modern-scrollbar')}>
|
||||
{filteredFileList.map((fileOrFolder) => {
|
||||
switch (fileOrFolder.kind) {
|
||||
case 'file': {
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { memo, useEffect, useRef } from 'react';
|
||||
import { IconButton } from '~/components/ui/IconButton';
|
||||
import type { PreviewInfo } from '~/lib/stores/previews';
|
||||
|
||||
interface PortDropdownProps {
|
||||
@@ -48,9 +47,18 @@ export const PortDropdown = memo(
|
||||
|
||||
return (
|
||||
<div className="relative z-port-dropdown" ref={dropdownRef}>
|
||||
<IconButton icon="i-ph:plug" onClick={() => setIsDropdownOpen(!isDropdownOpen)} />
|
||||
{/* Display the active port if available, otherwise show the plug icon */}
|
||||
<button
|
||||
className="flex items-center group-focus-within:text-bolt-elements-preview-addressBar-text bg-white group-focus-within:bg-bolt-elements-preview-addressBar-background dark:bg-bolt-elements-preview-addressBar-backgroundHover rounded-full px-2 py-1 gap-1.5"
|
||||
onClick={() => setIsDropdownOpen(!isDropdownOpen)}
|
||||
>
|
||||
<span className="i-ph:plug text-base"></span>
|
||||
{previews.length > 0 && activePreviewIndex >= 0 && activePreviewIndex < previews.length ? (
|
||||
<span className="text-xs font-medium">{previews[activePreviewIndex].port}</span>
|
||||
) : null}
|
||||
</button>
|
||||
{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="absolute left-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>
|
||||
|
||||
@@ -4,6 +4,8 @@ import { IconButton } from '~/components/ui/IconButton';
|
||||
import { workbenchStore } from '~/lib/stores/workbench';
|
||||
import { PortDropdown } from './PortDropdown';
|
||||
import { ScreenshotSelector } from './ScreenshotSelector';
|
||||
import { expoUrlAtom } from '~/lib/stores/qrCodeStore';
|
||||
import { ExpoQrModal } from '~/components/workbench/ExpoQrModal';
|
||||
|
||||
type ResizeSide = 'left' | 'right' | null;
|
||||
|
||||
@@ -53,12 +55,10 @@ export const Preview = memo(() => {
|
||||
const [activePreviewIndex, setActivePreviewIndex] = useState(0);
|
||||
const [isPortDropdownOpen, setIsPortDropdownOpen] = useState(false);
|
||||
const [isFullscreen, setIsFullscreen] = useState(false);
|
||||
const [isPreviewOnly, setIsPreviewOnly] = useState(false);
|
||||
const hasSelectedPreview = useRef(false);
|
||||
const previews = useStore(workbenchStore.previews);
|
||||
const activePreview = previews[activePreviewIndex];
|
||||
|
||||
const [url, setUrl] = useState('');
|
||||
const [displayPath, setDisplayPath] = useState('/');
|
||||
const [iframeUrl, setIframeUrl] = useState<string | undefined>();
|
||||
const [isSelectionMode, setIsSelectionMode] = useState(false);
|
||||
|
||||
@@ -86,39 +86,22 @@ export const Preview = memo(() => {
|
||||
const [isLandscape, setIsLandscape] = useState(false);
|
||||
const [showDeviceFrame, setShowDeviceFrame] = useState(true);
|
||||
const [showDeviceFrameInPreview, setShowDeviceFrameInPreview] = useState(false);
|
||||
const expoUrl = useStore(expoUrlAtom);
|
||||
const [isExpoQrModalOpen, setIsExpoQrModalOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!activePreview) {
|
||||
setUrl('');
|
||||
setIframeUrl(undefined);
|
||||
setDisplayPath('/');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const { baseUrl } = activePreview;
|
||||
setUrl(baseUrl);
|
||||
setIframeUrl(baseUrl);
|
||||
setDisplayPath('/');
|
||||
}, [activePreview]);
|
||||
|
||||
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;
|
||||
@@ -565,6 +548,12 @@ export const Preview = memo(() => {
|
||||
}
|
||||
};
|
||||
|
||||
const openInNewTab = () => {
|
||||
if (activePreview?.baseUrl) {
|
||||
window.open(activePreview?.baseUrl, '_blank');
|
||||
}
|
||||
};
|
||||
|
||||
// Function to get the correct frame padding based on orientation
|
||||
const getFramePadding = useCallback(() => {
|
||||
if (!selectedWindowSize) {
|
||||
@@ -630,10 +619,7 @@ export const Preview = memo(() => {
|
||||
}, [showDeviceFrameInPreview]);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className={`w-full h-full flex flex-col relative ${isPreviewOnly ? 'fixed inset-0 z-50 bg-white' : ''}`}
|
||||
>
|
||||
<div ref={containerRef} className={`w-full h-full flex flex-col relative`}>
|
||||
{isPortDropdownOpen && (
|
||||
<div className="z-iframe-overlay w-full h-full absolute" onClick={() => setIsPortDropdownOpen(false)} />
|
||||
)}
|
||||
@@ -647,50 +633,60 @@ export const Preview = memo(() => {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex-grow flex items-center gap-1 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">
|
||||
<div className="flex-grow flex items-center gap-1 bg-bolt-elements-preview-addressBar-background border border-bolt-elements-borderColor text-bolt-elements-preview-addressBar-text rounded-full px-1 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">
|
||||
<PortDropdown
|
||||
activePreviewIndex={activePreviewIndex}
|
||||
setActivePreviewIndex={setActivePreviewIndex}
|
||||
isDropdownOpen={isPortDropdownOpen}
|
||||
setHasSelectedPreview={(value) => (hasSelectedPreview.current = value)}
|
||||
setIsDropdownOpen={setIsPortDropdownOpen}
|
||||
previews={previews}
|
||||
/>
|
||||
<input
|
||||
title="URL"
|
||||
title="URL Path"
|
||||
ref={inputRef}
|
||||
className="w-full bg-transparent outline-none"
|
||||
type="text"
|
||||
value={url}
|
||||
value={displayPath}
|
||||
onChange={(event) => {
|
||||
setUrl(event.target.value);
|
||||
setDisplayPath(event.target.value);
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter' && validateUrl(url)) {
|
||||
setIframeUrl(url);
|
||||
if (event.key === 'Enter' && activePreview) {
|
||||
let targetPath = displayPath.trim();
|
||||
|
||||
if (!targetPath.startsWith('/')) {
|
||||
targetPath = '/' + targetPath;
|
||||
}
|
||||
|
||||
const fullUrl = activePreview.baseUrl + targetPath;
|
||||
setIframeUrl(fullUrl);
|
||||
setDisplayPath(targetPath);
|
||||
|
||||
if (inputRef.current) {
|
||||
inputRef.current.blur();
|
||||
}
|
||||
}
|
||||
}}
|
||||
disabled={!activePreview}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
{previews.length > 1 && (
|
||||
<PortDropdown
|
||||
activePreviewIndex={activePreviewIndex}
|
||||
setActivePreviewIndex={setActivePreviewIndex}
|
||||
isDropdownOpen={isPortDropdownOpen}
|
||||
setHasSelectedPreview={(value) => (hasSelectedPreview.current = value)}
|
||||
setIsDropdownOpen={setIsPortDropdownOpen}
|
||||
previews={previews}
|
||||
/>
|
||||
)}
|
||||
|
||||
<IconButton
|
||||
icon="i-ph:devices"
|
||||
onClick={toggleDeviceMode}
|
||||
title={isDeviceModeOn ? 'Switch to Responsive Mode' : 'Switch to Device Mode'}
|
||||
/>
|
||||
|
||||
{expoUrl && <IconButton icon="i-ph:qr-code" onClick={() => setIsExpoQrModalOpen(true)} title="Show QR" />}
|
||||
|
||||
<ExpoQrModal open={isExpoQrModalOpen} onClose={() => setIsExpoQrModalOpen(false)} />
|
||||
|
||||
{isDeviceModeOn && (
|
||||
<>
|
||||
<IconButton
|
||||
icon="i-ph:rotate-right"
|
||||
icon="i-ph:device-rotate"
|
||||
onClick={() => setIsLandscape(!isLandscape)}
|
||||
title={isLandscape ? 'Switch to Portrait' : 'Switch to Landscape'}
|
||||
/>
|
||||
@@ -702,60 +698,17 @@ export const Preview = memo(() => {
|
||||
</>
|
||||
)}
|
||||
|
||||
<IconButton
|
||||
icon="i-ph:layout-light"
|
||||
onClick={() => setIsPreviewOnly(!isPreviewOnly)}
|
||||
title={isPreviewOnly ? 'Show Full Interface' : 'Show Preview Only'}
|
||||
/>
|
||||
|
||||
<IconButton
|
||||
icon={isFullscreen ? 'i-ph:arrows-in' : 'i-ph:arrows-out'}
|
||||
onClick={toggleFullscreen}
|
||||
title={isFullscreen ? 'Exit Full Screen' : 'Full Screen'}
|
||||
/>
|
||||
|
||||
{/* Simple preview button */}
|
||||
<IconButton
|
||||
icon="i-ph:browser"
|
||||
onClick={() => {
|
||||
if (!activePreview?.baseUrl) {
|
||||
console.warn('[Preview] No active preview available');
|
||||
return;
|
||||
}
|
||||
|
||||
const match = activePreview.baseUrl.match(
|
||||
/^https?:\/\/([^.]+)\.local-credentialless\.webcontainer-api\.io/,
|
||||
);
|
||||
|
||||
if (!match) {
|
||||
console.warn('[Preview] Invalid WebContainer URL:', activePreview.baseUrl);
|
||||
return;
|
||||
}
|
||||
|
||||
const previewId = match[1];
|
||||
const previewUrl = `/webcontainer/preview/${previewId}`;
|
||||
|
||||
// Open in a new window with simple parameters
|
||||
window.open(
|
||||
previewUrl,
|
||||
`preview-${previewId}`,
|
||||
'width=1280,height=720,menubar=no,toolbar=no,location=no,status=no,resizable=yes',
|
||||
);
|
||||
}}
|
||||
title="Open Preview in New Window"
|
||||
/>
|
||||
|
||||
<div className="flex items-center relative">
|
||||
<IconButton
|
||||
icon="i-ph:arrow-square-out"
|
||||
onClick={() => openInNewWindow(selectedWindowSize)}
|
||||
title={`Open Preview in ${selectedWindowSize.name} Window`}
|
||||
/>
|
||||
<IconButton
|
||||
icon="i-ph:caret-down"
|
||||
icon="i-ph:list"
|
||||
onClick={() => setIsWindowSizeDropdownOpen(!isWindowSizeDropdownOpen)}
|
||||
className="ml-1"
|
||||
title="Select Window Size"
|
||||
title="New Window Options"
|
||||
/>
|
||||
|
||||
{isWindowSizeDropdownOpen && (
|
||||
@@ -764,11 +717,51 @@ export const Preview = memo(() => {
|
||||
<div className="absolute right-0 top-full mt-2 z-50 min-w-[240px] max-h-[400px] overflow-y-auto bg-white dark:bg-black rounded-xl shadow-2xl border border-[#E5E7EB] dark:border-[rgba(255,255,255,0.1)] overflow-hidden">
|
||||
<div className="p-3 border-b border-[#E5E7EB] dark:border-[rgba(255,255,255,0.1)]">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-sm font-medium text-[#111827] dark:text-gray-300">Device Options</span>
|
||||
<span className="text-sm font-medium text-[#111827] dark:text-gray-300">Window Options</span>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<button
|
||||
className={`flex w-full justify-between items-center text-start bg-transparent text-xs text-bolt-elements-textTertiary hover:text-bolt-elements-textPrimary`}
|
||||
onClick={() => {
|
||||
openInNewTab();
|
||||
}}
|
||||
>
|
||||
<span>Open in new tab</span>
|
||||
<div className="i-ph:arrow-square-out h-5 w-4" />
|
||||
</button>
|
||||
<button
|
||||
className={`flex w-full justify-between items-center text-start bg-transparent text-xs text-bolt-elements-textTertiary hover:text-bolt-elements-textPrimary`}
|
||||
onClick={() => {
|
||||
if (!activePreview?.baseUrl) {
|
||||
console.warn('[Preview] No active preview available');
|
||||
return;
|
||||
}
|
||||
|
||||
const match = activePreview.baseUrl.match(
|
||||
/^https?:\/\/([^.]+)\.local-credentialless\.webcontainer-api\.io/,
|
||||
);
|
||||
|
||||
if (!match) {
|
||||
console.warn('[Preview] Invalid WebContainer URL:', activePreview.baseUrl);
|
||||
return;
|
||||
}
|
||||
|
||||
const previewId = match[1];
|
||||
const previewUrl = `/webcontainer/preview/${previewId}`;
|
||||
|
||||
// Open in a new window with simple parameters
|
||||
window.open(
|
||||
previewUrl,
|
||||
`preview-${previewId}`,
|
||||
'width=1280,height=720,menubar=no,toolbar=no,location=no,status=no,resizable=yes',
|
||||
);
|
||||
}}
|
||||
>
|
||||
<span>Open in new window</span>
|
||||
<div className="i-ph:browser h-5 w-4" />
|
||||
</button>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs text-[#6B7280] dark:text-gray-400">Show Device Frame</span>
|
||||
<span className="text-xs text-bolt-elements-textTertiary">Show Device Frame</span>
|
||||
<button
|
||||
className={`w-10 h-5 rounded-full transition-colors duration-200 ${
|
||||
showDeviceFrame ? 'bg-[#6D28D9]' : 'bg-gray-300 dark:bg-gray-700'
|
||||
@@ -786,7 +779,7 @@ export const Preview = memo(() => {
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs text-[#6B7280] dark:text-gray-400">Landscape Mode</span>
|
||||
<span className="text-xs text-bolt-elements-textTertiary">Landscape Mode</span>
|
||||
<button
|
||||
className={`w-10 h-5 rounded-full transition-colors duration-200 ${
|
||||
isLandscape ? 'bg-[#6D28D9]' : 'bg-gray-300 dark:bg-gray-700'
|
||||
@@ -959,7 +952,7 @@ export const Preview = memo(() => {
|
||||
className="border-none w-full h-full bg-bolt-elements-background-depth-1"
|
||||
src={iframeUrl}
|
||||
sandbox="allow-scripts allow-forms allow-popups allow-modals allow-storage-access-by-user-activation allow-same-origin"
|
||||
allow="cross-origin-isolated"
|
||||
allow="geolocation; ch-ua-full-version-list; cross-origin-isolated; screen-wake-lock; publickey-credentials-get; shared-storage-select-url; ch-ua-arch; bluetooth; compute-pressure; ch-prefers-reduced-transparency; deferred-fetch; usb; ch-save-data; publickey-credentials-create; shared-storage; deferred-fetch-minimal; run-ad-auction; ch-ua-form-factors; ch-downlink; otp-credentials; payment; ch-ua; ch-ua-model; ch-ect; autoplay; camera; private-state-token-issuance; accelerometer; ch-ua-platform-version; idle-detection; private-aggregation; interest-cohort; ch-viewport-height; local-fonts; ch-ua-platform; midi; ch-ua-full-version; xr-spatial-tracking; clipboard-read; gamepad; display-capture; keyboard-map; join-ad-interest-group; ch-width; ch-prefers-reduced-motion; browsing-topics; encrypted-media; gyroscope; serial; ch-rtt; ch-ua-mobile; window-management; unload; ch-dpr; ch-prefers-color-scheme; ch-ua-wow64; attribution-reporting; fullscreen; identity-credentials-get; private-state-token-redemption; hid; ch-ua-bitness; storage-access; sync-xhr; ch-device-memory; ch-viewport-width; picture-in-picture; magnetometer; clipboard-write; microphone"
|
||||
/>
|
||||
)}
|
||||
<ScreenshotSelector
|
||||
|
||||
@@ -25,6 +25,7 @@ import { Preview } from './Preview';
|
||||
import useViewport from '~/lib/hooks';
|
||||
import { PushToGitHubDialog } from '~/components/@settings/tabs/connections/components/PushToGitHubDialog';
|
||||
import * as DropdownMenu from '@radix-ui/react-dropdown-menu';
|
||||
import { usePreviewStore } from '~/lib/stores/previews';
|
||||
|
||||
interface WorkspaceProps {
|
||||
chatStarted?: boolean;
|
||||
@@ -323,9 +324,16 @@ export const Workbench = memo(
|
||||
}, []);
|
||||
|
||||
const onFileSave = useCallback(() => {
|
||||
workbenchStore.saveCurrentDocument().catch(() => {
|
||||
toast.error('Failed to update file content');
|
||||
});
|
||||
workbenchStore
|
||||
.saveCurrentDocument()
|
||||
.then(() => {
|
||||
// Explicitly refresh all previews after a file save
|
||||
const previewStore = usePreviewStore();
|
||||
previewStore.refreshAllPreviews();
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error('Failed to update file content');
|
||||
});
|
||||
}, []);
|
||||
|
||||
const onFileReset = useCallback(() => {
|
||||
|
||||
@@ -150,7 +150,7 @@ export const TerminalTabs = memo(() => {
|
||||
<Terminal
|
||||
key={index}
|
||||
id={`terminal_${index}`}
|
||||
className={classNames('h-full overflow-hidden', {
|
||||
className={classNames('h-full overflow-hidden modern-scrollbar-invert', {
|
||||
hidden: !isActive,
|
||||
})}
|
||||
ref={(ref) => {
|
||||
@@ -166,7 +166,7 @@ export const TerminalTabs = memo(() => {
|
||||
<Terminal
|
||||
key={index}
|
||||
id={`terminal_${index}`}
|
||||
className={classNames('h-full overflow-hidden', {
|
||||
className={classNames('modern-scrollbar h-full overflow-hidden', {
|
||||
hidden: !isActive,
|
||||
})}
|
||||
ref={(ref) => {
|
||||
|
||||
Reference in New Issue
Block a user