feat: add Expo QR code generation and modal for mobile preview

Introduce Expo QR code functionality to allow users to preview their projects on mobile devices. Added a new QR code modal component, integrated it into the chat and preview components, and implemented Expo URL detection in the shell process. This enhances the mobile development workflow by providing a seamless way to test Expo projects directly on devices.

- Clean up and consolidate Preview icon buttons while removing redundant ones.
This commit is contained in:
KevIsDev 2025-04-17 13:03:41 +01:00
parent cbc22cdbdb
commit 9039653ae0
12 changed files with 238 additions and 106 deletions

View File

@ -39,6 +39,9 @@ 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 '~/stores/qrCodeStore';
import { useStore } from '@nanostores/react';
const TEXTAREA_MIN_HEIGHT = 76;
@ -130,6 +133,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(
@ -622,6 +634,7 @@ export const BaseChat = React.forwardRef<HTMLDivElement, BaseChatProps>(
</div>
) : null}
<SupabaseConnection />
<ExpoQrModal open={qrModalOpen} onClose={() => setQrModalOpen(false)} />
</div>
</div>
</div>

View File

@ -249,6 +249,8 @@ export const ChatImpl = memo(
});
}, [messages, isLoading, parseMessages]);
console.log('messages', messages);
const scrollTextArea = () => {
const textarea = textareaRef.current;

View File

@ -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"

View File

@ -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,
},

View File

@ -0,0 +1,43 @@
import React from 'react';
import { Dialog, DialogTitle, DialogDescription, DialogRoot } from '~/components/ui/Dialog';
import { useStore } from '@nanostores/react';
import { expoUrlAtom } from '~/stores/qrCodeStore';
import QRCode from 'react-qr-code';
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"></div>
<DialogTitle className="text-white 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 ? (
<div className="bg-white p-1 flex flex-col rounded-md justify-center items-center ">
<QRCode value={expoUrl} size={180} />
</div>
) : (
<div className="text-gray-500 text-center">No Expo URL detected.</div>
)}
</div>
</div>
</Dialog>
</DialogRoot>
);
};

View File

@ -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 '~/stores/qrCodeStore';
import { ExpoQrModal } from '~/components/workbench/ExpoQrModal';
type ResizeSide = 'left' | 'right' | null;
@ -53,7 +55,6 @@ 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];
@ -86,6 +87,8 @@ 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) {
@ -636,10 +639,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)} />
)}
@ -693,6 +693,10 @@ export const Preview = memo(() => {
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
@ -708,60 +712,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 && (
@ -770,7 +731,7 @@ 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
@ -782,6 +743,37 @@ export const Preview = memo(() => {
<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-bolt-elements-textTertiary">Show Device Frame</span>
<button

View File

@ -38,6 +38,8 @@ You are Bolt, an expert AI assistant and exceptional senior software developer w
IMPORTANT: When choosing databases or npm packages, prefer options that don't rely on native binaries. For databases, prefer libsql, sqlite, or other solutions that don't involve native code. WebContainer CANNOT execute arbitrary native binaries.
IMPORTANT You must never use the "bundled" type for artifacts.
Available shell commands:
File Operations:
- cat: Display file contents
@ -481,6 +483,7 @@ Here are some examples of correct usage of artifacts:
The following instructions guide how you should handle mobile app development using Expo and React Native.
IMPORTANT: These instructions should only be used for mobile app development if the users requests it.
IMPORTANT: Make sure to follow the instructions below to ensure a successful mobile app development process, The project structure must follow what has been provided.
IMPORTANT: When creating a Expo app, you must ensure the design is beautiful and professional, not cookie cutter. The app should be populted with content and never just be a blank example.
<core_requirements>
- Version: 2025

View File

@ -0,0 +1,4 @@
import { atom } from 'nanostores';
export const qrCodeAtom = atom<string | null>(null);
export const expoUrlAtom = atom<string | null>(null);

View File

@ -2,6 +2,7 @@ import type { WebContainer, WebContainerProcess } from '@webcontainer/api';
import type { ITerminal } from '~/types/terminal';
import { withResolvers } from './promises';
import { atom } from 'nanostores';
import { expoUrlAtom } from '~/stores/qrCodeStore';
export async function newShellProcess(webcontainer: WebContainer, terminal: ITerminal) {
const args: string[] = [];
@ -80,13 +81,96 @@ export class BoltShell {
this.#webcontainer = webcontainer;
this.#terminal = terminal;
const { process, output } = await this.newBoltShellProcess(webcontainer, terminal);
// Use all three streams from tee: one for terminal, one for command execution, one for Expo URL detection
const { process, commandStream, expoUrlStream } = await this.newBoltShellProcess(webcontainer, terminal);
this.#process = process;
this.#outputStream = output.getReader();
this.#outputStream = commandStream.getReader();
// Start background Expo URL watcher immediately
this._watchExpoUrlInBackground(expoUrlStream);
await this.waitTillOscCode('interactive');
this.#initialized?.();
}
async newBoltShellProcess(webcontainer: WebContainer, terminal: ITerminal) {
const args: string[] = [];
const process = await webcontainer.spawn('/bin/jsh', ['--osc', ...args], {
terminal: {
cols: terminal.cols ?? 80,
rows: terminal.rows ?? 15,
},
});
const input = process.input.getWriter();
this.#shellInputStream = input;
// Tee the output so we can have three independent readers
const [streamA, streamB] = process.output.tee();
const [streamC, streamD] = streamB.tee();
const jshReady = withResolvers<void>();
let isInteractive = false;
streamA.pipeTo(
new WritableStream({
write(data) {
if (!isInteractive) {
const [, osc] = data.match(/\x1b\]654;([^\x07]+)\x07/) || [];
if (osc === 'interactive') {
isInteractive = true;
jshReady.resolve();
}
}
terminal.write(data);
},
}),
);
terminal.onData((data) => {
if (isInteractive) {
input.write(data);
}
});
await jshReady.promise;
// Return all streams for use in init
return { process, terminalStream: streamA, commandStream: streamC, expoUrlStream: streamD };
}
// Dedicated background watcher for Expo URL
private async _watchExpoUrlInBackground(stream: ReadableStream<string>) {
const reader = stream.getReader();
let buffer = '';
const expoUrlRegex = /(exp:\/\/[^\s]+)/;
while (true) {
const { value, done } = await reader.read();
if (done) {
break;
}
buffer += value || '';
const expoUrlMatch = buffer.match(expoUrlRegex);
if (expoUrlMatch) {
const cleanUrl = expoUrlMatch[1]
.replace(/[\u001b\u009b][[()#;?]*(?:[0-9]{1,4}(?:;[0-9]{0,4})*)?[0-9A-ORZcf-nqry=><]/g, '')
.replace(/[^\x20-\x7E]+$/g, '');
expoUrlAtom.set(cleanUrl);
buffer = buffer.slice(buffer.indexOf(expoUrlMatch[1]) + expoUrlMatch[1].length);
}
if (buffer.length > 2048) {
buffer = buffer.slice(-2048);
}
}
}
get terminal() {
return this.#terminal;
}
@ -138,65 +222,17 @@ export class BoltShell {
return resp;
}
async newBoltShellProcess(webcontainer: WebContainer, terminal: ITerminal) {
const args: string[] = [];
// we spawn a JSH process with a fallback cols and rows in case the process is not attached yet to a visible terminal
const process = await webcontainer.spawn('/bin/jsh', ['--osc', ...args], {
terminal: {
cols: terminal.cols ?? 80,
rows: terminal.rows ?? 15,
},
});
const input = process.input.getWriter();
this.#shellInputStream = input;
const [internalOutput, terminalOutput] = process.output.tee();
const jshReady = withResolvers<void>();
let isInteractive = false;
terminalOutput.pipeTo(
new WritableStream({
write(data) {
if (!isInteractive) {
const [, osc] = data.match(/\x1b\]654;([^\x07]+)\x07/) || [];
if (osc === 'interactive') {
// wait until we see the interactive OSC
isInteractive = true;
jshReady.resolve();
}
}
terminal.write(data);
},
}),
);
terminal.onData((data) => {
// console.log('terminal onData', { data, isInteractive });
if (isInteractive) {
input.write(data);
}
});
await jshReady.promise;
return { process, output: internalOutput };
}
async getCurrentExecutionResult(): Promise<ExecutionResult> {
const { output, exitCode } = await this.waitTillOscCode('exit');
return { output, exitCode };
}
onQRCodeDetected?: (qrCode: string) => void;
async waitTillOscCode(waitCode: string) {
let fullOutput = '';
let exitCode: number = 0;
let buffer = ''; // <-- Add a buffer to accumulate output
if (!this.#outputStream) {
return { output: fullOutput, exitCode };
@ -204,6 +240,9 @@ export class BoltShell {
const tappedStream = this.#outputStream;
// Regex for Expo URL
const expoUrlRegex = /(exp:\/\/[^\s]+)/;
while (true) {
const { value, done } = await tappedStream.read();
@ -213,6 +252,21 @@ export class BoltShell {
const text = value || '';
fullOutput += text;
buffer += text; // <-- Accumulate in buffer
// Extract Expo URL from buffer and set store
const expoUrlMatch = buffer.match(expoUrlRegex);
if (expoUrlMatch) {
// Remove any trailing ANSI escape codes or non-printable characters
const cleanUrl = expoUrlMatch[1]
.replace(/[\u001b\u009b][[()#;?]*(?:[0-9]{1,4}(?:;[0-9]{0,4})*)?[0-9A-ORZcf-nqry=><]/g, '')
.replace(/[^\x20-\x7E]+$/g, '');
expoUrlAtom.set(cleanUrl);
// Remove everything up to and including the URL from the buffer to avoid duplicate matches
buffer = buffer.slice(buffer.indexOf(expoUrlMatch[1]) + expoUrlMatch[1].length);
}
// Check if command completion signal with exit code
const [, osc, , , code] = text.match(/\x1b\]654;([^\x07=]+)=?((-?\d+):(\d+))?\x07/) || [];

1
icons/expo-brand.svg Normal file
View File

@ -0,0 +1 @@
<svg width="114" height="32" viewBox="0 0 114 32"><path fill="#FFF" d="M14.805 10.334c.256-.377.535-.425.762-.425.227 0 .605.048.86.425 2.015 2.766 5.34 8.277 7.791 12.342 1.6 2.65 2.828 4.687 3.08 4.946.946.972 2.243.366 2.997-.737.742-1.086.948-1.849.948-2.663 0-.554-10.752-20.552-11.835-22.217C18.366.405 18.028 0 16.245 0H14.91c-1.777 0-2.034.404-3.075 2.005C10.753 3.67 0 23.668 0 24.222c0 .814.206 1.577.948 2.663.754 1.103 2.052 1.71 2.998.737.252-.26 1.48-2.295 3.08-4.946 2.451-4.065 5.765-9.576 7.78-12.342Zm23.913-8.566v24.55h14.96v-4.98h-9.799v-5.19h8.718v-4.98h-8.718v-4.42h9.799v-4.98h-14.96Zm34.922 24.55-6.208-8.908 5.789-8.312h-5.859l-2.859 4.068-2.825-4.068H55.75l5.789 8.347-6.172 8.873h5.858l3.243-4.664 3.243 4.664h5.928ZM86.115 8.747c-2.37 0-4.22.982-5.405 2.77v-2.42h-4.916V32h4.916v-8.102c1.186 1.789 3.034 2.771 5.405 2.771 4.429 0 7.95-4.033 7.95-8.979 0-4.945-3.521-8.943-7.95-8.943ZM85 21.934c-2.407 0-4.29-1.824-4.29-4.244 0-2.384 1.883-4.243 4.29-4.243 2.37 0 4.289 1.894 4.289 4.244 0 2.384-1.918 4.243-4.29 4.243Zm19.791-13.187c-5.056 0-8.892 3.858-8.892 8.979 0 5.12 3.835 8.943 8.892 8.943 5.021 0 8.892-3.823 8.892-8.943 0-5.121-3.871-8.979-8.892-8.979Zm0 4.735c2.301 0 4.08 1.789 4.08 4.244 0 2.384-1.779 4.208-4.08 4.208-2.337 0-4.08-1.824-4.08-4.208 0-2.455 1.743-4.244 4.08-4.244Z"></path></svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

View File

@ -136,6 +136,7 @@
"react-hotkeys-hook": "^4.6.1",
"react-icons": "^5.4.0",
"react-markdown": "^9.0.1",
"react-qr-code": "^2.0.15",
"react-resizable-panels": "^2.1.7",
"react-toastify": "^10.0.6",
"react-window": "^1.8.11",

View File

@ -287,6 +287,9 @@ importers:
react-markdown:
specifier: ^9.0.1
version: 9.1.0(@types/react@18.3.20)(react@18.3.1)
react-qr-code:
specifier: ^2.0.15
version: 2.0.15(react@18.3.1)
react-resizable-panels:
specifier: ^2.1.7
version: 2.1.7(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
@ -6556,6 +6559,9 @@ packages:
resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==}
engines: {node: '>=6'}
qr.js@0.0.0:
resolution: {integrity: sha512-c4iYnWb+k2E+vYpRimHqSu575b1/wKl4XFeJGpFmrJQz5I88v9aY2czh7s0w36srfCM1sXgC/xpoJz5dJfq+OQ==}
qs@6.13.0:
resolution: {integrity: sha512-+38qI9SOr8tfZ4QmJNplMUxqjbe7LKvvZgWdExBOmd+egZTtjLB67Gu0HRX3u/XOq7UU2Nx6nsjvS16Z9uwfpg==}
engines: {node: '>=0.6'}
@ -6657,6 +6663,11 @@ packages:
'@types/react': '>=18'
react: '>=18'
react-qr-code@2.0.15:
resolution: {integrity: sha512-MkZcjEXqVKqXEIMVE0mbcGgDpkfSdd8zhuzXEl9QzYeNcw8Hq2oVIzDLWuZN2PQBwM5PWjc2S31K8Q1UbcFMfw==}
peerDependencies:
react: '*'
react-redux@7.2.9:
resolution: {integrity: sha512-Gx4L3uM182jEEayZfRbI/G11ZpYdNAnBs70lFVMNdHJI76XYtR+7m0MN+eAs7UHBPhWXcnFPaS+9owSCJQHNpQ==}
peerDependencies:
@ -15554,6 +15565,8 @@ snapshots:
punycode@2.3.1: {}
qr.js@0.0.0: {}
qs@6.13.0:
dependencies:
side-channel: 1.1.0
@ -15668,6 +15681,12 @@ snapshots:
transitivePeerDependencies:
- supports-color
react-qr-code@2.0.15(react@18.3.1):
dependencies:
prop-types: 15.8.1
qr.js: 0.0.0
react: 18.3.1
react-redux@7.2.9(react-dom@18.3.1(react@18.3.1))(react@18.3.1):
dependencies:
'@babel/runtime': 7.27.0