) {
this.#webcontainer = webcontainerPromise;
}
addAction(data: ActionCallbackData) {
const { actionId } = data;
const actions = this.actions.get();
const action = actions[actionId];
if (action) {
// action already added
return;
}
const abortController = new AbortController();
this.actions.setKey(actionId, {
...data.action,
status: 'pending',
executed: false,
abort: () => {
abortController.abort();
this.#updateAction(actionId, { status: 'aborted' });
},
abortSignal: abortController.signal,
});
this.#currentExecutionPromise.then(() => {
this.#updateAction(actionId, { status: 'running' });
});
}
async runAction(data: ActionCallbackData) {
const { actionId } = data;
const action = this.actions.get()[actionId];
if (!action) {
unreachable(`Action ${actionId} not found`);
}
if (action.executed) {
return;
}
this.#updateAction(actionId, { ...action, ...data.action, executed: true });
this.#currentExecutionPromise = this.#currentExecutionPromise
.then(() => {
return this.#executeAction(actionId);
})
.catch((error) => {
console.error('Action failed:', error);
});
}
async #executeAction(actionId: string) {
const action = this.actions.get()[actionId];
this.#updateAction(actionId, { status: 'running' });
try {
switch (action.type) {
case 'shell': {
await this.#runShellAction(action);
break;
}
case 'file': {
await this.#runFileAction(action);
break;
}
}
this.#updateAction(actionId, { status: action.abortSignal.aborted ? 'aborted' : 'complete' });
} catch (error) {
this.#updateAction(actionId, { status: 'failed', error: 'Action failed' });
// re-throw the error to be caught in the promise chain
throw error;
}
}
async #runShellAction(action: ActionState) {
if (action.type !== 'shell') {
unreachable('Expected shell action');
}
const webcontainer = await this.#webcontainer;
const process = await webcontainer.spawn('jsh', ['-c', action.content], {
env: { npm_config_yes: true },
});
action.abortSignal.addEventListener('abort', () => {
process.kill();
});
process.output.pipeTo(
new WritableStream({
write(data) {
console.log(data);
},
}),
);
const exitCode = await process.exit;
logger.debug(`Process terminated with code ${exitCode}`);
}
async #runFileAction(action: ActionState) {
if (action.type !== 'file') {
unreachable('Expected file action');
}
const webcontainer = await this.#webcontainer;
let folder = nodePath.dirname(action.filePath);
// remove trailing slashes
folder = folder.replace(/\/+$/g, '');
if (folder !== '.') {
try {
await webcontainer.fs.mkdir(folder, { recursive: true });
logger.debug('Created folder', folder);
} catch (error) {
logger.error('Failed to create folder\n\n', error);
}
}
try {
await webcontainer.fs.writeFile(action.filePath, action.content);
logger.debug(`File written ${action.filePath}`);
} catch (error) {
logger.error('Failed to write file\n\n', error);
}
}
#updateAction(id: string, newState: ActionStateUpdate) {
const actions = this.actions.get();
this.actions.setKey(id, { ...actions[id], ...newState });
}
}
================================================
FILE: app/lib/runtime/message-parser.spec.ts
================================================
import { describe, expect, it, vi } from 'vitest';
import { StreamingMessageParser, type ActionCallback, type ArtifactCallback } from './message-parser';
interface ExpectedResult {
output: string;
callbacks?: {
onArtifactOpen?: number;
onArtifactClose?: number;
onActionOpen?: number;
onActionClose?: number;
};
}
describe('StreamingMessageParser', () => {
it('should pass through normal text', () => {
const parser = new StreamingMessageParser();
expect(parser.parse('test_id', 'Hello, world!')).toBe('Hello, world!');
});
it('should allow normal HTML tags', () => {
const parser = new StreamingMessageParser();
expect(parser.parse('test_id', 'Hello world!')).toBe('Hello world!');
});
describe('no artifacts', () => {
it.each<[string | string[], ExpectedResult | string]>([
['Foo bar', 'Foo bar'],
['Foo bar <', 'Foo bar '],
['Foo bar some text'], 'Foo bar some text'],
])('should correctly parse chunks and strip out bolt artifacts (%#)', (input, expected) => {
runTest(input, expected);
});
});
describe('invalid or incomplete artifacts', () => {
it.each<[string | string[], ExpectedResult | string]>([
['Foo bar ', 'Foo bar '],
['Before foo After', 'Before foo After'],
['Before foo After', 'Before foo After'],
])('should correctly parse chunks and strip out bolt artifacts (%#)', (input, expected) => {
runTest(input, expected);
});
});
describe('valid artifacts without actions', () => {
it.each<[string | string[], ExpectedResult | string]>([
[
'Some text before foo bar Some more text',
{
output: 'Some text before Some more text',
callbacks: { onArtifactOpen: 1, onArtifactClose: 1, onActionOpen: 0, onActionClose: 0 },
},
],
[
['Some text before foo Some more text'],
{
output: 'Some text before Some more text',
callbacks: { onArtifactOpen: 1, onArtifactClose: 1, onActionOpen: 0, onActionClose: 0 },
},
],
[
[
'Some text before ',
'foo Some more text',
],
{
output: 'Some text before Some more text',
callbacks: { onArtifactOpen: 1, onArtifactClose: 1, onActionOpen: 0, onActionClose: 0 },
},
],
[
[
'Some text before fo',
'o Some more text',
],
{
output: 'Some text before Some more text',
callbacks: { onArtifactOpen: 1, onArtifactClose: 1, onActionOpen: 0, onActionClose: 0 },
},
],
[
[
'Some text before fo',
'o',
'<',
'/boltArtifact> Some more text',
],
{
output: 'Some text before Some more text',
callbacks: { onArtifactOpen: 1, onArtifactClose: 1, onActionOpen: 0, onActionClose: 0 },
},
],
[
[
'Some text before fo',
'o<',
'/boltArtifact> Some more text',
],
{
output: 'Some text before Some more text',
callbacks: { onArtifactOpen: 1, onArtifactClose: 1, onActionOpen: 0, onActionClose: 0 },
},
],
[
'Before foo After',
{
output: 'Before After',
callbacks: { onArtifactOpen: 1, onArtifactClose: 1, onActionOpen: 0, onActionClose: 0 },
},
],
])('should correctly parse chunks and strip out bolt artifacts (%#)', (input, expected) => {
runTest(input, expected);
});
});
describe('valid artifacts with actions', () => {
it.each<[string | string[], ExpectedResult | string]>([
[
'Before npm install After',
{
output: 'Before After',
callbacks: { onArtifactOpen: 1, onArtifactClose: 1, onActionOpen: 1, onActionClose: 1 },
},
],
[
'Before npm installsome content After',
{
output: 'Before After',
callbacks: { onArtifactOpen: 1, onArtifactClose: 1, onActionOpen: 2, onActionClose: 2 },
},
],
])('should correctly parse chunks and strip out bolt artifacts (%#)', (input, expected) => {
runTest(input, expected);
});
});
});
function runTest(input: string | string[], outputOrExpectedResult: string | ExpectedResult) {
let expected: ExpectedResult;
if (typeof outputOrExpectedResult === 'string') {
expected = { output: outputOrExpectedResult };
} else {
expected = outputOrExpectedResult;
}
const callbacks = {
onArtifactOpen: vi.fn((data) => {
expect(data).toMatchSnapshot('onArtifactOpen');
}),
onArtifactClose: vi.fn((data) => {
expect(data).toMatchSnapshot('onArtifactClose');
}),
onActionOpen: vi.fn((data) => {
expect(data).toMatchSnapshot('onActionOpen');
}),
onActionClose: vi.fn((data) => {
expect(data).toMatchSnapshot('onActionClose');
}),
};
const parser = new StreamingMessageParser({
artifactElement: () => '',
callbacks,
});
let message = '';
let result = '';
const chunks = Array.isArray(input) ? input : input.split('');
for (const chunk of chunks) {
message += chunk;
result += parser.parse('message_1', message);
}
for (const name in expected.callbacks) {
const callbackName = name;
expect(callbacks[callbackName as keyof typeof callbacks]).toHaveBeenCalledTimes(
expected.callbacks[callbackName as keyof typeof expected.callbacks] ?? 0,
);
}
expect(result).toEqual(expected.output);
}
================================================
FILE: app/lib/runtime/message-parser.ts
================================================
import type { ActionType, BoltAction, BoltActionData, FileAction, ShellAction } from '~/types/actions';
import type { BoltArtifactData } from '~/types/artifact';
import { createScopedLogger } from '~/utils/logger';
import { unreachable } from '~/utils/unreachable';
const ARTIFACT_TAG_OPEN = ' void;
export type ActionCallback = (data: ActionCallbackData) => void;
export interface ParserCallbacks {
onArtifactOpen?: ArtifactCallback;
onArtifactClose?: ArtifactCallback;
onActionOpen?: ActionCallback;
onActionClose?: ActionCallback;
}
interface ElementFactoryProps {
messageId: string;
}
type ElementFactory = (props: ElementFactoryProps) => string;
export interface StreamingMessageParserOptions {
callbacks?: ParserCallbacks;
artifactElement?: ElementFactory;
}
interface MessageState {
position: number;
insideArtifact: boolean;
insideAction: boolean;
currentArtifact?: BoltArtifactData;
currentAction: BoltActionData;
actionId: number;
}
export class StreamingMessageParser {
#messages = new Map();
constructor(private _options: StreamingMessageParserOptions = {}) {}
parse(messageId: string, input: string) {
let state = this.#messages.get(messageId);
if (!state) {
state = {
position: 0,
insideAction: false,
insideArtifact: false,
currentAction: { content: '' },
actionId: 0,
};
this.#messages.set(messageId, state);
}
let output = '';
let i = state.position;
let earlyBreak = false;
while (i < input.length) {
if (state.insideArtifact) {
const currentArtifact = state.currentArtifact;
if (currentArtifact === undefined) {
unreachable('Artifact not initialized');
}
if (state.insideAction) {
const closeIndex = input.indexOf(ARTIFACT_ACTION_TAG_CLOSE, i);
const currentAction = state.currentAction;
if (closeIndex !== -1) {
currentAction.content += input.slice(i, closeIndex);
let content = currentAction.content.trim();
if ('type' in currentAction && currentAction.type === 'file') {
content += '\n';
}
currentAction.content = content;
this._options.callbacks?.onActionClose?.({
artifactId: currentArtifact.id,
messageId,
/**
* We decrement the id because it's been incremented already
* when `onActionOpen` was emitted to make sure the ids are
* the same.
*/
actionId: String(state.actionId - 1),
action: currentAction as BoltAction,
});
state.insideAction = false;
state.currentAction = { content: '' };
i = closeIndex + ARTIFACT_ACTION_TAG_CLOSE.length;
} else {
break;
}
} else {
const actionOpenIndex = input.indexOf(ARTIFACT_ACTION_TAG_OPEN, i);
const artifactCloseIndex = input.indexOf(ARTIFACT_TAG_CLOSE, i);
if (actionOpenIndex !== -1 && (artifactCloseIndex === -1 || actionOpenIndex < artifactCloseIndex)) {
const actionEndIndex = input.indexOf('>', actionOpenIndex);
if (actionEndIndex !== -1) {
state.insideAction = true;
state.currentAction = this.#parseActionTag(input, actionOpenIndex, actionEndIndex);
this._options.callbacks?.onActionOpen?.({
artifactId: currentArtifact.id,
messageId,
actionId: String(state.actionId++),
action: state.currentAction as BoltAction,
});
i = actionEndIndex + 1;
} else {
break;
}
} else if (artifactCloseIndex !== -1) {
this._options.callbacks?.onArtifactClose?.({ messageId, ...currentArtifact });
state.insideArtifact = false;
state.currentArtifact = undefined;
i = artifactCloseIndex + ARTIFACT_TAG_CLOSE.length;
} else {
break;
}
}
} else if (input[i] === '<' && input[i + 1] !== '/') {
let j = i;
let potentialTag = '';
while (j < input.length && potentialTag.length < ARTIFACT_TAG_OPEN.length) {
potentialTag += input[j];
if (potentialTag === ARTIFACT_TAG_OPEN) {
const nextChar = input[j + 1];
if (nextChar && nextChar !== '>' && nextChar !== ' ') {
output += input.slice(i, j + 1);
i = j + 1;
break;
}
const openTagEnd = input.indexOf('>', j);
if (openTagEnd !== -1) {
const artifactTag = input.slice(i, openTagEnd + 1);
const artifactTitle = this.#extractAttribute(artifactTag, 'title') as string;
const artifactId = this.#extractAttribute(artifactTag, 'id') as string;
if (!artifactTitle) {
logger.warn('Artifact title missing');
}
if (!artifactId) {
logger.warn('Artifact id missing');
}
state.insideArtifact = true;
const currentArtifact = {
id: artifactId,
title: artifactTitle,
} satisfies BoltArtifactData;
state.currentArtifact = currentArtifact;
this._options.callbacks?.onArtifactOpen?.({ messageId, ...currentArtifact });
const artifactFactory = this._options.artifactElement ?? createArtifactElement;
output += artifactFactory({ messageId });
i = openTagEnd + 1;
} else {
earlyBreak = true;
}
break;
} else if (!ARTIFACT_TAG_OPEN.startsWith(potentialTag)) {
output += input.slice(i, j + 1);
i = j + 1;
break;
}
j++;
}
if (j === input.length && ARTIFACT_TAG_OPEN.startsWith(potentialTag)) {
break;
}
} else {
output += input[i];
i++;
}
if (earlyBreak) {
break;
}
}
state.position = i;
return output;
}
reset() {
this.#messages.clear();
}
#parseActionTag(input: string, actionOpenIndex: number, actionEndIndex: number) {
const actionTag = input.slice(actionOpenIndex, actionEndIndex + 1);
const actionType = this.#extractAttribute(actionTag, 'type') as ActionType;
const actionAttributes = {
type: actionType,
content: '',
};
if (actionType === 'file') {
const filePath = this.#extractAttribute(actionTag, 'filePath') as string;
if (!filePath) {
logger.debug('File path not specified');
}
(actionAttributes as FileAction).filePath = filePath;
} else if (actionType !== 'shell') {
logger.warn(`Unknown action type '${actionType}'`);
}
return actionAttributes as FileAction | ShellAction;
}
#extractAttribute(tag: string, attributeName: string): string | undefined {
const match = tag.match(new RegExp(`${attributeName}="([^"]*)"`, 'i'));
return match ? match[1] : undefined;
}
}
const createArtifactElement: ElementFactory = (props) => {
const elementProps = [
'class="__boltArtifact__"',
...Object.entries(props).map(([key, value]) => {
return `data-${camelToDashCase(key)}=${JSON.stringify(value)}`;
}),
];
return ``;
};
function camelToDashCase(input: string) {
return input.replace(/([a-z])([A-Z])/g, '$1-$2').toLowerCase();
}
================================================
FILE: app/lib/stores/chat.ts
================================================
import { map } from 'nanostores';
export const chatStore = map({
started: false,
aborted: false,
showChat: true,
});
================================================
FILE: app/lib/stores/editor.ts
================================================
import { atom, computed, map, type MapStore, type WritableAtom } from 'nanostores';
import type { EditorDocument, ScrollPosition } from '~/components/editor/codemirror/CodeMirrorEditor';
import type { FileMap, FilesStore } from './files';
export type EditorDocuments = Record;
type SelectedFile = WritableAtom;
export class EditorStore {
#filesStore: FilesStore;
selectedFile: SelectedFile = import.meta.hot?.data.selectedFile ?? atom();
documents: MapStore = import.meta.hot?.data.documents ?? map({});
currentDocument = computed([this.documents, this.selectedFile], (documents, selectedFile) => {
if (!selectedFile) {
return undefined;
}
return documents[selectedFile];
});
constructor(filesStore: FilesStore) {
this.#filesStore = filesStore;
if (import.meta.hot) {
import.meta.hot.data.documents = this.documents;
import.meta.hot.data.selectedFile = this.selectedFile;
}
}
setDocuments(files: FileMap) {
const previousDocuments = this.documents.value;
this.documents.set(
Object.fromEntries(
Object.entries(files)
.map(([filePath, dirent]) => {
if (dirent === undefined || dirent.type === 'folder') {
return undefined;
}
const previousDocument = previousDocuments?.[filePath];
return [
filePath,
{
value: dirent.content,
filePath,
scroll: previousDocument?.scroll,
},
] as [string, EditorDocument];
})
.filter(Boolean) as Array<[string, EditorDocument]>,
),
);
}
setSelectedFile(filePath: string | undefined) {
this.selectedFile.set(filePath);
}
updateScrollPosition(filePath: string, position: ScrollPosition) {
const documents = this.documents.get();
const documentState = documents[filePath];
if (!documentState) {
return;
}
this.documents.setKey(filePath, {
...documentState,
scroll: position,
});
}
updateFile(filePath: string, newContent: string) {
const documents = this.documents.get();
const documentState = documents[filePath];
if (!documentState) {
return;
}
const currentContent = documentState.value;
const contentChanged = currentContent !== newContent;
if (contentChanged) {
this.documents.setKey(filePath, {
...documentState,
value: newContent,
});
}
}
}
================================================
FILE: app/lib/stores/files.ts
================================================
import type { PathWatcherEvent, WebContainer } from '@webcontainer/api';
import { getEncoding } from 'istextorbinary';
import { map, type MapStore } from 'nanostores';
import { Buffer } from 'node:buffer';
import * as nodePath from 'node:path';
import { bufferWatchEvents } from '~/utils/buffer';
import { WORK_DIR } from '~/utils/constants';
import { computeFileModifications } from '~/utils/diff';
import { createScopedLogger } from '~/utils/logger';
import { unreachable } from '~/utils/unreachable';
const logger = createScopedLogger('FilesStore');
const utf8TextDecoder = new TextDecoder('utf8', { fatal: true });
export interface File {
type: 'file';
content: string;
isBinary: boolean;
}
export interface Folder {
type: 'folder';
}
type Dirent = File | Folder;
export type FileMap = Record;
export class FilesStore {
#webcontainer: Promise;
/**
* Tracks the number of files without folders.
*/
#size = 0;
/**
* @note Keeps track all modified files with their original content since the last user message.
* Needs to be reset when the user sends another message and all changes have to be submitted
* for the model to be aware of the changes.
*/
#modifiedFiles: Map = import.meta.hot?.data.modifiedFiles ?? new Map();
/**
* Map of files that matches the state of WebContainer.
*/
files: MapStore = import.meta.hot?.data.files ?? map({});
get filesCount() {
return this.#size;
}
constructor(webcontainerPromise: Promise) {
this.#webcontainer = webcontainerPromise;
if (import.meta.hot) {
import.meta.hot.data.files = this.files;
import.meta.hot.data.modifiedFiles = this.#modifiedFiles;
}
this.#init();
}
getFile(filePath: string) {
const dirent = this.files.get()[filePath];
if (dirent?.type !== 'file') {
return undefined;
}
return dirent;
}
getFileModifications() {
return computeFileModifications(this.files.get(), this.#modifiedFiles);
}
resetFileModifications() {
this.#modifiedFiles.clear();
}
async saveFile(filePath: string, content: string) {
const webcontainer = await this.#webcontainer;
try {
const relativePath = nodePath.relative(webcontainer.workdir, filePath);
if (!relativePath) {
throw new Error(`EINVAL: invalid file path, write '${relativePath}'`);
}
const oldContent = this.getFile(filePath)?.content;
if (!oldContent) {
unreachable('Expected content to be defined');
}
await webcontainer.fs.writeFile(relativePath, content);
if (!this.#modifiedFiles.has(filePath)) {
this.#modifiedFiles.set(filePath, oldContent);
}
// we immediately update the file and don't rely on the `change` event coming from the watcher
this.files.setKey(filePath, { type: 'file', content, isBinary: false });
logger.info('File updated');
} catch (error) {
logger.error('Failed to update file content\n\n', error);
throw error;
}
}
async #init() {
const webcontainer = await this.#webcontainer;
webcontainer.internal.watchPaths(
{ include: [`${WORK_DIR}/**`], exclude: ['**/node_modules', '.git'], includeContent: true },
bufferWatchEvents(100, this.#processEventBuffer.bind(this)),
);
}
#processEventBuffer(events: Array<[events: PathWatcherEvent[]]>) {
const watchEvents = events.flat(2);
for (const { type, path, buffer } of watchEvents) {
// remove any trailing slashes
const sanitizedPath = path.replace(/\/+$/g, '');
switch (type) {
case 'add_dir': {
// we intentionally add a trailing slash so we can distinguish files from folders in the file tree
this.files.setKey(sanitizedPath, { type: 'folder' });
break;
}
case 'remove_dir': {
this.files.setKey(sanitizedPath, undefined);
for (const [direntPath] of Object.entries(this.files)) {
if (direntPath.startsWith(sanitizedPath)) {
this.files.setKey(direntPath, undefined);
}
}
break;
}
case 'add_file':
case 'change': {
if (type === 'add_file') {
this.#size++;
}
let content = '';
/**
* @note This check is purely for the editor. The way we detect this is not
* bullet-proof and it's a best guess so there might be false-positives.
* The reason we do this is because we don't want to display binary files
* in the editor nor allow to edit them.
*/
const isBinary = isBinaryFile(buffer);
if (!isBinary) {
content = this.#decodeFileContent(buffer);
}
this.files.setKey(sanitizedPath, { type: 'file', content, isBinary });
break;
}
case 'remove_file': {
this.#size--;
this.files.setKey(sanitizedPath, undefined);
break;
}
case 'update_directory': {
// we don't care about these events
break;
}
}
}
}
#decodeFileContent(buffer?: Uint8Array) {
if (!buffer || buffer.byteLength === 0) {
return '';
}
try {
return utf8TextDecoder.decode(buffer);
} catch (error) {
console.log(error);
return '';
}
}
}
function isBinaryFile(buffer: Uint8Array | undefined) {
if (buffer === undefined) {
return false;
}
return getEncoding(convertToBuffer(buffer), { chunkLength: 100 }) === 'binary';
}
/**
* Converts a `Uint8Array` into a Node.js `Buffer` by copying the prototype.
* The goal is to avoid expensive copies. It does create a new typed array
* but that's generally cheap as long as it uses the same underlying
* array buffer.
*/
function convertToBuffer(view: Uint8Array): Buffer {
const buffer = new Uint8Array(view.buffer, view.byteOffset, view.byteLength);
Object.setPrototypeOf(buffer, Buffer.prototype);
return buffer as Buffer;
}
================================================
FILE: app/lib/stores/previews.ts
================================================
import type { WebContainer } from '@webcontainer/api';
import { atom } from 'nanostores';
export interface PreviewInfo {
port: number;
ready: boolean;
baseUrl: string;
}
export class PreviewsStore {
#availablePreviews = new Map();
#webcontainer: Promise;
previews = atom([]);
constructor(webcontainerPromise: Promise) {
this.#webcontainer = webcontainerPromise;
this.#init();
}
async #init() {
const webcontainer = await this.#webcontainer;
webcontainer.on('port', (port, type, url) => {
let previewInfo = this.#availablePreviews.get(port);
if (type === 'close' && previewInfo) {
this.#availablePreviews.delete(port);
this.previews.set(this.previews.get().filter((preview) => preview.port !== port));
return;
}
const previews = this.previews.get();
if (!previewInfo) {
previewInfo = { port, ready: type === 'open', baseUrl: url };
this.#availablePreviews.set(port, previewInfo);
previews.push(previewInfo);
}
previewInfo.ready = type === 'open';
previewInfo.baseUrl = url;
this.previews.set([...previews]);
});
}
}
================================================
FILE: app/lib/stores/settings.ts
================================================
import { map } from 'nanostores';
import { workbenchStore } from './workbench';
export interface Shortcut {
key: string;
ctrlKey?: boolean;
shiftKey?: boolean;
altKey?: boolean;
metaKey?: boolean;
ctrlOrMetaKey?: boolean;
action: () => void;
}
export interface Shortcuts {
toggleTerminal: Shortcut;
}
export interface Settings {
shortcuts: Shortcuts;
}
export const shortcutsStore = map({
toggleTerminal: {
key: 'j',
ctrlOrMetaKey: true,
action: () => workbenchStore.toggleTerminal(),
},
});
export const settingsStore = map({
shortcuts: shortcutsStore.get(),
});
shortcutsStore.subscribe((shortcuts) => {
settingsStore.set({
...settingsStore.get(),
shortcuts,
});
});
================================================
FILE: app/lib/stores/terminal.ts
================================================
import type { WebContainer, WebContainerProcess } from '@webcontainer/api';
import { atom, type WritableAtom } from 'nanostores';
import type { ITerminal } from '~/types/terminal';
import { newShellProcess } from '~/utils/shell';
import { coloredText } from '~/utils/terminal';
export class TerminalStore {
#webcontainer: Promise;
#terminals: Array<{ terminal: ITerminal; process: WebContainerProcess }> = [];
showTerminal: WritableAtom = import.meta.hot?.data.showTerminal ?? atom(false);
constructor(webcontainerPromise: Promise) {
this.#webcontainer = webcontainerPromise;
if (import.meta.hot) {
import.meta.hot.data.showTerminal = this.showTerminal;
}
}
toggleTerminal(value?: boolean) {
this.showTerminal.set(value !== undefined ? value : !this.showTerminal.get());
}
async attachTerminal(terminal: ITerminal) {
try {
const shellProcess = await newShellProcess(await this.#webcontainer, terminal);
this.#terminals.push({ terminal, process: shellProcess });
} catch (error: any) {
terminal.write(coloredText.red('Failed to spawn shell\n\n') + error.message);
return;
}
}
onTerminalResize(cols: number, rows: number) {
for (const { process } of this.#terminals) {
process.resize({ cols, rows });
}
}
}
================================================
FILE: app/lib/stores/theme.ts
================================================
import { atom } from 'nanostores';
export type Theme = 'dark' | 'light';
export const kTheme = 'bolt_theme';
export function themeIsDark() {
return themeStore.get() === 'dark';
}
export const DEFAULT_THEME = 'light';
export const themeStore = atom(initStore());
function initStore() {
if (!import.meta.env.SSR) {
const persistedTheme = localStorage.getItem(kTheme) as Theme | undefined;
const themeAttribute = document.querySelector('html')?.getAttribute('data-theme');
return persistedTheme ?? (themeAttribute as Theme) ?? DEFAULT_THEME;
}
return DEFAULT_THEME;
}
export function toggleTheme() {
const currentTheme = themeStore.get();
const newTheme = currentTheme === 'dark' ? 'light' : 'dark';
themeStore.set(newTheme);
localStorage.setItem(kTheme, newTheme);
document.querySelector('html')?.setAttribute('data-theme', newTheme);
}
================================================
FILE: app/lib/stores/workbench.ts
================================================
import { atom, map, type MapStore, type ReadableAtom, type WritableAtom } from 'nanostores';
import type { EditorDocument, ScrollPosition } from '~/components/editor/codemirror/CodeMirrorEditor';
import { ActionRunner } from '~/lib/runtime/action-runner';
import type { ActionCallbackData, ArtifactCallbackData } from '~/lib/runtime/message-parser';
import { webcontainer } from '~/lib/webcontainer';
import type { ITerminal } from '~/types/terminal';
import { unreachable } from '~/utils/unreachable';
import { EditorStore } from './editor';
import { FilesStore, type FileMap } from './files';
import { PreviewsStore } from './previews';
import { TerminalStore } from './terminal';
export interface ArtifactState {
id: string;
title: string;
closed: boolean;
runner: ActionRunner;
}
export type ArtifactUpdateState = Pick;
type Artifacts = MapStore>;
export type WorkbenchViewType = 'code' | 'preview';
export class WorkbenchStore {
#previewsStore = new PreviewsStore(webcontainer);
#filesStore = new FilesStore(webcontainer);
#editorStore = new EditorStore(this.#filesStore);
#terminalStore = new TerminalStore(webcontainer);
artifacts: Artifacts = import.meta.hot?.data.artifacts ?? map({});
showWorkbench: WritableAtom = import.meta.hot?.data.showWorkbench ?? atom(false);
currentView: WritableAtom = import.meta.hot?.data.currentView ?? atom('code');
unsavedFiles: WritableAtom> = import.meta.hot?.data.unsavedFiles ?? atom(new Set());
modifiedFiles = new Set();
artifactIdList: string[] = [];
constructor() {
if (import.meta.hot) {
import.meta.hot.data.artifacts = this.artifacts;
import.meta.hot.data.unsavedFiles = this.unsavedFiles;
import.meta.hot.data.showWorkbench = this.showWorkbench;
import.meta.hot.data.currentView = this.currentView;
}
}
get previews() {
return this.#previewsStore.previews;
}
get files() {
return this.#filesStore.files;
}
get currentDocument(): ReadableAtom {
return this.#editorStore.currentDocument;
}
get selectedFile(): ReadableAtom {
return this.#editorStore.selectedFile;
}
get firstArtifact(): ArtifactState | undefined {
return this.#getArtifact(this.artifactIdList[0]);
}
get filesCount(): number {
return this.#filesStore.filesCount;
}
get showTerminal() {
return this.#terminalStore.showTerminal;
}
toggleTerminal(value?: boolean) {
this.#terminalStore.toggleTerminal(value);
}
attachTerminal(terminal: ITerminal) {
this.#terminalStore.attachTerminal(terminal);
}
onTerminalResize(cols: number, rows: number) {
this.#terminalStore.onTerminalResize(cols, rows);
}
setDocuments(files: FileMap) {
this.#editorStore.setDocuments(files);
if (this.#filesStore.filesCount > 0 && this.currentDocument.get() === undefined) {
// we find the first file and select it
for (const [filePath, dirent] of Object.entries(files)) {
if (dirent?.type === 'file') {
this.setSelectedFile(filePath);
break;
}
}
}
}
setShowWorkbench(show: boolean) {
this.showWorkbench.set(show);
}
setCurrentDocumentContent(newContent: string) {
const filePath = this.currentDocument.get()?.filePath;
if (!filePath) {
return;
}
const originalContent = this.#filesStore.getFile(filePath)?.content;
const unsavedChanges = originalContent !== undefined && originalContent !== newContent;
this.#editorStore.updateFile(filePath, newContent);
const currentDocument = this.currentDocument.get();
if (currentDocument) {
const previousUnsavedFiles = this.unsavedFiles.get();
if (unsavedChanges && previousUnsavedFiles.has(currentDocument.filePath)) {
return;
}
const newUnsavedFiles = new Set(previousUnsavedFiles);
if (unsavedChanges) {
newUnsavedFiles.add(currentDocument.filePath);
} else {
newUnsavedFiles.delete(currentDocument.filePath);
}
this.unsavedFiles.set(newUnsavedFiles);
}
}
setCurrentDocumentScrollPosition(position: ScrollPosition) {
const editorDocument = this.currentDocument.get();
if (!editorDocument) {
return;
}
const { filePath } = editorDocument;
this.#editorStore.updateScrollPosition(filePath, position);
}
setSelectedFile(filePath: string | undefined) {
this.#editorStore.setSelectedFile(filePath);
}
async saveFile(filePath: string) {
const documents = this.#editorStore.documents.get();
const document = documents[filePath];
if (document === undefined) {
return;
}
await this.#filesStore.saveFile(filePath, document.value);
const newUnsavedFiles = new Set(this.unsavedFiles.get());
newUnsavedFiles.delete(filePath);
this.unsavedFiles.set(newUnsavedFiles);
}
async saveCurrentDocument() {
const currentDocument = this.currentDocument.get();
if (currentDocument === undefined) {
return;
}
await this.saveFile(currentDocument.filePath);
}
resetCurrentDocument() {
const currentDocument = this.currentDocument.get();
if (currentDocument === undefined) {
return;
}
const { filePath } = currentDocument;
const file = this.#filesStore.getFile(filePath);
if (!file) {
return;
}
this.setCurrentDocumentContent(file.content);
}
async saveAllFiles() {
for (const filePath of this.unsavedFiles.get()) {
await this.saveFile(filePath);
}
}
getFileModifcations() {
return this.#filesStore.getFileModifications();
}
resetAllFileModifications() {
this.#filesStore.resetFileModifications();
}
abortAllActions() {
// TODO: what do we wanna do and how do we wanna recover from this?
}
addArtifact({ messageId, title, id }: ArtifactCallbackData) {
const artifact = this.#getArtifact(messageId);
if (artifact) {
return;
}
if (!this.artifactIdList.includes(messageId)) {
this.artifactIdList.push(messageId);
}
this.artifacts.setKey(messageId, {
id,
title,
closed: false,
runner: new ActionRunner(webcontainer),
});
}
updateArtifact({ messageId }: ArtifactCallbackData, state: Partial) {
const artifact = this.#getArtifact(messageId);
if (!artifact) {
return;
}
this.artifacts.setKey(messageId, { ...artifact, ...state });
}
async addAction(data: ActionCallbackData) {
const { messageId } = data;
const artifact = this.#getArtifact(messageId);
if (!artifact) {
unreachable('Artifact not found');
}
artifact.runner.addAction(data);
}
async runAction(data: ActionCallbackData) {
const { messageId } = data;
const artifact = this.#getArtifact(messageId);
if (!artifact) {
unreachable('Artifact not found');
}
artifact.runner.runAction(data);
}
#getArtifact(id: string) {
const artifacts = this.artifacts.get();
return artifacts[id];
}
}
export const workbenchStore = new WorkbenchStore();
================================================
FILE: app/lib/webcontainer/auth.client.ts
================================================
/**
* This client-only module that contains everything related to auth and is used
* to avoid importing `@webcontainer/api` in the server bundle.
*/
export { auth, type AuthAPI } from '@webcontainer/api';
================================================
FILE: app/lib/webcontainer/index.ts
================================================
import { WebContainer } from '@webcontainer/api';
import { WORK_DIR_NAME } from '~/utils/constants';
interface WebContainerContext {
loaded: boolean;
}
export const webcontainerContext: WebContainerContext = import.meta.hot?.data.webcontainerContext ?? {
loaded: false,
};
if (import.meta.hot) {
import.meta.hot.data.webcontainerContext = webcontainerContext;
}
export let webcontainer: Promise = new Promise(() => {
// noop for ssr
});
if (!import.meta.env.SSR) {
webcontainer =
import.meta.hot?.data.webcontainer ??
Promise.resolve()
.then(() => {
return WebContainer.boot({ workdirName: WORK_DIR_NAME });
})
.then((webcontainer) => {
webcontainerContext.loaded = true;
return webcontainer;
});
if (import.meta.hot) {
import.meta.hot.data.webcontainer = webcontainer;
}
}
================================================
FILE: app/root.tsx
================================================
import { useStore } from '@nanostores/react';
import type { LinksFunction } from '@remix-run/cloudflare';
import { Links, Meta, Outlet, Scripts, ScrollRestoration } from '@remix-run/react';
import tailwindReset from '@unocss/reset/tailwind-compat.css?url';
import { themeStore } from './lib/stores/theme';
import { stripIndents } from './utils/stripIndent';
import { createHead } from 'remix-island';
import { useEffect } from 'react';
import reactToastifyStyles from 'react-toastify/dist/ReactToastify.css?url';
import globalStyles from './styles/index.scss?url';
import xtermStyles from '@xterm/xterm/css/xterm.css?url';
import 'virtual:uno.css';
export const links: LinksFunction = () => [
{
rel: 'icon',
href: '/favicon.svg',
type: 'image/svg+xml',
},
{ rel: 'stylesheet', href: reactToastifyStyles },
{ rel: 'stylesheet', href: tailwindReset },
{ rel: 'stylesheet', href: globalStyles },
{ rel: 'stylesheet', href: xtermStyles },
{
rel: 'preconnect',
href: 'https://fonts.googleapis.com',
},
{
rel: 'preconnect',
href: 'https://fonts.gstatic.com',
crossOrigin: 'anonymous',
},
{
rel: 'stylesheet',
href: 'https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap',
},
];
const inlineThemeCode = stripIndents`
setTutorialKitTheme();
function setTutorialKitTheme() {
let theme = localStorage.getItem('bolt_theme');
if (!theme) {
theme = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
}
document.querySelector('html')?.setAttribute('data-theme', theme);
}
`;
export const Head = createHead(() => (
<>
>
));
export function Layout({ children }: { children: React.ReactNode }) {
const theme = useStore(themeStore);
useEffect(() => {
document.querySelector('html')?.setAttribute('data-theme', theme);
}, [theme]);
return (
<>
{children}
>
);
}
export default function App() {
return ;
}
================================================
FILE: app/routes/_index.tsx
================================================
import { json, type MetaFunction } from '@remix-run/cloudflare';
import { ClientOnly } from 'remix-utils/client-only';
import { BaseChat } from '~/components/chat/BaseChat';
import { Chat } from '~/components/chat/Chat.client';
import { Header } from '~/components/header/Header';
export const meta: MetaFunction = () => {
return [{ title: 'Bolt' }, { name: 'description', content: 'Talk with Bolt, an AI assistant from StackBlitz' }];
};
export const loader = () => json({});
export default function Index() {
return (
}>{() => }
);
}
================================================
FILE: app/routes/api.chat.ts
================================================
import { type ActionFunctionArgs } from '@remix-run/cloudflare';
import { MAX_RESPONSE_SEGMENTS, MAX_TOKENS } from '~/lib/.server/llm/constants';
import { CONTINUE_PROMPT } from '~/lib/.server/llm/prompts';
import { streamText, type Messages, type StreamingOptions } from '~/lib/.server/llm/stream-text';
import SwitchableStream from '~/lib/.server/llm/switchable-stream';
export async function action(args: ActionFunctionArgs) {
return chatAction(args);
}
async function chatAction({ context, request }: ActionFunctionArgs) {
const { messages } = await request.json<{ messages: Messages }>();
const stream = new SwitchableStream();
try {
const options: StreamingOptions = {
toolChoice: 'none',
onFinish: async ({ text: content, finishReason }) => {
if (finishReason !== 'length') {
return stream.close();
}
if (stream.switches >= MAX_RESPONSE_SEGMENTS) {
throw Error('Cannot continue message: Maximum segments reached');
}
const switchesLeft = MAX_RESPONSE_SEGMENTS - stream.switches;
console.log(`Reached max token limit (${MAX_TOKENS}): Continuing message (${switchesLeft} switches left)`);
messages.push({ role: 'assistant', content });
messages.push({ role: 'user', content: CONTINUE_PROMPT });
const result = await streamText(messages, context.cloudflare.env, options);
return stream.switchSource(result.toAIStream());
},
};
const result = await streamText(messages, context.cloudflare.env, options);
stream.switchSource(result.toAIStream());
return new Response(stream.readable, {
status: 200,
headers: {
contentType: 'text/plain; charset=utf-8',
},
});
} catch (error) {
console.log(error);
throw new Response(null, {
status: 500,
statusText: 'Internal Server Error',
});
}
}
================================================
FILE: app/routes/api.enhancer.ts
================================================
import { type ActionFunctionArgs } from '@remix-run/cloudflare';
import { StreamingTextResponse, parseStreamPart } from 'ai';
import { streamText } from '~/lib/.server/llm/stream-text';
import { stripIndents } from '~/utils/stripIndent';
const encoder = new TextEncoder();
const decoder = new TextDecoder();
export async function action(args: ActionFunctionArgs) {
return enhancerAction(args);
}
async function enhancerAction({ context, request }: ActionFunctionArgs) {
const { message } = await request.json<{ message: string }>();
try {
const result = await streamText(
[
{
role: 'user',
content: stripIndents`
I want you to improve the user prompt that is wrapped in \`\` tags.
IMPORTANT: Only respond with the improved prompt and nothing else!
${message}
`,
},
],
context.cloudflare.env,
);
const transformStream = new TransformStream({
transform(chunk, controller) {
const processedChunk = decoder
.decode(chunk)
.split('\n')
.filter((line) => line !== '')
.map(parseStreamPart)
.map((part) => part.value)
.join('');
controller.enqueue(encoder.encode(processedChunk));
},
});
const transformedStream = result.toAIStream().pipeThrough(transformStream);
return new StreamingTextResponse(transformedStream);
} catch (error) {
console.log(error);
throw new Response(null, {
status: 500,
statusText: 'Internal Server Error',
});
}
}
================================================
FILE: app/routes/chat.$id.tsx
================================================
import { json, type LoaderFunctionArgs } from '@remix-run/cloudflare';
import { default as IndexRoute } from './_index';
export async function loader(args: LoaderFunctionArgs) {
return json({ id: args.params.id });
}
export default IndexRoute;
================================================
FILE: app/styles/animations.scss
================================================
.animated {
animation-fill-mode: both;
animation-duration: var(--animate-duration, 0.2s);
animation-timing-function: cubic-bezier(0, 0, 0.2, 1);
&.fadeInRight {
animation-name: fadeInRight;
}
&.fadeOutRight {
animation-name: fadeOutRight;
}
}
@keyframes fadeInRight {
from {
opacity: 0;
transform: translate3d(100%, 0, 0);
}
to {
opacity: 1;
transform: translate3d(0, 0, 0);
}
}
@keyframes fadeOutRight {
from {
opacity: 1;
}
to {
opacity: 0;
transform: translate3d(100%, 0, 0);
}
}
.dropdown-animation {
opacity: 0;
animation: fadeMoveDown 0.15s forwards;
animation-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
}
@keyframes fadeMoveDown {
to {
opacity: 1;
transform: translateY(6px);
}
}
================================================
FILE: app/styles/components/code.scss
================================================
.actions .shiki {
background-color: var(--bolt-elements-actions-code-background) !important;
}
.shiki {
&:not(:has(.actions), .actions *) {
background-color: var(--bolt-elements-messages-code-background) !important;
}
}
================================================
FILE: app/styles/components/editor.scss
================================================
:root {
--cm-backgroundColor: var(--bolt-elements-editor-backgroundColor, var(--bolt-elements-bg-depth-1));
--cm-textColor: var(--bolt-elements-editor-textColor, var(--bolt-elements-textPrimary));
/* Gutter */
--cm-gutter-backgroundColor: var(--bolt-elements-editor-gutter-backgroundColor, var(--cm-backgroundColor));
--cm-gutter-textColor: var(--bolt-elements-editor-gutter-textColor, var(--bolt-elements-textSecondary));
--cm-gutter-activeLineTextColor: var(--bolt-elements-editor-gutter-activeLineTextColor, var(--cm-gutter-textColor));
/* Fold Gutter */
--cm-foldGutter-textColor: var(--bolt-elements-editor-foldGutter-textColor, var(--cm-gutter-textColor));
--cm-foldGutter-textColorHover: var(--bolt-elements-editor-foldGutter-textColorHover, var(--cm-gutter-textColor));
/* Active Line */
--cm-activeLineBackgroundColor: var(--bolt-elements-editor-activeLineBackgroundColor, rgb(224 231 235 / 30%));
/* Cursor */
--cm-cursor-width: 2px;
--cm-cursor-backgroundColor: var(--bolt-elements-editor-cursorColor, var(--bolt-elements-textSecondary));
/* Matching Brackets */
--cm-matching-bracket: var(--bolt-elements-editor-matchingBracketBackgroundColor, rgb(50 140 130 / 0.3));
/* Selection */
--cm-selection-backgroundColorFocused: var(--bolt-elements-editor-selection-backgroundColor, #42b4ff);
--cm-selection-backgroundOpacityFocused: var(--bolt-elements-editor-selection-backgroundOpacity, 0.3);
--cm-selection-backgroundColorBlured: var(--bolt-elements-editor-selection-inactiveBackgroundColor, #c9e9ff);
--cm-selection-backgroundOpacityBlured: var(--bolt-elements-editor-selection-inactiveBackgroundOpacity, 0.3);
/* Panels */
--cm-panels-borderColor: var(--bolt-elements-editor-panels-borderColor, var(--bolt-elements-borderColor));
/* Search */
--cm-search-backgroundColor: var(--bolt-elements-editor-search-backgroundColor, var(--cm-backgroundColor));
--cm-search-textColor: var(--bolt-elements-editor-search-textColor, var(--bolt-elements-textSecondary));
--cm-search-closeButton-backgroundColor: var(--bolt-elements-editor-search-closeButton-backgroundColor, transparent);
--cm-search-closeButton-backgroundColorHover: var(
--bolt-elements-editor-search-closeButton-backgroundColorHover,
var(--bolt-elements-item-backgroundActive)
);
--cm-search-closeButton-textColor: var(
--bolt-elements-editor-search-closeButton-textColor,
var(--bolt-elements-item-contentDefault)
);
--cm-search-closeButton-textColorHover: var(
--bolt-elements-editor-search-closeButton-textColorHover,
var(--bolt-elements-item-contentActive)
);
--cm-search-button-backgroundColor: var(
--bolt-elements-editor-search-button-backgroundColor,
var(--bolt-elements-item-backgroundDefault)
);
--cm-search-button-backgroundColorHover: var(
--bolt-elements-editor-search-button-backgroundColorHover,
var(--bolt-elements-item-backgroundActive)
);
--cm-search-button-textColor: var(--bolt-elements-editor-search-button-textColor, var(--bolt-elements-textSecondary));
--cm-search-button-textColorHover: var(
--bolt-elements-editor-search-button-textColorHover,
var(--bolt-elements-textPrimary)
);
--cm-search-button-borderColor: var(--bolt-elements-editor-search-button-borderColor, transparent);
--cm-search-button-borderColorHover: var(--bolt-elements-editor-search-button-borderColorHover, transparent);
--cm-search-button-borderColorFocused: var(
--bolt-elements-editor-search-button-borderColorFocused,
var(--bolt-elements-borderColorActive)
);
--cm-search-input-backgroundColor: var(--bolt-elements-editor-search-input-backgroundColor, transparent);
--cm-search-input-textColor: var(--bolt-elements-editor-search-input-textColor, var(--bolt-elements-textPrimary));
--cm-search-input-borderColor: var(--bolt-elements-editor-search-input-borderColor, var(--bolt-elements-borderColor));
--cm-search-input-borderColorFocused: var(
--bolt-elements-editor-search-input-borderColorFocused,
var(--bolt-elements-borderColorActive)
);
/* Tooltip */
--cm-tooltip-backgroundColor: var(--bolt-elements-editor-tooltip-backgroundColor, var(--cm-backgroundColor));
--cm-tooltip-textColor: var(--bolt-elements-editor-tooltip-textColor, var(--bolt-elements-textPrimary));
--cm-tooltip-backgroundColorSelected: var(
--bolt-elements-editor-tooltip-backgroundColorSelected,
theme('colors.alpha.accent.30')
);
--cm-tooltip-textColorSelected: var(
--bolt-elements-editor-tooltip-textColorSelected,
var(--bolt-elements-textPrimary)
);
--cm-tooltip-borderColor: var(--bolt-elements-editor-tooltip-borderColor, var(--bolt-elements-borderColor));
--cm-searchMatch-backgroundColor: var(--bolt-elements-editor-searchMatch-backgroundColor, rgba(234, 92, 0, 0.33));
}
html[data-theme='light'] {
--bolt-elements-editor-gutter-textColor: #237893;
--bolt-elements-editor-gutter-activeLineTextColor: var(--bolt-elements-textPrimary);
--bolt-elements-editor-foldGutter-textColorHover: var(--bolt-elements-textPrimary);
--bolt-elements-editor-activeLineBackgroundColor: rgb(50 53 63 / 5%);
--bolt-elements-editor-tooltip-backgroundColorSelected: theme('colors.alpha.accent.20');
--bolt-elements-editor-search-button-backgroundColor: theme('colors.gray.100');
--bolt-elements-editor-search-button-backgroundColorHover: theme('colors.alpha.gray.10');
}
html[data-theme='dark'] {
--cm-backgroundColor: var(--bolt-elements-bg-depth-2);
--bolt-elements-editor-gutter-textColor: var(--bolt-elements-textTertiary);
--bolt-elements-editor-gutter-activeLineTextColor: var(--bolt-elements-textSecondary);
--bolt-elements-editor-selection-inactiveBackgroundOpacity: 0.3;
--bolt-elements-editor-activeLineBackgroundColor: rgb(50 53 63 / 50%);
--bolt-elements-editor-foldGutter-textColorHover: var(--bolt-elements-textPrimary);
--bolt-elements-editor-matchingBracketBackgroundColor: rgba(66, 180, 255, 0.3);
--bolt-elements-editor-search-button-backgroundColor: theme('colors.gray.800');
--bolt-elements-editor-search-button-backgroundColorHover: theme('colors.alpha.white.10');
}
================================================
FILE: app/styles/components/resize-handle.scss
================================================
[data-resize-handle] {
position: relative;
&[data-panel-group-direction='horizontal']:after {
content: '';
position: absolute;
top: 0;
bottom: 0;
left: -6px;
right: -5px;
z-index: $zIndexMax;
}
&[data-panel-group-direction='vertical']:after {
content: '';
position: absolute;
left: 0;
right: 0;
top: -5px;
bottom: -6px;
z-index: $zIndexMax;
}
&[data-resize-handle-state='hover']:after,
&[data-resize-handle-state='drag']:after {
background-color: #8882;
}
}
================================================
FILE: app/styles/components/terminal.scss
================================================
.xterm {
padding: 1rem;
}
================================================
FILE: app/styles/components/toast.scss
================================================
.Toastify__toast {
--at-apply: shadow-md;
background-color: var(--bolt-elements-bg-depth-2);
color: var(--bolt-elements-textPrimary);
border: 1px solid var(--bolt-elements-borderColor);
}
.Toastify__close-button {
color: var(--bolt-elements-item-contentDefault);
opacity: 1;
transition: none;
&:hover {
color: var(--bolt-elements-item-contentActive);
}
}
================================================
FILE: app/styles/index.scss
================================================
@import './variables.scss';
@import './z-index.scss';
@import './animations.scss';
@import './components/terminal.scss';
@import './components/resize-handle.scss';
@import './components/code.scss';
@import './components/editor.scss';
@import './components/toast.scss';
html,
body {
height: 100%;
width: 100%;
}
================================================
FILE: app/styles/variables.scss
================================================
/* Color Tokens Light Theme */
:root,
:root[data-theme='light'] {
--bolt-elements-borderColor: theme('colors.alpha.gray.10');
--bolt-elements-borderColorActive: theme('colors.accent.600');
--bolt-elements-bg-depth-1: theme('colors.white');
--bolt-elements-bg-depth-2: theme('colors.gray.50');
--bolt-elements-bg-depth-3: theme('colors.gray.200');
--bolt-elements-bg-depth-4: theme('colors.alpha.gray.5');
--bolt-elements-textPrimary: theme('colors.gray.950');
--bolt-elements-textSecondary: theme('colors.gray.600');
--bolt-elements-textTertiary: theme('colors.gray.500');
--bolt-elements-code-background: theme('colors.gray.100');
--bolt-elements-code-text: theme('colors.gray.950');
--bolt-elements-button-primary-background: theme('colors.alpha.accent.10');
--bolt-elements-button-primary-backgroundHover: theme('colors.alpha.accent.20');
--bolt-elements-button-primary-text: theme('colors.accent.500');
--bolt-elements-button-secondary-background: theme('colors.alpha.gray.5');
--bolt-elements-button-secondary-backgroundHover: theme('colors.alpha.gray.10');
--bolt-elements-button-secondary-text: theme('colors.gray.950');
--bolt-elements-button-danger-background: theme('colors.alpha.red.10');
--bolt-elements-button-danger-backgroundHover: theme('colors.alpha.red.20');
--bolt-elements-button-danger-text: theme('colors.red.500');
--bolt-elements-item-contentDefault: theme('colors.alpha.gray.50');
--bolt-elements-item-contentActive: theme('colors.gray.950');
--bolt-elements-item-contentAccent: theme('colors.accent.700');
--bolt-elements-item-contentDanger: theme('colors.red.500');
--bolt-elements-item-backgroundDefault: rgba(0, 0, 0, 0);
--bolt-elements-item-backgroundActive: theme('colors.alpha.gray.5');
--bolt-elements-item-backgroundAccent: theme('colors.alpha.accent.10');
--bolt-elements-item-backgroundDanger: theme('colors.alpha.red.10');
--bolt-elements-loader-background: theme('colors.alpha.gray.10');
--bolt-elements-loader-progress: theme('colors.accent.500');
--bolt-elements-artifacts-background: theme('colors.white');
--bolt-elements-artifacts-backgroundHover: theme('colors.alpha.gray.2');
--bolt-elements-artifacts-borderColor: var(--bolt-elements-borderColor);
--bolt-elements-artifacts-inlineCode-background: theme('colors.gray.100');
--bolt-elements-artifacts-inlineCode-text: var(--bolt-elements-textPrimary);
--bolt-elements-actions-background: theme('colors.white');
--bolt-elements-actions-code-background: theme('colors.gray.800');
--bolt-elements-messages-background: theme('colors.gray.100');
--bolt-elements-messages-linkColor: theme('colors.accent.500');
--bolt-elements-messages-code-background: theme('colors.gray.800');
--bolt-elements-messages-inlineCode-background: theme('colors.gray.200');
--bolt-elements-messages-inlineCode-text: theme('colors.gray.800');
--bolt-elements-icon-success: theme('colors.green.500');
--bolt-elements-icon-error: theme('colors.red.500');
--bolt-elements-icon-primary: theme('colors.gray.950');
--bolt-elements-icon-secondary: theme('colors.gray.600');
--bolt-elements-icon-tertiary: theme('colors.gray.500');
--bolt-elements-dividerColor: theme('colors.gray.100');
--bolt-elements-prompt-background: theme('colors.alpha.white.80');
--bolt-elements-sidebar-dropdownShadow: theme('colors.alpha.gray.10');
--bolt-elements-sidebar-buttonBackgroundDefault: theme('colors.alpha.accent.10');
--bolt-elements-sidebar-buttonBackgroundHover: theme('colors.alpha.accent.20');
--bolt-elements-sidebar-buttonText: theme('colors.accent.700');
--bolt-elements-preview-addressBar-background: theme('colors.gray.100');
--bolt-elements-preview-addressBar-backgroundHover: theme('colors.alpha.gray.5');
--bolt-elements-preview-addressBar-backgroundActive: theme('colors.white');
--bolt-elements-preview-addressBar-text: var(--bolt-elements-textSecondary);
--bolt-elements-preview-addressBar-textActive: var(--bolt-elements-textPrimary);
--bolt-elements-terminals-background: theme('colors.white');
--bolt-elements-terminals-buttonBackground: var(--bolt-elements-bg-depth-4);
--bolt-elements-cta-background: theme('colors.gray.100');
--bolt-elements-cta-text: theme('colors.gray.950');
/* Terminal Colors */
--bolt-terminal-background: var(--bolt-elements-terminals-background);
--bolt-terminal-foreground: #333333;
--bolt-terminal-selection-background: #00000040;
--bolt-terminal-black: #000000;
--bolt-terminal-red: #cd3131;
--bolt-terminal-green: #00bc00;
--bolt-terminal-yellow: #949800;
--bolt-terminal-blue: #0451a5;
--bolt-terminal-magenta: #bc05bc;
--bolt-terminal-cyan: #0598bc;
--bolt-terminal-white: #555555;
--bolt-terminal-brightBlack: #686868;
--bolt-terminal-brightRed: #cd3131;
--bolt-terminal-brightGreen: #00bc00;
--bolt-terminal-brightYellow: #949800;
--bolt-terminal-brightBlue: #0451a5;
--bolt-terminal-brightMagenta: #bc05bc;
--bolt-terminal-brightCyan: #0598bc;
--bolt-terminal-brightWhite: #a5a5a5;
}
/* Color Tokens Dark Theme */
:root,
:root[data-theme='dark'] {
--bolt-elements-borderColor: theme('colors.alpha.white.10');
--bolt-elements-borderColorActive: theme('colors.accent.500');
--bolt-elements-bg-depth-1: theme('colors.gray.950');
--bolt-elements-bg-depth-2: theme('colors.gray.900');
--bolt-elements-bg-depth-3: theme('colors.gray.800');
--bolt-elements-bg-depth-4: theme('colors.alpha.white.5');
--bolt-elements-textPrimary: theme('colors.white');
--bolt-elements-textSecondary: theme('colors.gray.400');
--bolt-elements-textTertiary: theme('colors.gray.500');
--bolt-elements-code-background: theme('colors.gray.800');
--bolt-elements-code-text: theme('colors.white');
--bolt-elements-button-primary-background: theme('colors.alpha.accent.10');
--bolt-elements-button-primary-backgroundHover: theme('colors.alpha.accent.20');
--bolt-elements-button-primary-text: theme('colors.accent.500');
--bolt-elements-button-secondary-background: theme('colors.alpha.white.5');
--bolt-elements-button-secondary-backgroundHover: theme('colors.alpha.white.10');
--bolt-elements-button-secondary-text: theme('colors.white');
--bolt-elements-button-danger-background: theme('colors.alpha.red.10');
--bolt-elements-button-danger-backgroundHover: theme('colors.alpha.red.20');
--bolt-elements-button-danger-text: theme('colors.red.500');
--bolt-elements-item-contentDefault: theme('colors.alpha.white.50');
--bolt-elements-item-contentActive: theme('colors.white');
--bolt-elements-item-contentAccent: theme('colors.accent.500');
--bolt-elements-item-contentDanger: theme('colors.red.500');
--bolt-elements-item-backgroundDefault: rgba(255, 255, 255, 0);
--bolt-elements-item-backgroundActive: theme('colors.alpha.white.10');
--bolt-elements-item-backgroundAccent: theme('colors.alpha.accent.10');
--bolt-elements-item-backgroundDanger: theme('colors.alpha.red.10');
--bolt-elements-loader-background: theme('colors.alpha.gray.10');
--bolt-elements-loader-progress: theme('colors.accent.500');
--bolt-elements-artifacts-background: theme('colors.gray.900');
--bolt-elements-artifacts-backgroundHover: theme('colors.alpha.white.5');
--bolt-elements-artifacts-borderColor: var(--bolt-elements-borderColor);
--bolt-elements-artifacts-inlineCode-background: theme('colors.gray.800');
--bolt-elements-artifacts-inlineCode-text: theme('colors.white');
--bolt-elements-actions-background: theme('colors.gray.900');
--bolt-elements-actions-code-background: theme('colors.gray.800');
--bolt-elements-messages-background: theme('colors.gray.800');
--bolt-elements-messages-linkColor: theme('colors.accent.500');
--bolt-elements-messages-code-background: theme('colors.gray.900');
--bolt-elements-messages-inlineCode-background: theme('colors.gray.700');
--bolt-elements-messages-inlineCode-text: var(--bolt-elements-textPrimary);
--bolt-elements-icon-success: theme('colors.green.400');
--bolt-elements-icon-error: theme('colors.red.400');
--bolt-elements-icon-primary: theme('colors.gray.950');
--bolt-elements-icon-secondary: theme('colors.gray.600');
--bolt-elements-icon-tertiary: theme('colors.gray.500');
--bolt-elements-dividerColor: theme('colors.gray.100');
--bolt-elements-prompt-background: theme('colors.alpha.gray.80');
--bolt-elements-sidebar-dropdownShadow: theme('colors.alpha.gray.30');
--bolt-elements-sidebar-buttonBackgroundDefault: theme('colors.alpha.accent.10');
--bolt-elements-sidebar-buttonBackgroundHover: theme('colors.alpha.accent.20');
--bolt-elements-sidebar-buttonText: theme('colors.accent.500');
--bolt-elements-preview-addressBar-background: var(--bolt-elements-bg-depth-1);
--bolt-elements-preview-addressBar-backgroundHover: theme('colors.alpha.white.5');
--bolt-elements-preview-addressBar-backgroundActive: var(--bolt-elements-bg-depth-1);
--bolt-elements-preview-addressBar-text: var(--bolt-elements-textSecondary);
--bolt-elements-preview-addressBar-textActive: var(--bolt-elements-textPrimary);
--bolt-elements-terminals-background: var(--bolt-elements-bg-depth-1);
--bolt-elements-terminals-buttonBackground: var(--bolt-elements-bg-depth-3);
--bolt-elements-cta-background: theme('colors.alpha.white.10');
--bolt-elements-cta-text: theme('colors.white');
/* Terminal Colors */
--bolt-terminal-background: var(--bolt-elements-terminals-background);
--bolt-terminal-foreground: #eff0eb;
--bolt-terminal-selection-background: #97979b33;
--bolt-terminal-black: #000000;
--bolt-terminal-red: #ff5c57;
--bolt-terminal-green: #5af78e;
--bolt-terminal-yellow: #f3f99d;
--bolt-terminal-blue: #57c7ff;
--bolt-terminal-magenta: #ff6ac1;
--bolt-terminal-cyan: #9aedfe;
--bolt-terminal-white: #f1f1f0;
--bolt-terminal-brightBlack: #686868;
--bolt-terminal-brightRed: #ff5c57;
--bolt-terminal-brightGreen: #5af78e;
--bolt-terminal-brightYellow: #f3f99d;
--bolt-terminal-brightBlue: #57c7ff;
--bolt-terminal-brightMagenta: #ff6ac1;
--bolt-terminal-brightCyan: #9aedfe;
--bolt-terminal-brightWhite: #f1f1f0;
}
/*
* Element Tokens
*
* Hierarchy: Element Token -> (Element Token | Color Tokens) -> Primitives
*/
:root {
--header-height: 54px;
--chat-max-width: 37rem;
--chat-min-width: 640px;
--workbench-width: min(calc(100% - var(--chat-min-width)), 1536px);
--workbench-inner-width: var(--workbench-width);
--workbench-left: calc(100% - var(--workbench-width));
/* Toasts */
--toastify-color-progress-success: var(--bolt-elements-icon-success);
--toastify-color-progress-error: var(--bolt-elements-icon-error);
/* Terminal */
--bolt-elements-terminal-backgroundColor: var(--bolt-terminal-background);
--bolt-elements-terminal-textColor: var(--bolt-terminal-foreground);
--bolt-elements-terminal-cursorColor: var(--bolt-terminal-foreground);
--bolt-elements-terminal-selection-backgroundColor: var(--bolt-terminal-selection-background);
--bolt-elements-terminal-color-black: var(--bolt-terminal-black);
--bolt-elements-terminal-color-red: var(--bolt-terminal-red);
--bolt-elements-terminal-color-green: var(--bolt-terminal-green);
--bolt-elements-terminal-color-yellow: var(--bolt-terminal-yellow);
--bolt-elements-terminal-color-blue: var(--bolt-terminal-blue);
--bolt-elements-terminal-color-magenta: var(--bolt-terminal-magenta);
--bolt-elements-terminal-color-cyan: var(--bolt-terminal-cyan);
--bolt-elements-terminal-color-white: var(--bolt-terminal-white);
--bolt-elements-terminal-color-brightBlack: var(--bolt-terminal-brightBlack);
--bolt-elements-terminal-color-brightRed: var(--bolt-terminal-brightRed);
--bolt-elements-terminal-color-brightGreen: var(--bolt-terminal-brightGreen);
--bolt-elements-terminal-color-brightYellow: var(--bolt-terminal-brightYellow);
--bolt-elements-terminal-color-brightBlue: var(--bolt-terminal-brightBlue);
--bolt-elements-terminal-color-brightMagenta: var(--bolt-terminal-brightMagenta);
--bolt-elements-terminal-color-brightCyan: var(--bolt-terminal-brightCyan);
--bolt-elements-terminal-color-brightWhite: var(--bolt-terminal-brightWhite);
}
================================================
FILE: app/styles/z-index.scss
================================================
$zIndexMax: 999;
.z-logo {
z-index: $zIndexMax - 1;
}
.z-sidebar {
z-index: $zIndexMax - 2;
}
.z-port-dropdown {
z-index: $zIndexMax - 3;
}
.z-iframe-overlay {
z-index: $zIndexMax - 4;
}
.z-prompt {
z-index: 2;
}
.z-workbench {
z-index: 3;
}
.z-file-tree-breadcrumb {
z-index: $zIndexMax - 1;
}
.z-max {
z-index: $zIndexMax;
}
================================================
FILE: app/types/actions.ts
================================================
export type ActionType = 'file' | 'shell';
export interface BaseAction {
content: string;
}
export interface FileAction extends BaseAction {
type: 'file';
filePath: string;
}
export interface ShellAction extends BaseAction {
type: 'shell';
}
export type BoltAction = FileAction | ShellAction;
export type BoltActionData = BoltAction | BaseAction;
================================================
FILE: app/types/artifact.ts
================================================
export interface BoltArtifactData {
id: string;
title: string;
}
================================================
FILE: app/types/terminal.ts
================================================
export interface ITerminal {
readonly cols?: number;
readonly rows?: number;
reset: () => void;
write: (data: string) => void;
onData: (cb: (data: string) => void) => void;
}
================================================
FILE: app/types/theme.ts
================================================
export type Theme = 'dark' | 'light';
================================================
FILE: app/utils/buffer.ts
================================================
export function bufferWatchEvents(timeInMs: number, cb: (events: T[]) => unknown) {
let timeoutId: number | undefined;
let events: T[] = [];
// keep track of the processing of the previous batch so we can wait for it
let processing: Promise = Promise.resolve();
const scheduleBufferTick = () => {
timeoutId = self.setTimeout(async () => {
// we wait until the previous batch is entirely processed so events are processed in order
await processing;
if (events.length > 0) {
processing = Promise.resolve(cb(events));
}
timeoutId = undefined;
events = [];
}, timeInMs);
};
return (...args: T) => {
events.push(args);
if (!timeoutId) {
scheduleBufferTick();
}
};
}
================================================
FILE: app/utils/classNames.ts
================================================
/**
* Copyright (c) 2018 Jed Watson.
* Licensed under the MIT License (MIT), see:
*
* @link http://jedwatson.github.io/classnames
*/
type ClassNamesArg = undefined | string | Record | ClassNamesArg[];
/**
* A simple JavaScript utility for conditionally joining classNames together.
*
* @param args A series of classes or object with key that are class and values
* that are interpreted as boolean to decide whether or not the class
* should be included in the final class.
*/
export function classNames(...args: ClassNamesArg[]): string {
let classes = '';
for (const arg of args) {
classes = appendClass(classes, parseValue(arg));
}
return classes;
}
function parseValue(arg: ClassNamesArg) {
if (typeof arg === 'string' || typeof arg === 'number') {
return arg;
}
if (typeof arg !== 'object') {
return '';
}
if (Array.isArray(arg)) {
return classNames(...arg);
}
let classes = '';
for (const key in arg) {
if (arg[key]) {
classes = appendClass(classes, key);
}
}
return classes;
}
function appendClass(value: string, newClass: string | undefined) {
if (!newClass) {
return value;
}
if (value) {
return value + ' ' + newClass;
}
return value + newClass;
}
================================================
FILE: app/utils/constants.ts
================================================
export const WORK_DIR_NAME = 'project';
export const WORK_DIR = `/home/${WORK_DIR_NAME}`;
export const MODIFICATIONS_TAG_NAME = 'bolt_file_modifications';
================================================
FILE: app/utils/debounce.ts
================================================
export function debounce(fn: (...args: Args) => void, delay = 100) {
if (delay === 0) {
return fn;
}
let timer: number | undefined;
return function (this: U, ...args: Args) {
const context = this;
clearTimeout(timer);
timer = window.setTimeout(() => {
fn.apply(context, args);
}, delay);
};
}
================================================
FILE: app/utils/diff.ts
================================================
import { createTwoFilesPatch } from 'diff';
import type { FileMap } from '~/lib/stores/files';
import { MODIFICATIONS_TAG_NAME } from './constants';
export const modificationsRegex = new RegExp(
`^<${MODIFICATIONS_TAG_NAME}>[\\s\\S]*?<\\/${MODIFICATIONS_TAG_NAME}>\\s+`,
'g',
);
interface ModifiedFile {
type: 'diff' | 'file';
content: string;
}
type FileModifications = Record;
export function computeFileModifications(files: FileMap, modifiedFiles: Map) {
const modifications: FileModifications = {};
let hasModifiedFiles = false;
for (const [filePath, originalContent] of modifiedFiles) {
const file = files[filePath];
if (file?.type !== 'file') {
continue;
}
const unifiedDiff = diffFiles(filePath, originalContent, file.content);
if (!unifiedDiff) {
// files are identical
continue;
}
hasModifiedFiles = true;
if (unifiedDiff.length > file.content.length) {
// if there are lots of changes we simply grab the current file content since it's smaller than the diff
modifications[filePath] = { type: 'file', content: file.content };
} else {
// otherwise we use the diff since it's smaller
modifications[filePath] = { type: 'diff', content: unifiedDiff };
}
}
if (!hasModifiedFiles) {
return undefined;
}
return modifications;
}
/**
* Computes a diff in the unified format. The only difference is that the header is omitted
* because it will always assume that you're comparing two versions of the same file and
* it allows us to avoid the extra characters we send back to the llm.
*
* @see https://www.gnu.org/software/diffutils/manual/html_node/Unified-Format.html
*/
export function diffFiles(fileName: string, oldFileContent: string, newFileContent: string) {
let unifiedDiff = createTwoFilesPatch(fileName, fileName, oldFileContent, newFileContent);
const patchHeaderEnd = `--- ${fileName}\n+++ ${fileName}\n`;
const headerEndIndex = unifiedDiff.indexOf(patchHeaderEnd);
if (headerEndIndex >= 0) {
unifiedDiff = unifiedDiff.slice(headerEndIndex + patchHeaderEnd.length);
}
if (unifiedDiff === '') {
return undefined;
}
return unifiedDiff;
}
/**
* Converts the unified diff to HTML.
*
* Example:
*
* ```html
*
*
* - console.log('Hello, World!');
* + console.log('Hello, Bolt!');
*
*
* ```
*/
export function fileModificationsToHTML(modifications: FileModifications) {
const entries = Object.entries(modifications);
if (entries.length === 0) {
return undefined;
}
const result: string[] = [`<${MODIFICATIONS_TAG_NAME}>`];
for (const [filePath, { type, content }] of entries) {
result.push(`<${type} path=${JSON.stringify(filePath)}>`, content, `${type}>`);
}
result.push(`${MODIFICATIONS_TAG_NAME}>`);
return result.join('\n');
}
================================================
FILE: app/utils/easings.ts
================================================
import { cubicBezier } from 'framer-motion';
export const cubicEasingFn = cubicBezier(0.4, 0, 0.2, 1);
================================================
FILE: app/utils/logger.ts
================================================
export type DebugLevel = 'trace' | 'debug' | 'info' | 'warn' | 'error';
type LoggerFunction = (...messages: any[]) => void;
interface Logger {
trace: LoggerFunction;
debug: LoggerFunction;
info: LoggerFunction;
warn: LoggerFunction;
error: LoggerFunction;
setLevel: (level: DebugLevel) => void;
}
let currentLevel: DebugLevel = import.meta.env.VITE_LOG_LEVEL ?? import.meta.env.DEV ? 'debug' : 'info';
const isWorker = 'HTMLRewriter' in globalThis;
const supportsColor = !isWorker;
export const logger: Logger = {
trace: (...messages: any[]) => log('trace', undefined, messages),
debug: (...messages: any[]) => log('debug', undefined, messages),
info: (...messages: any[]) => log('info', undefined, messages),
warn: (...messages: any[]) => log('warn', undefined, messages),
error: (...messages: any[]) => log('error', undefined, messages),
setLevel,
};
export function createScopedLogger(scope: string): Logger {
return {
trace: (...messages: any[]) => log('trace', scope, messages),
debug: (...messages: any[]) => log('debug', scope, messages),
info: (...messages: any[]) => log('info', scope, messages),
warn: (...messages: any[]) => log('warn', scope, messages),
error: (...messages: any[]) => log('error', scope, messages),
setLevel,
};
}
function setLevel(level: DebugLevel) {
if ((level === 'trace' || level === 'debug') && import.meta.env.PROD) {
return;
}
currentLevel = level;
}
function log(level: DebugLevel, scope: string | undefined, messages: any[]) {
const levelOrder: DebugLevel[] = ['trace', 'debug', 'info', 'warn', 'error'];
if (levelOrder.indexOf(level) < levelOrder.indexOf(currentLevel)) {
return;
}
const allMessages = messages.reduce((acc, current) => {
if (acc.endsWith('\n')) {
return acc + current;
}
if (!acc) {
return current;
}
return `${acc} ${current}`;
}, '');
if (!supportsColor) {
console.log(`[${level.toUpperCase()}]`, allMessages);
return;
}
const labelBackgroundColor = getColorForLevel(level);
const labelTextColor = level === 'warn' ? 'black' : 'white';
const labelStyles = getLabelStyles(labelBackgroundColor, labelTextColor);
const scopeStyles = getLabelStyles('#77828D', 'white');
const styles = [labelStyles];
if (typeof scope === 'string') {
styles.push('', scopeStyles);
}
console.log(`%c${level.toUpperCase()}${scope ? `%c %c${scope}` : ''}`, ...styles, allMessages);
}
function getLabelStyles(color: string, textColor: string) {
return `background-color: ${color}; color: white; border: 4px solid ${color}; color: ${textColor};`;
}
function getColorForLevel(level: DebugLevel): string {
switch (level) {
case 'trace':
case 'debug': {
return '#77828D';
}
case 'info': {
return '#1389FD';
}
case 'warn': {
return '#FFDB6C';
}
case 'error': {
return '#EE4744';
}
default: {
return 'black';
}
}
}
export const renderLogger = createScopedLogger('Render');
================================================
FILE: app/utils/markdown.ts
================================================
import rehypeRaw from 'rehype-raw';
import remarkGfm from 'remark-gfm';
import type { PluggableList, Plugin } from 'unified';
import rehypeSanitize, { defaultSchema, type Options as RehypeSanitizeOptions } from 'rehype-sanitize';
import { SKIP, visit } from 'unist-util-visit';
import type { UnistNode, UnistParent } from 'node_modules/unist-util-visit/lib';
export const allowedHTMLElements = [
'a',
'b',
'blockquote',
'br',
'code',
'dd',
'del',
'details',
'div',
'dl',
'dt',
'em',
'h1',
'h2',
'h3',
'h4',
'h5',
'h6',
'hr',
'i',
'ins',
'kbd',
'li',
'ol',
'p',
'pre',
'q',
'rp',
'rt',
'ruby',
's',
'samp',
'source',
'span',
'strike',
'strong',
'sub',
'summary',
'sup',
'table',
'tbody',
'td',
'tfoot',
'th',
'thead',
'tr',
'ul',
'var',
];
const rehypeSanitizeOptions: RehypeSanitizeOptions = {
...defaultSchema,
tagNames: allowedHTMLElements,
attributes: {
...defaultSchema.attributes,
div: [...(defaultSchema.attributes?.div ?? []), 'data*', ['className', '__boltArtifact__']],
},
strip: [],
};
export function remarkPlugins(limitedMarkdown: boolean) {
const plugins: PluggableList = [remarkGfm];
if (limitedMarkdown) {
plugins.unshift(limitedMarkdownPlugin);
}
return plugins;
}
export function rehypePlugins(html: boolean) {
const plugins: PluggableList = [];
if (html) {
plugins.push(rehypeRaw, [rehypeSanitize, rehypeSanitizeOptions]);
}
return plugins;
}
const limitedMarkdownPlugin: Plugin = () => {
return (tree, file) => {
const contents = file.toString();
visit(tree, (node: UnistNode, index, parent: UnistParent) => {
if (
index == null ||
['paragraph', 'text', 'inlineCode', 'code', 'strong', 'emphasis'].includes(node.type) ||
!node.position
) {
return true;
}
let value = contents.slice(node.position.start.offset, node.position.end.offset);
if (node.type === 'heading') {
value = `\n${value}`;
}
parent.children[index] = {
type: 'text',
value,
} as any;
return [SKIP, index] as const;
});
};
};
================================================
FILE: app/utils/mobile.ts
================================================
export function isMobile() {
// we use sm: as the breakpoint for mobile. It's currently set to 640px
return globalThis.innerWidth < 640;
}
================================================
FILE: app/utils/promises.ts
================================================
export function withResolvers(): PromiseWithResolvers {
if (typeof Promise.withResolvers === 'function') {
return Promise.withResolvers();
}
let resolve!: (value: T | PromiseLike) => void;
let reject!: (reason?: any) => void;
const promise = new Promise((_resolve, _reject) => {
resolve = _resolve;
reject = _reject;
});
return {
resolve,
reject,
promise,
};
}
================================================
FILE: app/utils/react.ts
================================================
import { memo } from 'react';
export const genericMemo: >(
component: T,
propsAreEqual?: (prevProps: React.ComponentProps, nextProps: React.ComponentProps) => boolean,
) => T & { displayName?: string } = memo;
================================================
FILE: app/utils/shell.ts
================================================
import type { WebContainer } from '@webcontainer/api';
import type { ITerminal } from '~/types/terminal';
import { withResolvers } from './promises';
export async function newShellProcess(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();
const output = process.output;
const jshReady = withResolvers();
let isInteractive = false;
output.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) => {
if (isInteractive) {
input.write(data);
}
});
await jshReady.promise;
return process;
}
================================================
FILE: app/utils/stripIndent.ts
================================================
export function stripIndents(value: string): string;
export function stripIndents(strings: TemplateStringsArray, ...values: any[]): string;
export function stripIndents(arg0: string | TemplateStringsArray, ...values: any[]) {
if (typeof arg0 !== 'string') {
const processedString = arg0.reduce((acc, curr, i) => {
acc += curr + (values[i] ?? '');
return acc;
}, '');
return _stripIndents(processedString);
}
return _stripIndents(arg0);
}
function _stripIndents(value: string) {
return value
.split('\n')
.map((line) => line.trim())
.join('\n')
.trimStart()
.replace(/[\r\n]$/, '');
}
================================================
FILE: app/utils/terminal.ts
================================================
const reset = '\x1b[0m';
export const escapeCodes = {
reset,
clear: '\x1b[g',
red: '\x1b[1;31m',
};
export const coloredText = {
red: (text: string) => `${escapeCodes.red}${text}${reset}`,
};
================================================
FILE: app/utils/unreachable.ts
================================================
export function unreachable(message: string): never {
throw new Error(`Unreachable: ${message}`);
}
================================================
FILE: bindings.sh
================================================
#!/bin/bash
bindings=""
while IFS= read -r line || [ -n "$line" ]; do
if [[ ! "$line" =~ ^# ]] && [[ -n "$line" ]]; then
name=$(echo "$line" | cut -d '=' -f 1)
value=$(echo "$line" | cut -d '=' -f 2-)
value=$(echo $value | sed 's/^"\(.*\)"$/\1/')
bindings+="--binding ${name}=${value} "
fi
done < .env.local
bindings=$(echo $bindings | sed 's/[[:space:]]*$//')
echo $bindings
================================================
FILE: eslint.config.mjs
================================================
import blitzPlugin from '@blitz/eslint-plugin';
import { jsFileExtensions } from '@blitz/eslint-plugin/dist/configs/javascript.js';
import { getNamingConventionRule, tsFileExtensions } from '@blitz/eslint-plugin/dist/configs/typescript.js';
export default [
{
ignores: ['**/dist', '**/node_modules', '**/.wrangler', '**/bolt/build'],
},
...blitzPlugin.configs.recommended(),
{
rules: {
'@blitz/catch-error-name': 'off',
'@typescript-eslint/no-this-alias': 'off',
'@typescript-eslint/no-empty-object-type': 'off',
},
},
{
files: ['**/*.tsx'],
rules: {
...getNamingConventionRule({}, true),
},
},
{
files: ['**/*.d.ts'],
rules: {
'@typescript-eslint/no-empty-object-type': 'off',
},
},
{
files: [...tsFileExtensions, ...jsFileExtensions, '**/*.tsx'],
ignores: ['functions/*'],
rules: {
'no-restricted-imports': [
'error',
{
patterns: [
{
group: ['../'],
message: `Relative imports are not allowed. Please use '~/' instead.`,
},
],
},
],
},
},
];
================================================
FILE: functions/[[path]].ts
================================================
import type { ServerBuild } from '@remix-run/cloudflare';
import { createPagesFunctionHandler } from '@remix-run/cloudflare-pages';
// @ts-ignore because the server build file is generated by `remix vite:build`
import * as serverBuild from '../build/server';
export const onRequest = createPagesFunctionHandler({
build: serverBuild as unknown as ServerBuild,
});
================================================
FILE: load-context.ts
================================================
import { type PlatformProxy } from 'wrangler';
type Cloudflare = Omit, 'dispose'>;
declare module '@remix-run/cloudflare' {
interface AppLoadContext {
cloudflare: Cloudflare;
}
}
================================================
FILE: package.json
================================================
{
"name": "bolt",
"description": "StackBlitz AI Agent",
"private": true,
"license": "MIT",
"packageManager": "pnpm@9.4.0",
"sideEffects": false,
"type": "module",
"scripts": {
"deploy": "npm run build && wrangler pages deploy",
"build": "remix vite:build",
"dev": "remix vite:dev",
"test": "vitest --run",
"test:watch": "vitest",
"lint": "eslint --cache --cache-location ./node_modules/.cache/eslint .",
"lint:fix": "npm run lint -- --fix",
"start": "bindings=$(./bindings.sh) && wrangler pages dev ./build/client $bindings",
"typecheck": "tsc",
"typegen": "wrangler types",
"preview": "pnpm run build && pnpm run start"
},
"engines": {
"node": ">=18.18.0"
},
"dependencies": {
"@ai-sdk/anthropic": "^0.0.39",
"@codemirror/autocomplete": "^6.17.0",
"@codemirror/commands": "^6.6.0",
"@codemirror/lang-cpp": "^6.0.2",
"@codemirror/lang-css": "^6.2.1",
"@codemirror/lang-html": "^6.4.9",
"@codemirror/lang-javascript": "^6.2.2",
"@codemirror/lang-json": "^6.0.1",
"@codemirror/lang-markdown": "^6.2.5",
"@codemirror/lang-python": "^6.1.6",
"@codemirror/lang-sass": "^6.0.2",
"@codemirror/lang-wast": "^6.0.2",
"@codemirror/language": "^6.10.2",
"@codemirror/search": "^6.5.6",
"@codemirror/state": "^6.4.1",
"@codemirror/view": "^6.28.4",
"@iconify-json/ph": "^1.1.13",
"@iconify-json/svg-spinners": "^1.1.2",
"@lezer/highlight": "^1.2.0",
"@nanostores/react": "^0.7.2",
"@radix-ui/react-dialog": "^1.1.1",
"@radix-ui/react-dropdown-menu": "^2.1.1",
"@remix-run/cloudflare": "^2.10.2",
"@remix-run/cloudflare-pages": "^2.10.2",
"@remix-run/react": "^2.10.2",
"@uiw/codemirror-theme-vscode": "^4.23.0",
"@unocss/reset": "^0.61.0",
"@webcontainer/api": "1.3.0-internal.10",
"@xterm/addon-fit": "^0.10.0",
"@xterm/addon-web-links": "^0.11.0",
"@xterm/xterm": "^5.5.0",
"ai": "^3.3.4",
"date-fns": "^3.6.0",
"diff": "^5.2.0",
"framer-motion": "^11.2.12",
"isbot": "^4.1.0",
"istextorbinary": "^9.5.0",
"jose": "^5.6.3",
"nanostores": "^0.10.3",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-hotkeys-hook": "^4.5.0",
"react-markdown": "^9.0.1",
"react-resizable-panels": "^2.0.20",
"react-toastify": "^10.0.5",
"rehype-raw": "^7.0.0",
"rehype-sanitize": "^6.0.0",
"remark-gfm": "^4.0.0",
"remix-island": "^0.2.0",
"remix-utils": "^7.6.0",
"shiki": "^1.9.1",
"unist-util-visit": "^5.0.0"
},
"devDependencies": {
"@blitz/eslint-plugin": "0.1.0",
"@cloudflare/workers-types": "^4.20240620.0",
"@remix-run/dev": "^2.10.0",
"@types/diff": "^5.2.1",
"@types/react": "^18.2.20",
"@types/react-dom": "^18.2.7",
"fast-glob": "^3.3.2",
"is-ci": "^3.0.1",
"node-fetch": "^3.3.2",
"prettier": "^3.3.2",
"typescript": "^5.5.2",
"unified": "^11.0.5",
"unocss": "^0.61.3",
"vite": "^5.3.1",
"vite-plugin-node-polyfills": "^0.22.0",
"vite-plugin-optimize-css-modules": "^1.1.0",
"vite-tsconfig-paths": "^4.3.2",
"vitest": "^2.0.1",
"wrangler": "^3.63.2",
"zod": "^3.23.8"
},
"resolutions": {
"@typescript-eslint/utils": "^8.0.0-alpha.30"
}
}
================================================
FILE: tsconfig.json
================================================
{
"compilerOptions": {
"lib": ["DOM", "DOM.Iterable", "ESNext"],
"types": ["@remix-run/cloudflare", "vite/client", "@cloudflare/workers-types/2023-07-01"],
"isolatedModules": true,
"esModuleInterop": true,
"jsx": "react-jsx",
"module": "ESNext",
"moduleResolution": "Bundler",
"resolveJsonModule": true,
"target": "ESNext",
"strict": true,
"allowJs": true,
"skipLibCheck": true,
"verbatimModuleSyntax": true,
"forceConsistentCasingInFileNames": true,
"baseUrl": ".",
"paths": {
"~/*": ["./app/*"]
},
// vite takes care of building everything, not tsc
"noEmit": true
},
"include": [
"**/*.ts",
"**/*.tsx",
"**/.server/**/*.ts",
"**/.server/**/*.tsx",
"**/.client/**/*.ts",
"**/.client/**/*.tsx"
]
}
================================================
FILE: types/istextorbinary.d.ts
================================================
/**
* @note For some reason the types aren't picked up from node_modules so I declared the module here
* with only the function that we use.
*/
declare module 'istextorbinary' {
export interface EncodingOpts {
/** Defaults to 24 */
chunkLength?: number;
/** If not provided, will check the start, beginning, and end */
chunkBegin?: number;
}
export function getEncoding(buffer: Buffer | null, opts?: EncodingOpts): 'utf8' | 'binary' | null;
}
================================================
FILE: uno.config.ts
================================================
import { globSync } from 'fast-glob';
import fs from 'node:fs/promises';
import { basename } from 'node:path';
import { defineConfig, presetIcons, presetUno, transformerDirectives } from 'unocss';
const iconPaths = globSync('./icons/*.svg');
const collectionName = 'bolt';
const customIconCollection = iconPaths.reduce(
(acc, iconPath) => {
const [iconName] = basename(iconPath).split('.');
acc[collectionName] ??= {};
acc[collectionName][iconName] = async () => fs.readFile(iconPath, 'utf8');
return acc;
},
{} as Record Promise>>,
);
const BASE_COLORS = {
white: '#FFFFFF',
gray: {
50: '#FAFAFA',
100: '#F5F5F5',
200: '#E5E5E5',
300: '#D4D4D4',
400: '#A3A3A3',
500: '#737373',
600: '#525252',
700: '#404040',
800: '#262626',
900: '#171717',
950: '#0A0A0A',
},
accent: {
50: '#EEF9FF',
100: '#D8F1FF',
200: '#BAE7FF',
300: '#8ADAFF',
400: '#53C4FF',
500: '#2BA6FF',
600: '#1488FC',
700: '#0D6FE8',
800: '#1259BB',
900: '#154E93',
950: '#122F59',
},
green: {
50: '#F0FDF4',
100: '#DCFCE7',
200: '#BBF7D0',
300: '#86EFAC',
400: '#4ADE80',
500: '#22C55E',
600: '#16A34A',
700: '#15803D',
800: '#166534',
900: '#14532D',
950: '#052E16',
},
orange: {
50: '#FFFAEB',
100: '#FEEFC7',
200: '#FEDF89',
300: '#FEC84B',
400: '#FDB022',
500: '#F79009',
600: '#DC6803',
700: '#B54708',
800: '#93370D',
900: '#792E0D',
},
red: {
50: '#FEF2F2',
100: '#FEE2E2',
200: '#FECACA',
300: '#FCA5A5',
400: '#F87171',
500: '#EF4444',
600: '#DC2626',
700: '#B91C1C',
800: '#991B1B',
900: '#7F1D1D',
950: '#450A0A',
},
};
const COLOR_PRIMITIVES = {
...BASE_COLORS,
alpha: {
white: generateAlphaPalette(BASE_COLORS.white),
gray: generateAlphaPalette(BASE_COLORS.gray[900]),
red: generateAlphaPalette(BASE_COLORS.red[500]),
accent: generateAlphaPalette(BASE_COLORS.accent[500]),
},
};
export default defineConfig({
shortcuts: {
'bolt-ease-cubic-bezier': 'ease-[cubic-bezier(0.4,0,0.2,1)]',
'transition-theme': 'transition-[background-color,border-color,color] duration-150 bolt-ease-cubic-bezier',
kdb: 'bg-bolt-elements-code-background text-bolt-elements-code-text py-1 px-1.5 rounded-md',
'max-w-chat': 'max-w-[var(--chat-max-width)]',
},
rules: [
/**
* This shorthand doesn't exist in Tailwind and we overwrite it to avoid
* any conflicts with minified CSS classes.
*/
['b', {}],
],
theme: {
colors: {
...COLOR_PRIMITIVES,
bolt: {
elements: {
borderColor: 'var(--bolt-elements-borderColor)',
borderColorActive: 'var(--bolt-elements-borderColorActive)',
background: {
depth: {
1: 'var(--bolt-elements-bg-depth-1)',
2: 'var(--bolt-elements-bg-depth-2)',
3: 'var(--bolt-elements-bg-depth-3)',
4: 'var(--bolt-elements-bg-depth-4)',
},
},
textPrimary: 'var(--bolt-elements-textPrimary)',
textSecondary: 'var(--bolt-elements-textSecondary)',
textTertiary: 'var(--bolt-elements-textTertiary)',
code: {
background: 'var(--bolt-elements-code-background)',
text: 'var(--bolt-elements-code-text)',
},
button: {
primary: {
background: 'var(--bolt-elements-button-primary-background)',
backgroundHover: 'var(--bolt-elements-button-primary-backgroundHover)',
text: 'var(--bolt-elements-button-primary-text)',
},
secondary: {
background: 'var(--bolt-elements-button-secondary-background)',
backgroundHover: 'var(--bolt-elements-button-secondary-backgroundHover)',
text: 'var(--bolt-elements-button-secondary-text)',
},
danger: {
background: 'var(--bolt-elements-button-danger-background)',
backgroundHover: 'var(--bolt-elements-button-danger-backgroundHover)',
text: 'var(--bolt-elements-button-danger-text)',
},
},
item: {
contentDefault: 'var(--bolt-elements-item-contentDefault)',
contentActive: 'var(--bolt-elements-item-contentActive)',
contentAccent: 'var(--bolt-elements-item-contentAccent)',
contentDanger: 'var(--bolt-elements-item-contentDanger)',
backgroundDefault: 'var(--bolt-elements-item-backgroundDefault)',
backgroundActive: 'var(--bolt-elements-item-backgroundActive)',
backgroundAccent: 'var(--bolt-elements-item-backgroundAccent)',
backgroundDanger: 'var(--bolt-elements-item-backgroundDanger)',
},
actions: {
background: 'var(--bolt-elements-actions-background)',
code: {
background: 'var(--bolt-elements-actions-code-background)',
},
},
artifacts: {
background: 'var(--bolt-elements-artifacts-background)',
backgroundHover: 'var(--bolt-elements-artifacts-backgroundHover)',
borderColor: 'var(--bolt-elements-artifacts-borderColor)',
inlineCode: {
background: 'var(--bolt-elements-artifacts-inlineCode-background)',
text: 'var(--bolt-elements-artifacts-inlineCode-text)',
},
},
messages: {
background: 'var(--bolt-elements-messages-background)',
linkColor: 'var(--bolt-elements-messages-linkColor)',
code: {
background: 'var(--bolt-elements-messages-code-background)',
},
inlineCode: {
background: 'var(--bolt-elements-messages-inlineCode-background)',
text: 'var(--bolt-elements-messages-inlineCode-text)',
},
},
icon: {
success: 'var(--bolt-elements-icon-success)',
error: 'var(--bolt-elements-icon-error)',
primary: 'var(--bolt-elements-icon-primary)',
secondary: 'var(--bolt-elements-icon-secondary)',
tertiary: 'var(--bolt-elements-icon-tertiary)',
},
preview: {
addressBar: {
background: 'var(--bolt-elements-preview-addressBar-background)',
backgroundHover: 'var(--bolt-elements-preview-addressBar-backgroundHover)',
backgroundActive: 'var(--bolt-elements-preview-addressBar-backgroundActive)',
text: 'var(--bolt-elements-preview-addressBar-text)',
textActive: 'var(--bolt-elements-preview-addressBar-textActive)',
},
},
terminals: {
background: 'var(--bolt-elements-terminals-background)',
buttonBackground: 'var(--bolt-elements-terminals-buttonBackground)',
},
dividerColor: 'var(--bolt-elements-dividerColor)',
loader: {
background: 'var(--bolt-elements-loader-background)',
progress: 'var(--bolt-elements-loader-progress)',
},
prompt: {
background: 'var(--bolt-elements-prompt-background)',
},
sidebar: {
dropdownShadow: 'var(--bolt-elements-sidebar-dropdownShadow)',
buttonBackgroundDefault: 'var(--bolt-elements-sidebar-buttonBackgroundDefault)',
buttonBackgroundHover: 'var(--bolt-elements-sidebar-buttonBackgroundHover)',
buttonText: 'var(--bolt-elements-sidebar-buttonText)',
},
cta: {
background: 'var(--bolt-elements-cta-background)',
text: 'var(--bolt-elements-cta-text)',
},
},
},
},
},
transformers: [transformerDirectives()],
presets: [
presetUno({
dark: {
light: '[data-theme="light"]',
dark: '[data-theme="dark"]',
},
}),
presetIcons({
warn: true,
collections: {
...customIconCollection,
},
}),
],
});
/**
* Generates an alpha palette for a given hex color.
*
* @param hex - The hex color code (without alpha) to generate the palette from.
* @returns An object where keys are opacity percentages and values are hex colors with alpha.
*
* Example:
*
* ```
* {
* '1': '#FFFFFF03',
* '2': '#FFFFFF05',
* '3': '#FFFFFF08',
* }
* ```
*/
function generateAlphaPalette(hex: string) {
return [1, 2, 3, 4, 5, 10, 20, 30, 40, 50, 60, 70, 80, 90, 100].reduce(
(acc, opacity) => {
const alpha = Math.round((opacity / 100) * 255)
.toString(16)
.padStart(2, '0');
acc[opacity] = `${hex}${alpha}`;
return acc;
},
{} as Record,
);
}
================================================
FILE: vite.config.ts
================================================
import { cloudflareDevProxyVitePlugin as remixCloudflareDevProxy, vitePlugin as remixVitePlugin } from '@remix-run/dev';
import UnoCSS from 'unocss/vite';
import { defineConfig, type ViteDevServer } from 'vite';
import { nodePolyfills } from 'vite-plugin-node-polyfills';
import { optimizeCssModules } from 'vite-plugin-optimize-css-modules';
import tsconfigPaths from 'vite-tsconfig-paths';
export default defineConfig((config) => {
return {
build: {
target: 'esnext',
},
plugins: [
nodePolyfills({
include: ['path', 'buffer'],
}),
config.mode !== 'test' && remixCloudflareDevProxy(),
remixVitePlugin({
future: {
v3_fetcherPersist: true,
v3_relativeSplatPath: true,
v3_throwAbortReason: true,
},
}),
UnoCSS(),
tsconfigPaths(),
chrome129IssuePlugin(),
config.mode === 'production' && optimizeCssModules({ apply: 'build' }),
],
};
});
function chrome129IssuePlugin() {
return {
name: 'chrome129IssuePlugin',
configureServer(server: ViteDevServer) {
server.middlewares.use((req, res, next) => {
const raw = req.headers['user-agent']?.match(/Chrom(e|ium)\/([0-9]+)\./);
if (raw) {
const version = parseInt(raw[2], 10);
if (version === 129) {
res.setHeader('content-type', 'text/html');
res.end(
'Please use Chrome Canary for testing.
Chrome 129 has an issue with JavaScript modules & Vite local development, see for more information.
Note: This only impacts local development. `pnpm run build` and `pnpm run start` will work fine in this browser.
',
);
return;
}
}
next();
});
},
};
}
================================================
FILE: worker-configuration.d.ts
================================================
interface Env {
ANTHROPIC_API_KEY: string;
}
================================================
FILE: wrangler.toml
================================================
#:schema node_modules/wrangler/config-schema.json
name = "bolt"
compatibility_flags = ["nodejs_compat"]
compatibility_date = "2024-07-01"
pages_build_output_dir = "./build/client"