Showing preview only (310K chars total). Download the full file or copy to clipboard to get everything.
Repository: stackblitz/bolt.new
Branch: main
Commit: eda10b121221
Files: 129
Total size: 281.1 KB
Directory structure:
gitextract_pf42qdrs/
├── .editorconfig
├── .github/
│ ├── ISSUE_TEMPLATE/
│ │ ├── bug_report.yml
│ │ ├── config.yml
│ │ └── feature_request.md
│ ├── actions/
│ │ └── setup-and-build/
│ │ └── action.yaml
│ └── workflows/
│ ├── ci.yaml
│ └── semantic-pr.yaml
├── .gitignore
├── .husky/
│ └── commit-msg
├── .prettierignore
├── .prettierrc
├── .tool-versions
├── CONTRIBUTING.md
├── LICENSE
├── README.md
├── app/
│ ├── components/
│ │ ├── chat/
│ │ │ ├── Artifact.tsx
│ │ │ ├── AssistantMessage.tsx
│ │ │ ├── BaseChat.module.scss
│ │ │ ├── BaseChat.tsx
│ │ │ ├── Chat.client.tsx
│ │ │ ├── CodeBlock.module.scss
│ │ │ ├── CodeBlock.tsx
│ │ │ ├── Markdown.module.scss
│ │ │ ├── Markdown.tsx
│ │ │ ├── Messages.client.tsx
│ │ │ ├── SendButton.client.tsx
│ │ │ └── UserMessage.tsx
│ │ ├── editor/
│ │ │ └── codemirror/
│ │ │ ├── BinaryContent.tsx
│ │ │ ├── CodeMirrorEditor.tsx
│ │ │ ├── cm-theme.ts
│ │ │ ├── indent.ts
│ │ │ └── languages.ts
│ │ ├── header/
│ │ │ ├── Header.tsx
│ │ │ └── HeaderActionButtons.client.tsx
│ │ ├── sidebar/
│ │ │ ├── HistoryItem.tsx
│ │ │ ├── Menu.client.tsx
│ │ │ └── date-binning.ts
│ │ ├── ui/
│ │ │ ├── Dialog.tsx
│ │ │ ├── IconButton.tsx
│ │ │ ├── LoadingDots.tsx
│ │ │ ├── PanelHeader.tsx
│ │ │ ├── PanelHeaderButton.tsx
│ │ │ ├── Slider.tsx
│ │ │ └── ThemeSwitch.tsx
│ │ └── workbench/
│ │ ├── EditorPanel.tsx
│ │ ├── FileBreadcrumb.tsx
│ │ ├── FileTree.tsx
│ │ ├── PortDropdown.tsx
│ │ ├── Preview.tsx
│ │ ├── Workbench.client.tsx
│ │ └── terminal/
│ │ ├── Terminal.tsx
│ │ └── theme.ts
│ ├── entry.client.tsx
│ ├── entry.server.tsx
│ ├── lib/
│ │ ├── .server/
│ │ │ └── llm/
│ │ │ ├── api-key.ts
│ │ │ ├── constants.ts
│ │ │ ├── model.ts
│ │ │ ├── prompts.ts
│ │ │ ├── stream-text.ts
│ │ │ └── switchable-stream.ts
│ │ ├── crypto.ts
│ │ ├── fetch.ts
│ │ ├── hooks/
│ │ │ ├── index.ts
│ │ │ ├── useMessageParser.ts
│ │ │ ├── usePromptEnhancer.ts
│ │ │ ├── useShortcuts.ts
│ │ │ └── useSnapScroll.ts
│ │ ├── persistence/
│ │ │ ├── ChatDescription.client.tsx
│ │ │ ├── db.ts
│ │ │ ├── index.ts
│ │ │ └── useChatHistory.ts
│ │ ├── runtime/
│ │ │ ├── __snapshots__/
│ │ │ │ └── message-parser.spec.ts.snap
│ │ │ ├── action-runner.ts
│ │ │ ├── message-parser.spec.ts
│ │ │ └── message-parser.ts
│ │ ├── stores/
│ │ │ ├── chat.ts
│ │ │ ├── editor.ts
│ │ │ ├── files.ts
│ │ │ ├── previews.ts
│ │ │ ├── settings.ts
│ │ │ ├── terminal.ts
│ │ │ ├── theme.ts
│ │ │ └── workbench.ts
│ │ └── webcontainer/
│ │ ├── auth.client.ts
│ │ └── index.ts
│ ├── root.tsx
│ ├── routes/
│ │ ├── _index.tsx
│ │ ├── api.chat.ts
│ │ ├── api.enhancer.ts
│ │ └── chat.$id.tsx
│ ├── styles/
│ │ ├── animations.scss
│ │ ├── components/
│ │ │ ├── code.scss
│ │ │ ├── editor.scss
│ │ │ ├── resize-handle.scss
│ │ │ ├── terminal.scss
│ │ │ └── toast.scss
│ │ ├── index.scss
│ │ ├── variables.scss
│ │ └── z-index.scss
│ ├── types/
│ │ ├── actions.ts
│ │ ├── artifact.ts
│ │ ├── terminal.ts
│ │ └── theme.ts
│ └── utils/
│ ├── buffer.ts
│ ├── classNames.ts
│ ├── constants.ts
│ ├── debounce.ts
│ ├── diff.ts
│ ├── easings.ts
│ ├── logger.ts
│ ├── markdown.ts
│ ├── mobile.ts
│ ├── promises.ts
│ ├── react.ts
│ ├── shell.ts
│ ├── stripIndent.ts
│ ├── terminal.ts
│ └── unreachable.ts
├── bindings.sh
├── eslint.config.mjs
├── functions/
│ └── [[path]].ts
├── load-context.ts
├── package.json
├── tsconfig.json
├── types/
│ └── istextorbinary.d.ts
├── uno.config.ts
├── vite.config.ts
├── worker-configuration.d.ts
└── wrangler.toml
================================================
FILE CONTENTS
================================================
================================================
FILE: .editorconfig
================================================
root = true
[*]
indent_style = space
end_of_line = lf
charset = utf-8
trim_trailing_whitespace = true
insert_final_newline = true
max_line_length = 120
indent_size = 2
[*.md]
trim_trailing_whitespace = false
================================================
FILE: .github/ISSUE_TEMPLATE/bug_report.yml
================================================
name: "Bug report"
description: Create a report to help us improve
body:
- type: markdown
attributes:
value: |
Thank you for reporting an issue :pray:.
This issue tracker is for bugs and issues found with [Bolt.new](https://bolt.new).
If you experience issues related to WebContainer, please file an issue in our [WebContainer repo](https://github.com/stackblitz/webcontainer-core), or file an issue in our [StackBlitz core repo](https://github.com/stackblitz/core) for issues with StackBlitz.
The more information you fill in, the better we can help you.
- type: textarea
id: description
attributes:
label: Describe the bug
description: Provide a clear and concise description of what you're running into.
validations:
required: true
- type: input
id: link
attributes:
label: Link to the Bolt URL that caused the error
description: Please do not delete it after reporting!
validations:
required: true
- type: checkboxes
id: checkboxes
attributes:
label: Validations
description: Before submitting the issue, please make sure you do the following
options:
- label: "Please make your project public or accessible by URL. This will allow anyone trying to help you to easily reproduce the issue and provide assistance."
required: true
- type: markdown
attributes:
value: |

- type: textarea
id: steps
attributes:
label: Steps to reproduce
description: Describe the steps we have to take to reproduce the behavior.
placeholder: |
1. Go to '...'
2. Click on '....'
3. Scroll down to '....'
4. See error
validations:
required: true
- type: textarea
id: expected
attributes:
label: Expected behavior
description: Provide a clear and concise description of what you expected to happen.
validations:
required: true
- type: textarea
id: screenshots
attributes:
label: Screen Recording / Screenshot
description: If applicable, **please include a screen recording** (preferably) or screenshot showcasing the issue. This will assist us in resolving your issue <u>quickly</u>.
- type: textarea
id: platform
attributes:
label: Platform
value: |
- OS: [e.g. macOS, Windows, Linux]
- Browser: [e.g. Chrome, Safari, Firefox]
- Version: [e.g. 91.1]
- type: textarea
id: additional
attributes:
label: Additional context
description: Add any other context about the problem here.
================================================
FILE: .github/ISSUE_TEMPLATE/config.yml
================================================
blank_issues_enabled: false
contact_links:
- name: Bolt.new Help Center
url: https://support.bolt.new
about: Official central repository for tips, tricks, tutorials, known issues, and best practices for bolt.new usage.
- name: Billing Issues
url: https://support.bolt.new/Billing-13fd971055d680ebb393cb80973710b6
about: Instructions for billing and subscription related support
- name: Discord Chat
url: https://discord.gg/stackblitz
about: Build, share, and learn with other Bolters in real time.
================================================
FILE: .github/ISSUE_TEMPLATE/feature_request.md
================================================
---
name: Feature request
about: Suggest an idea for this project
title: ''
labels: ''
assignees: ''
---
**Is your feature request related to a problem? Please describe:**
<!-- A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] -->
**Describe the solution you'd like:**
<!-- A clear and concise description of what you want to happen. -->
**Describe alternatives you've considered:**
<!-- A clear and concise description of any alternative solutions or features you've considered. -->
**Additional context:**
<!-- Add any other context or screenshots about the feature request here. -->
================================================
FILE: .github/actions/setup-and-build/action.yaml
================================================
name: Setup and Build
description: Generic setup action
inputs:
pnpm-version:
required: false
type: string
default: '9.4.0'
node-version:
required: false
type: string
default: '20.15.1'
runs:
using: composite
steps:
- uses: pnpm/action-setup@v4
with:
version: ${{ inputs.pnpm-version }}
run_install: false
- name: Set Node.js version to ${{ inputs.node-version }}
uses: actions/setup-node@v4
with:
node-version: ${{ inputs.node-version }}
cache: pnpm
- name: Install dependencies and build project
shell: bash
run: |
pnpm install
pnpm run build
================================================
FILE: .github/workflows/ci.yaml
================================================
name: CI/CD
on:
push:
branches:
- master
pull_request:
jobs:
test:
name: Test
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup and Build
uses: ./.github/actions/setup-and-build
- name: Run type check
run: pnpm run typecheck
# - name: Run ESLint
# run: pnpm run lint
- name: Run tests
run: pnpm run test
================================================
FILE: .github/workflows/semantic-pr.yaml
================================================
name: Semantic Pull Request
on:
pull_request_target:
types: [opened, reopened, edited, synchronize]
permissions:
pull-requests: read
jobs:
main:
name: Validate PR Title
runs-on: ubuntu-latest
steps:
# https://github.com/amannn/action-semantic-pull-request/releases/tag/v5.5.3
- uses: amannn/action-semantic-pull-request@0723387faaf9b38adef4775cd42cfd5155ed6017
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
subjectPattern: ^(?![A-Z]).+$
subjectPatternError: |
The subject "{subject}" found in the pull request title "{title}"
didn't match the configured pattern. Please ensure that the subject
doesn't start with an uppercase character.
types: |
fix
feat
chore
build
ci
perf
docs
refactor
revert
test
================================================
FILE: .gitignore
================================================
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
.vscode/*
!.vscode/launch.json
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
/.cache
/build
.env*
*.vars
.wrangler
_worker.bundle
================================================
FILE: .husky/commit-msg
================================================
#!/usr/bin/env sh
. "$(dirname "$0")/_/husky.sh"
npx commitlint --edit $1
exit 0
================================================
FILE: .prettierignore
================================================
pnpm-lock.yaml
.astro
================================================
FILE: .prettierrc
================================================
{
"printWidth": 120,
"singleQuote": true,
"useTabs": false,
"tabWidth": 2,
"semi": true,
"bracketSpacing": true
}
================================================
FILE: .tool-versions
================================================
nodejs 20.15.1
pnpm 9.4.0
================================================
FILE: CONTRIBUTING.md
================================================
[](https://bolt.new)
> Welcome to the **Bolt** open-source codebase! This repo contains a simple example app using the core components from bolt.new to help you get started building **AI-powered software development tools** powered by StackBlitz’s **WebContainer API**.
### Why Build with Bolt + WebContainer API
By building with the Bolt + WebContainer API you can create browser-based applications that let users **prompt, run, edit, and deploy** full-stack web apps directly in the browser, without the need for virtual machines. With WebContainer API, you can build apps that give AI direct access and full control over a **Node.js server**, **filesystem**, **package manager** and **dev terminal** inside your users browser tab. This powerful combination allows you to create a new class of development tools that support all major JavaScript libraries and Node packages right out of the box, all without remote environments or local installs.
### What’s the Difference Between Bolt (This Repo) and [Bolt.new](https://bolt.new)?
- **Bolt.new**: This is the **commercial product** from StackBlitz—a hosted, browser-based AI development tool that enables users to prompt, run, edit, and deploy full-stack web applications directly in the browser. Built on top of the [Bolt open-source repo](https://github.com/stackblitz/bolt.new) and powered by the StackBlitz **WebContainer API**.
- **Bolt (This Repo)**: This open-source repository provides the core components used to make **Bolt.new**. This repo contains the UI interface for Bolt as well as the server components, built using [Remix Run](https://remix.run/). By leveraging this repo and StackBlitz’s **WebContainer API**, you can create your own AI-powered development tools and full-stack applications that run entirely in the browser.
# Get Started Building with Bolt
Bolt combines the capabilities of AI with sandboxed development environments to create a collaborative experience where code can be developed by the assistant and the programmer together. Bolt combines [WebContainer API](https://webcontainers.io/api) with [Claude Sonnet 3.5](https://www.anthropic.com/news/claude-3-5-sonnet) using [Remix](https://remix.run/) and the [AI SDK](https://sdk.vercel.ai/).
### WebContainer API
Bolt uses [WebContainers](https://webcontainers.io/) to run generated code in the browser. WebContainers provide Bolt with a full-stack sandbox environment using [WebContainer API](https://webcontainers.io/api). WebContainers run full-stack applications directly in the browser without the cost and security concerns of cloud hosted AI agents. WebContainers are interactive and editable, and enables Bolt's AI to run code and understand any changes from the user.
The [WebContainer API](https://webcontainers.io) is free for personal and open source usage. If you're building an application for commercial usage, you can learn more about our [WebContainer API commercial usage pricing here](https://stackblitz.com/pricing#webcontainer-api).
### Remix App
Bolt is built with [Remix](https://remix.run/) and
deployed using [CloudFlare Pages](https://pages.cloudflare.com/) and
[CloudFlare Workers](https://workers.cloudflare.com/).
### AI SDK Integration
Bolt uses the [AI SDK](https://github.com/vercel/ai) to integrate with AI
models. At this time, Bolt supports using Anthropic's Claude Sonnet 3.5.
You can get an API key from the [Anthropic API Console](https://console.anthropic.com/) to use with Bolt.
Take a look at how [Bolt uses the AI SDK](https://github.com/stackblitz/bolt.new/tree/main/app/lib/.server/llm)
## Prerequisites
Before you begin, ensure you have the following installed:
- Node.js (v20.15.1)
- pnpm (v9.4.0)
## Setup
1. Clone the repository (if you haven't already):
```bash
git clone https://github.com/stackblitz/bolt.new.git
```
2. Install dependencies:
```bash
pnpm install
```
3. Create a `.env.local` file in the root directory and add your Anthropic API key:
```
ANTHROPIC_API_KEY=XXX
```
Optionally, you can set the debug level:
```
VITE_LOG_LEVEL=debug
```
**Important**: Never commit your `.env.local` file to version control. It's already included in .gitignore.
## Available Scripts
- `pnpm run dev`: Starts the development server.
- `pnpm run build`: Builds the project.
- `pnpm run start`: Runs the built application locally using Wrangler Pages. This script uses `bindings.sh` to set up necessary bindings so you don't have to duplicate environment variables.
- `pnpm run preview`: Builds the project and then starts it locally, useful for testing the production build. Note, HTTP streaming currently doesn't work as expected with `wrangler pages dev`.
- `pnpm test`: Runs the test suite using Vitest.
- `pnpm run typecheck`: Runs TypeScript type checking.
- `pnpm run typegen`: Generates TypeScript types using Wrangler.
- `pnpm run deploy`: Builds the project and deploys it to Cloudflare Pages.
## Development
To start the development server:
```bash
pnpm run dev
```
This will start the Remix Vite development server.
## Testing
Run the test suite with:
```bash
pnpm test
```
## Deployment
To deploy the application to Cloudflare Pages:
```bash
pnpm run deploy
```
Make sure you have the necessary permissions and Wrangler is correctly configured for your Cloudflare account.
================================================
FILE: LICENSE
================================================
MIT License
Copyright (c) 2024 StackBlitz, Inc.
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
================================================
FILE: README.md
================================================
[](https://bolt.new)
# Bolt.new: AI-Powered Full-Stack Web Development in the Browser
Bolt.new is an AI-powered web development agent that allows you to prompt, run, edit, and deploy full-stack applications directly from your browser—no local setup required. If you're here to build your own AI-powered web dev agent using the Bolt open source codebase, [click here to get started!](./CONTRIBUTING.md)
## What Makes Bolt.new Different
Claude, v0, etc are incredible- but you can't install packages, run backends or edit code. That’s where Bolt.new stands out:
- **Full-Stack in the Browser**: Bolt.new integrates cutting-edge AI models with an in-browser development environment powered by **StackBlitz’s WebContainers**. This allows you to:
- Install and run npm tools and libraries (like Vite, Next.js, and more)
- Run Node.js servers
- Interact with third-party APIs
- Deploy to production from chat
- Share your work via a URL
- **AI with Environment Control**: Unlike traditional dev environments where the AI can only assist in code generation, Bolt.new gives AI models **complete control** over the entire environment including the filesystem, node server, package manager, terminal, and browser console. This empowers AI agents to handle the entire app lifecycle—from creation to deployment.
Whether you’re an experienced developer, a PM or designer, Bolt.new allows you to build production-grade full-stack applications with ease.
For developers interested in building their own AI-powered development tools with WebContainers, check out the open-source Bolt codebase in this repo!
## Tips and Tricks
Here are some tips to get the most out of Bolt.new:
- **Be specific about your stack**: If you want to use specific frameworks or libraries (like Astro, Tailwind, ShadCN, or any other popular JavaScript framework), mention them in your initial prompt to ensure Bolt scaffolds the project accordingly.
- **Use the enhance prompt icon**: Before sending your prompt, try clicking the 'enhance' icon to have the AI model help you refine your prompt, then edit the results before submitting.
- **Scaffold the basics first, then add features**: Make sure the basic structure of your application is in place before diving into more advanced functionality. This helps Bolt understand the foundation of your project and ensure everything is wired up right before building out more advanced functionality.
- **Batch simple instructions**: Save time by combining simple instructions into one message. For example, you can ask Bolt to change the color scheme, add mobile responsiveness, and restart the dev server, all in one go saving you time and reducing API credit consumption significantly.
## FAQs
**Where do I sign up for a paid plan?**
Bolt.new is free to get started. If you need more AI tokens or want private projects, you can purchase a paid subscription in your [Bolt.new](https://bolt.new) settings, in the lower-left hand corner of the application.
**What happens if I hit the free usage limit?**
Once your free daily token limit is reached, AI interactions are paused until the next day or until you upgrade your plan.
**Is Bolt in beta?**
Yes, Bolt.new is in beta, and we are actively improving it based on feedback.
**How can I report Bolt.new issues?**
Check out the [Issues section](https://github.com/stackblitz/bolt.new/issues) to report an issue or request a new feature. Please use the search feature to check if someone else has already submitted the same issue/request.
**What frameworks/libraries currently work on Bolt?**
Bolt.new supports most popular JavaScript frameworks and libraries. If it runs on StackBlitz, it will run on Bolt.new as well.
**How can I add make sure my framework/project works well in bolt?**
We are excited to work with the JavaScript ecosystem to improve functionality in Bolt. Reach out to us via [hello@stackblitz.com](mailto:hello@stackblitz.com) to discuss how we can partner!
================================================
FILE: app/components/chat/Artifact.tsx
================================================
import { useStore } from '@nanostores/react';
import { AnimatePresence, motion } from 'framer-motion';
import { computed } from 'nanostores';
import { memo, useEffect, useRef, useState } from 'react';
import { createHighlighter, type BundledLanguage, type BundledTheme, type HighlighterGeneric } from 'shiki';
import type { ActionState } from '~/lib/runtime/action-runner';
import { workbenchStore } from '~/lib/stores/workbench';
import { classNames } from '~/utils/classNames';
import { cubicEasingFn } from '~/utils/easings';
const highlighterOptions = {
langs: ['shell'],
themes: ['light-plus', 'dark-plus'],
};
const shellHighlighter: HighlighterGeneric<BundledLanguage, BundledTheme> =
import.meta.hot?.data.shellHighlighter ?? (await createHighlighter(highlighterOptions));
if (import.meta.hot) {
import.meta.hot.data.shellHighlighter = shellHighlighter;
}
interface ArtifactProps {
messageId: string;
}
export const Artifact = memo(({ messageId }: ArtifactProps) => {
const userToggledActions = useRef(false);
const [showActions, setShowActions] = useState(false);
const artifacts = useStore(workbenchStore.artifacts);
const artifact = artifacts[messageId];
const actions = useStore(
computed(artifact.runner.actions, (actions) => {
return Object.values(actions);
}),
);
const toggleActions = () => {
userToggledActions.current = true;
setShowActions(!showActions);
};
useEffect(() => {
if (actions.length && !showActions && !userToggledActions.current) {
setShowActions(true);
}
}, [actions]);
return (
<div className="artifact border border-bolt-elements-borderColor flex flex-col overflow-hidden rounded-lg w-full transition-border duration-150">
<div className="flex">
<button
className="flex items-stretch bg-bolt-elements-artifacts-background hover:bg-bolt-elements-artifacts-backgroundHover w-full overflow-hidden"
onClick={() => {
const showWorkbench = workbenchStore.showWorkbench.get();
workbenchStore.showWorkbench.set(!showWorkbench);
}}
>
<div className="px-5 p-3.5 w-full text-left">
<div className="w-full text-bolt-elements-textPrimary font-medium leading-5 text-sm">{artifact?.title}</div>
<div className="w-full w-full text-bolt-elements-textSecondary text-xs mt-0.5">Click to open Workbench</div>
</div>
</button>
<div className="bg-bolt-elements-artifacts-borderColor w-[1px]" />
<AnimatePresence>
{actions.length && (
<motion.button
initial={{ width: 0 }}
animate={{ width: 'auto' }}
exit={{ width: 0 }}
transition={{ duration: 0.15, ease: cubicEasingFn }}
className="bg-bolt-elements-artifacts-background hover:bg-bolt-elements-artifacts-backgroundHover"
onClick={toggleActions}
>
<div className="p-4">
<div className={showActions ? 'i-ph:caret-up-bold' : 'i-ph:caret-down-bold'}></div>
</div>
</motion.button>
)}
</AnimatePresence>
</div>
<AnimatePresence>
{showActions && actions.length > 0 && (
<motion.div
className="actions"
initial={{ height: 0 }}
animate={{ height: 'auto' }}
exit={{ height: '0px' }}
transition={{ duration: 0.15 }}
>
<div className="bg-bolt-elements-artifacts-borderColor h-[1px]" />
<div className="p-5 text-left bg-bolt-elements-actions-background">
<ActionList actions={actions} />
</div>
</motion.div>
)}
</AnimatePresence>
</div>
);
});
interface ShellCodeBlockProps {
classsName?: string;
code: string;
}
function ShellCodeBlock({ classsName, code }: ShellCodeBlockProps) {
return (
<div
className={classNames('text-xs', classsName)}
dangerouslySetInnerHTML={{
__html: shellHighlighter.codeToHtml(code, {
lang: 'shell',
theme: 'dark-plus',
}),
}}
></div>
);
}
interface ActionListProps {
actions: ActionState[];
}
const actionVariants = {
hidden: { opacity: 0, y: 20 },
visible: { opacity: 1, y: 0 },
};
const ActionList = memo(({ actions }: ActionListProps) => {
return (
<motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }} transition={{ duration: 0.15 }}>
<ul className="list-none space-y-2.5">
{actions.map((action, index) => {
const { status, type, content } = action;
const isLast = index === actions.length - 1;
return (
<motion.li
key={index}
variants={actionVariants}
initial="hidden"
animate="visible"
transition={{
duration: 0.2,
ease: cubicEasingFn,
}}
>
<div className="flex items-center gap-1.5 text-sm">
<div className={classNames('text-lg', getIconColor(action.status))}>
{status === 'running' ? (
<div className="i-svg-spinners:90-ring-with-bg"></div>
) : status === 'pending' ? (
<div className="i-ph:circle-duotone"></div>
) : status === 'complete' ? (
<div className="i-ph:check"></div>
) : status === 'failed' || status === 'aborted' ? (
<div className="i-ph:x"></div>
) : null}
</div>
{type === 'file' ? (
<div>
Create{' '}
<code className="bg-bolt-elements-artifacts-inlineCode-background text-bolt-elements-artifacts-inlineCode-text px-1.5 py-1 rounded-md">
{action.filePath}
</code>
</div>
) : type === 'shell' ? (
<div className="flex items-center w-full min-h-[28px]">
<span className="flex-1">Run command</span>
</div>
) : null}
</div>
{type === 'shell' && (
<ShellCodeBlock
classsName={classNames('mt-1', {
'mb-3.5': !isLast,
})}
code={content}
/>
)}
</motion.li>
);
})}
</ul>
</motion.div>
);
});
function getIconColor(status: ActionState['status']) {
switch (status) {
case 'pending': {
return 'text-bolt-elements-textTertiary';
}
case 'running': {
return 'text-bolt-elements-loader-progress';
}
case 'complete': {
return 'text-bolt-elements-icon-success';
}
case 'aborted': {
return 'text-bolt-elements-textSecondary';
}
case 'failed': {
return 'text-bolt-elements-icon-error';
}
default: {
return undefined;
}
}
}
================================================
FILE: app/components/chat/AssistantMessage.tsx
================================================
import { memo } from 'react';
import { Markdown } from './Markdown';
interface AssistantMessageProps {
content: string;
}
export const AssistantMessage = memo(({ content }: AssistantMessageProps) => {
return (
<div className="overflow-hidden w-full">
<Markdown html>{content}</Markdown>
</div>
);
});
================================================
FILE: app/components/chat/BaseChat.module.scss
================================================
.BaseChat {
&[data-chat-visible='false'] {
--workbench-inner-width: 100%;
--workbench-left: 0;
.Chat {
--at-apply: bolt-ease-cubic-bezier;
transition-property: transform, opacity;
transition-duration: 0.3s;
will-change: transform, opacity;
transform: translateX(-50%);
opacity: 0;
}
}
}
.Chat {
opacity: 1;
}
================================================
FILE: app/components/chat/BaseChat.tsx
================================================
import type { Message } from 'ai';
import React, { type RefCallback } from 'react';
import { ClientOnly } from 'remix-utils/client-only';
import { Menu } from '~/components/sidebar/Menu.client';
import { IconButton } from '~/components/ui/IconButton';
import { Workbench } from '~/components/workbench/Workbench.client';
import { classNames } from '~/utils/classNames';
import { Messages } from './Messages.client';
import { SendButton } from './SendButton.client';
import styles from './BaseChat.module.scss';
interface BaseChatProps {
textareaRef?: React.RefObject<HTMLTextAreaElement> | undefined;
messageRef?: RefCallback<HTMLDivElement> | undefined;
scrollRef?: RefCallback<HTMLDivElement> | undefined;
showChat?: boolean;
chatStarted?: boolean;
isStreaming?: boolean;
messages?: Message[];
enhancingPrompt?: boolean;
promptEnhanced?: boolean;
input?: string;
handleStop?: () => void;
sendMessage?: (event: React.UIEvent, messageInput?: string) => void;
handleInputChange?: (event: React.ChangeEvent<HTMLTextAreaElement>) => void;
enhancePrompt?: () => void;
}
const EXAMPLE_PROMPTS = [
{ text: 'Build a todo app in React using Tailwind' },
{ text: 'Build a simple blog using Astro' },
{ text: 'Create a cookie consent form using Material UI' },
{ text: 'Make a space invaders game' },
{ text: 'How do I center a div?' },
];
const TEXTAREA_MIN_HEIGHT = 76;
export const BaseChat = React.forwardRef<HTMLDivElement, BaseChatProps>(
(
{
textareaRef,
messageRef,
scrollRef,
showChat = true,
chatStarted = false,
isStreaming = false,
enhancingPrompt = false,
promptEnhanced = false,
messages,
input = '',
sendMessage,
handleInputChange,
enhancePrompt,
handleStop,
},
ref,
) => {
const TEXTAREA_MAX_HEIGHT = chatStarted ? 400 : 200;
return (
<div
ref={ref}
className={classNames(
styles.BaseChat,
'relative flex h-full w-full overflow-hidden bg-bolt-elements-background-depth-1',
)}
data-chat-visible={showChat}
>
<ClientOnly>{() => <Menu />}</ClientOnly>
<div ref={scrollRef} className="flex overflow-y-auto w-full h-full">
<div className={classNames(styles.Chat, 'flex flex-col flex-grow min-w-[var(--chat-min-width)] h-full')}>
{!chatStarted && (
<div id="intro" className="mt-[26vh] max-w-chat mx-auto">
<h1 className="text-5xl text-center font-bold text-bolt-elements-textPrimary mb-2">
Where ideas begin
</h1>
<p className="mb-4 text-center text-bolt-elements-textSecondary">
Bring ideas to life in seconds or get help on existing projects.
</p>
</div>
)}
<div
className={classNames('pt-6 px-6', {
'h-full flex flex-col': chatStarted,
})}
>
<ClientOnly>
{() => {
return chatStarted ? (
<Messages
ref={messageRef}
className="flex flex-col w-full flex-1 max-w-chat px-4 pb-6 mx-auto z-1"
messages={messages}
isStreaming={isStreaming}
/>
) : null;
}}
</ClientOnly>
<div
className={classNames('relative w-full max-w-chat mx-auto z-prompt', {
'sticky bottom-0': chatStarted,
})}
>
<div
className={classNames(
'shadow-sm border border-bolt-elements-borderColor bg-bolt-elements-prompt-background backdrop-filter backdrop-blur-[8px] rounded-lg overflow-hidden',
)}
>
<textarea
ref={textareaRef}
className={`w-full pl-4 pt-4 pr-16 focus:outline-none resize-none text-md text-bolt-elements-textPrimary placeholder-bolt-elements-textTertiary bg-transparent`}
onKeyDown={(event) => {
if (event.key === 'Enter') {
if (event.shiftKey) {
return;
}
event.preventDefault();
sendMessage?.(event);
}
}}
value={input}
onChange={(event) => {
handleInputChange?.(event);
}}
style={{
minHeight: TEXTAREA_MIN_HEIGHT,
maxHeight: TEXTAREA_MAX_HEIGHT,
}}
placeholder="How can Bolt help you today?"
translate="no"
/>
<ClientOnly>
{() => (
<SendButton
show={input.length > 0 || isStreaming}
isStreaming={isStreaming}
onClick={(event) => {
if (isStreaming) {
handleStop?.();
return;
}
sendMessage?.(event);
}}
/>
)}
</ClientOnly>
<div className="flex justify-between text-sm p-4 pt-2">
<div className="flex gap-1 items-center">
<IconButton
title="Enhance prompt"
disabled={input.length === 0 || enhancingPrompt}
className={classNames({
'opacity-100!': enhancingPrompt,
'text-bolt-elements-item-contentAccent! pr-1.5 enabled:hover:bg-bolt-elements-item-backgroundAccent!':
promptEnhanced,
})}
onClick={() => enhancePrompt?.()}
>
{enhancingPrompt ? (
<>
<div className="i-svg-spinners:90-ring-with-bg text-bolt-elements-loader-progress text-xl"></div>
<div className="ml-1.5">Enhancing prompt...</div>
</>
) : (
<>
<div className="i-bolt:stars text-xl"></div>
{promptEnhanced && <div className="ml-1.5">Prompt enhanced</div>}
</>
)}
</IconButton>
</div>
{input.length > 3 ? (
<div className="text-xs text-bolt-elements-textTertiary">
Use <kbd className="kdb">Shift</kbd> + <kbd className="kdb">Return</kbd> for a new line
</div>
) : null}
</div>
</div>
<div className="bg-bolt-elements-background-depth-1 pb-6">{/* Ghost Element */}</div>
</div>
</div>
{!chatStarted && (
<div id="examples" className="relative w-full max-w-xl mx-auto mt-8 flex justify-center">
<div className="flex flex-col space-y-2 [mask-image:linear-gradient(to_bottom,black_0%,transparent_180%)] hover:[mask-image:none]">
{EXAMPLE_PROMPTS.map((examplePrompt, index) => {
return (
<button
key={index}
onClick={(event) => {
sendMessage?.(event, examplePrompt.text);
}}
className="group flex items-center w-full gap-2 justify-center bg-transparent text-bolt-elements-textTertiary hover:text-bolt-elements-textPrimary transition-theme"
>
{examplePrompt.text}
<div className="i-ph:arrow-bend-down-left" />
</button>
);
})}
</div>
</div>
)}
</div>
<ClientOnly>{() => <Workbench chatStarted={chatStarted} isStreaming={isStreaming} />}</ClientOnly>
</div>
</div>
);
},
);
================================================
FILE: app/components/chat/Chat.client.tsx
================================================
import { useStore } from '@nanostores/react';
import type { Message } from 'ai';
import { useChat } from 'ai/react';
import { useAnimate } from 'framer-motion';
import { memo, useEffect, useRef, useState } from 'react';
import { cssTransition, toast, ToastContainer } from 'react-toastify';
import { useMessageParser, usePromptEnhancer, useShortcuts, useSnapScroll } from '~/lib/hooks';
import { useChatHistory } from '~/lib/persistence';
import { chatStore } from '~/lib/stores/chat';
import { workbenchStore } from '~/lib/stores/workbench';
import { fileModificationsToHTML } from '~/utils/diff';
import { cubicEasingFn } from '~/utils/easings';
import { createScopedLogger, renderLogger } from '~/utils/logger';
import { BaseChat } from './BaseChat';
const toastAnimation = cssTransition({
enter: 'animated fadeInRight',
exit: 'animated fadeOutRight',
});
const logger = createScopedLogger('Chat');
export function Chat() {
renderLogger.trace('Chat');
const { ready, initialMessages, storeMessageHistory } = useChatHistory();
return (
<>
{ready && <ChatImpl initialMessages={initialMessages} storeMessageHistory={storeMessageHistory} />}
<ToastContainer
closeButton={({ closeToast }) => {
return (
<button className="Toastify__close-button" onClick={closeToast}>
<div className="i-ph:x text-lg" />
</button>
);
}}
icon={({ type }) => {
/**
* @todo Handle more types if we need them. This may require extra color palettes.
*/
switch (type) {
case 'success': {
return <div className="i-ph:check-bold text-bolt-elements-icon-success text-2xl" />;
}
case 'error': {
return <div className="i-ph:warning-circle-bold text-bolt-elements-icon-error text-2xl" />;
}
}
return undefined;
}}
position="bottom-right"
pauseOnFocusLoss
transition={toastAnimation}
/>
</>
);
}
interface ChatProps {
initialMessages: Message[];
storeMessageHistory: (messages: Message[]) => Promise<void>;
}
export const ChatImpl = memo(({ initialMessages, storeMessageHistory }: ChatProps) => {
useShortcuts();
const textareaRef = useRef<HTMLTextAreaElement>(null);
const [chatStarted, setChatStarted] = useState(initialMessages.length > 0);
const { showChat } = useStore(chatStore);
const [animationScope, animate] = useAnimate();
const { messages, isLoading, input, handleInputChange, setInput, stop, append } = useChat({
api: '/api/chat',
onError: (error) => {
logger.error('Request failed\n\n', error);
toast.error('There was an error processing your request');
},
onFinish: () => {
logger.debug('Finished streaming');
},
initialMessages,
});
const { enhancingPrompt, promptEnhanced, enhancePrompt, resetEnhancer } = usePromptEnhancer();
const { parsedMessages, parseMessages } = useMessageParser();
const TEXTAREA_MAX_HEIGHT = chatStarted ? 400 : 200;
useEffect(() => {
chatStore.setKey('started', initialMessages.length > 0);
}, []);
useEffect(() => {
parseMessages(messages, isLoading);
if (messages.length > initialMessages.length) {
storeMessageHistory(messages).catch((error) => toast.error(error.message));
}
}, [messages, isLoading, parseMessages]);
const scrollTextArea = () => {
const textarea = textareaRef.current;
if (textarea) {
textarea.scrollTop = textarea.scrollHeight;
}
};
const abort = () => {
stop();
chatStore.setKey('aborted', true);
workbenchStore.abortAllActions();
};
useEffect(() => {
const textarea = textareaRef.current;
if (textarea) {
textarea.style.height = 'auto';
const scrollHeight = textarea.scrollHeight;
textarea.style.height = `${Math.min(scrollHeight, TEXTAREA_MAX_HEIGHT)}px`;
textarea.style.overflowY = scrollHeight > TEXTAREA_MAX_HEIGHT ? 'auto' : 'hidden';
}
}, [input, textareaRef]);
const runAnimation = async () => {
if (chatStarted) {
return;
}
await Promise.all([
animate('#examples', { opacity: 0, display: 'none' }, { duration: 0.1 }),
animate('#intro', { opacity: 0, flex: 1 }, { duration: 0.2, ease: cubicEasingFn }),
]);
chatStore.setKey('started', true);
setChatStarted(true);
};
const sendMessage = async (_event: React.UIEvent, messageInput?: string) => {
const _input = messageInput || input;
if (_input.length === 0 || isLoading) {
return;
}
/**
* @note (delm) Usually saving files shouldn't take long but it may take longer if there
* many unsaved files. In that case we need to block user input and show an indicator
* of some kind so the user is aware that something is happening. But I consider the
* happy case to be no unsaved files and I would expect users to save their changes
* before they send another message.
*/
await workbenchStore.saveAllFiles();
const fileModifications = workbenchStore.getFileModifcations();
chatStore.setKey('aborted', false);
runAnimation();
if (fileModifications !== undefined) {
const diff = fileModificationsToHTML(fileModifications);
/**
* If we have file modifications we append a new user message manually since we have to prefix
* the user input with the file modifications and we don't want the new user input to appear
* in the prompt. Using `append` is almost the same as `handleSubmit` except that we have to
* manually reset the input and we'd have to manually pass in file attachments. However, those
* aren't relevant here.
*/
append({ role: 'user', content: `${diff}\n\n${_input}` });
/**
* After sending a new message we reset all modifications since the model
* should now be aware of all the changes.
*/
workbenchStore.resetAllFileModifications();
} else {
append({ role: 'user', content: _input });
}
setInput('');
resetEnhancer();
textareaRef.current?.blur();
};
const [messageRef, scrollRef] = useSnapScroll();
return (
<BaseChat
ref={animationScope}
textareaRef={textareaRef}
input={input}
showChat={showChat}
chatStarted={chatStarted}
isStreaming={isLoading}
enhancingPrompt={enhancingPrompt}
promptEnhanced={promptEnhanced}
sendMessage={sendMessage}
messageRef={messageRef}
scrollRef={scrollRef}
handleInputChange={handleInputChange}
handleStop={abort}
messages={messages.map((message, i) => {
if (message.role === 'user') {
return message;
}
return {
...message,
content: parsedMessages[i] || '',
};
})}
enhancePrompt={() => {
enhancePrompt(input, (input) => {
setInput(input);
scrollTextArea();
});
}}
/>
);
});
================================================
FILE: app/components/chat/CodeBlock.module.scss
================================================
.CopyButtonContainer {
button:before {
content: 'Copied';
font-size: 12px;
position: absolute;
left: -53px;
padding: 2px 6px;
height: 30px;
}
}
================================================
FILE: app/components/chat/CodeBlock.tsx
================================================
import { memo, useEffect, useState } from 'react';
import { bundledLanguages, codeToHtml, isSpecialLang, type BundledLanguage, type SpecialLanguage } from 'shiki';
import { classNames } from '~/utils/classNames';
import { createScopedLogger } from '~/utils/logger';
import styles from './CodeBlock.module.scss';
const logger = createScopedLogger('CodeBlock');
interface CodeBlockProps {
className?: string;
code: string;
language?: BundledLanguage | SpecialLanguage;
theme?: 'light-plus' | 'dark-plus';
disableCopy?: boolean;
}
export const CodeBlock = memo(
({ className, code, language = 'plaintext', theme = 'dark-plus', disableCopy = false }: CodeBlockProps) => {
const [html, setHTML] = useState<string | undefined>(undefined);
const [copied, setCopied] = useState(false);
const copyToClipboard = () => {
if (copied) {
return;
}
navigator.clipboard.writeText(code);
setCopied(true);
setTimeout(() => {
setCopied(false);
}, 2000);
};
useEffect(() => {
if (language && !isSpecialLang(language) && !(language in bundledLanguages)) {
logger.warn(`Unsupported language '${language}'`);
}
logger.trace(`Language = ${language}`);
const processCode = async () => {
setHTML(await codeToHtml(code, { lang: language, theme }));
};
processCode();
}, [code]);
return (
<div className={classNames('relative group text-left', className)}>
<div
className={classNames(
styles.CopyButtonContainer,
'bg-white absolute top-[10px] right-[10px] rounded-md z-10 text-lg flex items-center justify-center opacity-0 group-hover:opacity-100',
{
'rounded-l-0 opacity-100': copied,
},
)}
>
{!disableCopy && (
<button
className={classNames(
'flex items-center bg-transparent p-[6px] justify-center before:bg-white before:rounded-l-md before:text-gray-500 before:border-r before:border-gray-300',
{
'before:opacity-0': !copied,
'before:opacity-100': copied,
},
)}
title="Copy Code"
onClick={() => copyToClipboard()}
>
<div className="i-ph:clipboard-text-duotone"></div>
</button>
)}
</div>
<div dangerouslySetInnerHTML={{ __html: html ?? '' }}></div>
</div>
);
},
);
================================================
FILE: app/components/chat/Markdown.module.scss
================================================
$font-mono: ui-monospace, 'Fira Code', Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace;
$code-font-size: 13px;
@mixin not-inside-actions {
&:not(:has(:global(.actions)), :global(.actions *)) {
@content;
}
}
.MarkdownContent {
line-height: 1.6;
color: var(--bolt-elements-textPrimary);
> *:not(:last-child) {
margin-block-end: 16px;
}
:global(.artifact) {
margin: 1.5em 0;
}
:is(h1, h2, h3, h4, h5, h6) {
@include not-inside-actions {
margin-block-start: 24px;
margin-block-end: 16px;
font-weight: 600;
line-height: 1.25;
color: var(--bolt-elements-textPrimary);
}
}
h1 {
font-size: 2em;
border-bottom: 1px solid var(--bolt-elements-borderColor);
padding-bottom: 0.3em;
}
h2 {
font-size: 1.5em;
border-bottom: 1px solid var(--bolt-elements-borderColor);
padding-bottom: 0.3em;
}
h3 {
font-size: 1.25em;
}
h4 {
font-size: 1em;
}
h5 {
font-size: 0.875em;
}
h6 {
font-size: 0.85em;
color: #6a737d;
}
p {
white-space: pre-wrap;
&:not(:last-of-type) {
margin-block-start: 0;
margin-block-end: 16px;
}
}
a {
color: var(--bolt-elements-messages-linkColor);
text-decoration: none;
cursor: pointer;
&:hover {
text-decoration: underline;
}
}
:not(pre) > code {
font-family: $font-mono;
font-size: $code-font-size;
@include not-inside-actions {
border-radius: 6px;
padding: 0.2em 0.4em;
background-color: var(--bolt-elements-messages-inlineCode-background);
color: var(--bolt-elements-messages-inlineCode-text);
}
}
pre {
padding: 20px 16px;
border-radius: 6px;
}
pre:has(> code) {
font-family: $font-mono;
font-size: $code-font-size;
background: transparent;
overflow-x: auto;
min-width: 0;
}
blockquote {
margin: 0;
padding: 0 1em;
color: var(--bolt-elements-textTertiary);
border-left: 0.25em solid var(--bolt-elements-borderColor);
}
:is(ul, ol) {
@include not-inside-actions {
padding-left: 2em;
margin-block-start: 0;
margin-block-end: 16px;
}
}
ul {
@include not-inside-actions {
list-style-type: disc;
}
}
ol {
@include not-inside-actions {
list-style-type: decimal;
}
}
li {
@include not-inside-actions {
& + li {
margin-block-start: 8px;
}
> *:not(:last-child) {
margin-block-end: 16px;
}
}
}
img {
max-width: 100%;
box-sizing: border-box;
}
hr {
height: 0.25em;
padding: 0;
margin: 24px 0;
background-color: var(--bolt-elements-borderColor);
border: 0;
}
table {
border-collapse: collapse;
width: 100%;
margin-block-end: 16px;
:is(th, td) {
padding: 6px 13px;
border: 1px solid #dfe2e5;
}
tr:nth-child(2n) {
background-color: #f6f8fa;
}
}
}
================================================
FILE: app/components/chat/Markdown.tsx
================================================
import { memo, useMemo } from 'react';
import ReactMarkdown, { type Components } from 'react-markdown';
import type { BundledLanguage } from 'shiki';
import { createScopedLogger } from '~/utils/logger';
import { rehypePlugins, remarkPlugins, allowedHTMLElements } from '~/utils/markdown';
import { Artifact } from './Artifact';
import { CodeBlock } from './CodeBlock';
import styles from './Markdown.module.scss';
const logger = createScopedLogger('MarkdownComponent');
interface MarkdownProps {
children: string;
html?: boolean;
limitedMarkdown?: boolean;
}
export const Markdown = memo(({ children, html = false, limitedMarkdown = false }: MarkdownProps) => {
logger.trace('Render');
const components = useMemo(() => {
return {
div: ({ className, children, node, ...props }) => {
if (className?.includes('__boltArtifact__')) {
const messageId = node?.properties.dataMessageId as string;
if (!messageId) {
logger.error(`Invalid message id ${messageId}`);
}
return <Artifact messageId={messageId} />;
}
return (
<div className={className} {...props}>
{children}
</div>
);
},
pre: (props) => {
const { children, node, ...rest } = props;
const [firstChild] = node?.children ?? [];
if (
firstChild &&
firstChild.type === 'element' &&
firstChild.tagName === 'code' &&
firstChild.children[0].type === 'text'
) {
const { className, ...rest } = firstChild.properties;
const [, language = 'plaintext'] = /language-(\w+)/.exec(String(className) || '') ?? [];
return <CodeBlock code={firstChild.children[0].value} language={language as BundledLanguage} {...rest} />;
}
return <pre {...rest}>{children}</pre>;
},
} satisfies Components;
}, []);
return (
<ReactMarkdown
allowedElements={allowedHTMLElements}
className={styles.MarkdownContent}
components={components}
remarkPlugins={remarkPlugins(limitedMarkdown)}
rehypePlugins={rehypePlugins(html)}
>
{children}
</ReactMarkdown>
);
});
================================================
FILE: app/components/chat/Messages.client.tsx
================================================
import type { Message } from 'ai';
import React from 'react';
import { classNames } from '~/utils/classNames';
import { AssistantMessage } from './AssistantMessage';
import { UserMessage } from './UserMessage';
interface MessagesProps {
id?: string;
className?: string;
isStreaming?: boolean;
messages?: Message[];
}
export const Messages = React.forwardRef<HTMLDivElement, MessagesProps>((props: MessagesProps, ref) => {
const { id, isStreaming = false, messages = [] } = props;
return (
<div id={id} ref={ref} className={props.className}>
{messages.length > 0
? messages.map((message, index) => {
const { role, content } = message;
const isUserMessage = role === 'user';
const isFirst = index === 0;
const isLast = index === messages.length - 1;
return (
<div
key={index}
className={classNames('flex gap-4 p-6 w-full rounded-[calc(0.75rem-1px)]', {
'bg-bolt-elements-messages-background': isUserMessage || !isStreaming || (isStreaming && !isLast),
'bg-gradient-to-b from-bolt-elements-messages-background from-30% to-transparent':
isStreaming && isLast,
'mt-4': !isFirst,
})}
>
{isUserMessage && (
<div className="flex items-center justify-center w-[34px] h-[34px] overflow-hidden bg-white text-gray-600 rounded-full shrink-0 self-start">
<div className="i-ph:user-fill text-xl"></div>
</div>
)}
<div className="grid grid-col-1 w-full">
{isUserMessage ? <UserMessage content={content} /> : <AssistantMessage content={content} />}
</div>
</div>
);
})
: null}
{isStreaming && (
<div className="text-center w-full text-bolt-elements-textSecondary i-svg-spinners:3-dots-fade text-4xl mt-4"></div>
)}
</div>
);
});
================================================
FILE: app/components/chat/SendButton.client.tsx
================================================
import { AnimatePresence, cubicBezier, motion } from 'framer-motion';
interface SendButtonProps {
show: boolean;
isStreaming?: boolean;
onClick?: (event: React.MouseEvent<HTMLButtonElement, MouseEvent>) => void;
}
const customEasingFn = cubicBezier(0.4, 0, 0.2, 1);
export function SendButton({ show, isStreaming, onClick }: SendButtonProps) {
return (
<AnimatePresence>
{show ? (
<motion.button
className="absolute flex justify-center items-center top-[18px] right-[22px] p-1 bg-accent-500 hover:brightness-94 color-white rounded-md w-[34px] h-[34px] transition-theme"
transition={{ ease: customEasingFn, duration: 0.17 }}
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: 10 }}
onClick={(event) => {
event.preventDefault();
onClick?.(event);
}}
>
<div className="text-lg">
{!isStreaming ? <div className="i-ph:arrow-right"></div> : <div className="i-ph:stop-circle-bold"></div>}
</div>
</motion.button>
) : null}
</AnimatePresence>
);
}
================================================
FILE: app/components/chat/UserMessage.tsx
================================================
import { modificationsRegex } from '~/utils/diff';
import { Markdown } from './Markdown';
interface UserMessageProps {
content: string;
}
export function UserMessage({ content }: UserMessageProps) {
return (
<div className="overflow-hidden pt-[4px]">
<Markdown limitedMarkdown>{sanitizeUserMessage(content)}</Markdown>
</div>
);
}
function sanitizeUserMessage(content: string) {
return content.replace(modificationsRegex, '').trim();
}
================================================
FILE: app/components/editor/codemirror/BinaryContent.tsx
================================================
export function BinaryContent() {
return (
<div className="flex items-center justify-center absolute inset-0 z-10 text-sm bg-tk-elements-app-backgroundColor text-tk-elements-app-textColor">
File format cannot be displayed.
</div>
);
}
================================================
FILE: app/components/editor/codemirror/CodeMirrorEditor.tsx
================================================
import { acceptCompletion, autocompletion, closeBrackets } from '@codemirror/autocomplete';
import { defaultKeymap, history, historyKeymap } from '@codemirror/commands';
import { bracketMatching, foldGutter, indentOnInput, indentUnit } from '@codemirror/language';
import { searchKeymap } from '@codemirror/search';
import { Compartment, EditorSelection, EditorState, StateEffect, StateField, type Extension } from '@codemirror/state';
import {
drawSelection,
dropCursor,
EditorView,
highlightActiveLine,
highlightActiveLineGutter,
keymap,
lineNumbers,
scrollPastEnd,
showTooltip,
tooltips,
type Tooltip,
} from '@codemirror/view';
import { memo, useEffect, useRef, useState, type MutableRefObject } from 'react';
import type { Theme } from '~/types/theme';
import { classNames } from '~/utils/classNames';
import { debounce } from '~/utils/debounce';
import { createScopedLogger, renderLogger } from '~/utils/logger';
import { BinaryContent } from './BinaryContent';
import { getTheme, reconfigureTheme } from './cm-theme';
import { indentKeyBinding } from './indent';
import { getLanguage } from './languages';
const logger = createScopedLogger('CodeMirrorEditor');
export interface EditorDocument {
value: string;
isBinary: boolean;
filePath: string;
scroll?: ScrollPosition;
}
export interface EditorSettings {
fontSize?: string;
gutterFontSize?: string;
tabSize?: number;
}
type TextEditorDocument = EditorDocument & {
value: string;
};
export interface ScrollPosition {
top: number;
left: number;
}
export interface EditorUpdate {
selection: EditorSelection;
content: string;
}
export type OnChangeCallback = (update: EditorUpdate) => void;
export type OnScrollCallback = (position: ScrollPosition) => void;
export type OnSaveCallback = () => void;
interface Props {
theme: Theme;
id?: unknown;
doc?: EditorDocument;
editable?: boolean;
debounceChange?: number;
debounceScroll?: number;
autoFocusOnDocumentChange?: boolean;
onChange?: OnChangeCallback;
onScroll?: OnScrollCallback;
onSave?: OnSaveCallback;
className?: string;
settings?: EditorSettings;
}
type EditorStates = Map<string, EditorState>;
const readOnlyTooltipStateEffect = StateEffect.define<boolean>();
const editableTooltipField = StateField.define<readonly Tooltip[]>({
create: () => [],
update(_tooltips, transaction) {
if (!transaction.state.readOnly) {
return [];
}
for (const effect of transaction.effects) {
if (effect.is(readOnlyTooltipStateEffect) && effect.value) {
return getReadOnlyTooltip(transaction.state);
}
}
return [];
},
provide: (field) => {
return showTooltip.computeN([field], (state) => state.field(field));
},
});
const editableStateEffect = StateEffect.define<boolean>();
const editableStateField = StateField.define<boolean>({
create() {
return true;
},
update(value, transaction) {
for (const effect of transaction.effects) {
if (effect.is(editableStateEffect)) {
return effect.value;
}
}
return value;
},
});
export const CodeMirrorEditor = memo(
({
id,
doc,
debounceScroll = 100,
debounceChange = 150,
autoFocusOnDocumentChange = false,
editable = true,
onScroll,
onChange,
onSave,
theme,
settings,
className = '',
}: Props) => {
renderLogger.trace('CodeMirrorEditor');
const [languageCompartment] = useState(new Compartment());
const containerRef = useRef<HTMLDivElement | null>(null);
const viewRef = useRef<EditorView>();
const themeRef = useRef<Theme>();
const docRef = useRef<EditorDocument>();
const editorStatesRef = useRef<EditorStates>();
const onScrollRef = useRef(onScroll);
const onChangeRef = useRef(onChange);
const onSaveRef = useRef(onSave);
/**
* This effect is used to avoid side effects directly in the render function
* and instead the refs are updated after each render.
*/
useEffect(() => {
onScrollRef.current = onScroll;
onChangeRef.current = onChange;
onSaveRef.current = onSave;
docRef.current = doc;
themeRef.current = theme;
});
useEffect(() => {
const onUpdate = debounce((update: EditorUpdate) => {
onChangeRef.current?.(update);
}, debounceChange);
const view = new EditorView({
parent: containerRef.current!,
dispatchTransactions(transactions) {
const previousSelection = view.state.selection;
view.update(transactions);
const newSelection = view.state.selection;
const selectionChanged =
newSelection !== previousSelection &&
(newSelection === undefined || previousSelection === undefined || !newSelection.eq(previousSelection));
if (docRef.current && (transactions.some((transaction) => transaction.docChanged) || selectionChanged)) {
onUpdate({
selection: view.state.selection,
content: view.state.doc.toString(),
});
editorStatesRef.current!.set(docRef.current.filePath, view.state);
}
},
});
viewRef.current = view;
return () => {
viewRef.current?.destroy();
viewRef.current = undefined;
};
}, []);
useEffect(() => {
if (!viewRef.current) {
return;
}
viewRef.current.dispatch({
effects: [reconfigureTheme(theme)],
});
}, [theme]);
useEffect(() => {
editorStatesRef.current = new Map<string, EditorState>();
}, [id]);
useEffect(() => {
const editorStates = editorStatesRef.current!;
const view = viewRef.current!;
const theme = themeRef.current!;
if (!doc) {
const state = newEditorState('', theme, settings, onScrollRef, debounceScroll, onSaveRef, [
languageCompartment.of([]),
]);
view.setState(state);
setNoDocument(view);
return;
}
if (doc.isBinary) {
return;
}
if (doc.filePath === '') {
logger.warn('File path should not be empty');
}
let state = editorStates.get(doc.filePath);
if (!state) {
state = newEditorState(doc.value, theme, settings, onScrollRef, debounceScroll, onSaveRef, [
languageCompartment.of([]),
]);
editorStates.set(doc.filePath, state);
}
view.setState(state);
setEditorDocument(
view,
theme,
editable,
languageCompartment,
autoFocusOnDocumentChange,
doc as TextEditorDocument,
);
}, [doc?.value, editable, doc?.filePath, autoFocusOnDocumentChange]);
return (
<div className={classNames('relative h-full', className)}>
{doc?.isBinary && <BinaryContent />}
<div className="h-full overflow-hidden" ref={containerRef} />
</div>
);
},
);
export default CodeMirrorEditor;
CodeMirrorEditor.displayName = 'CodeMirrorEditor';
function newEditorState(
content: string,
theme: Theme,
settings: EditorSettings | undefined,
onScrollRef: MutableRefObject<OnScrollCallback | undefined>,
debounceScroll: number,
onFileSaveRef: MutableRefObject<OnSaveCallback | undefined>,
extensions: Extension[],
) {
return EditorState.create({
doc: content,
extensions: [
EditorView.domEventHandlers({
scroll: debounce((event, view) => {
if (event.target !== view.scrollDOM) {
return;
}
onScrollRef.current?.({ left: view.scrollDOM.scrollLeft, top: view.scrollDOM.scrollTop });
}, debounceScroll),
keydown: (event, view) => {
if (view.state.readOnly) {
view.dispatch({
effects: [readOnlyTooltipStateEffect.of(event.key !== 'Escape')],
});
return true;
}
return false;
},
}),
getTheme(theme, settings),
history(),
keymap.of([
...defaultKeymap,
...historyKeymap,
...searchKeymap,
{ key: 'Tab', run: acceptCompletion },
{
key: 'Mod-s',
preventDefault: true,
run: () => {
onFileSaveRef.current?.();
return true;
},
},
indentKeyBinding,
]),
indentUnit.of('\t'),
autocompletion({
closeOnBlur: false,
}),
tooltips({
position: 'absolute',
parent: document.body,
tooltipSpace: (view) => {
const rect = view.dom.getBoundingClientRect();
return {
top: rect.top - 50,
left: rect.left,
bottom: rect.bottom,
right: rect.right + 10,
};
},
}),
closeBrackets(),
lineNumbers(),
scrollPastEnd(),
dropCursor(),
drawSelection(),
bracketMatching(),
EditorState.tabSize.of(settings?.tabSize ?? 2),
indentOnInput(),
editableTooltipField,
editableStateField,
EditorState.readOnly.from(editableStateField, (editable) => !editable),
highlightActiveLineGutter(),
highlightActiveLine(),
foldGutter({
markerDOM: (open) => {
const icon = document.createElement('div');
icon.className = `fold-icon ${open ? 'i-ph-caret-down-bold' : 'i-ph-caret-right-bold'}`;
return icon;
},
}),
...extensions,
],
});
}
function setNoDocument(view: EditorView) {
view.dispatch({
selection: { anchor: 0 },
changes: {
from: 0,
to: view.state.doc.length,
insert: '',
},
});
view.scrollDOM.scrollTo(0, 0);
}
function setEditorDocument(
view: EditorView,
theme: Theme,
editable: boolean,
languageCompartment: Compartment,
autoFocus: boolean,
doc: TextEditorDocument,
) {
if (doc.value !== view.state.doc.toString()) {
view.dispatch({
selection: { anchor: 0 },
changes: {
from: 0,
to: view.state.doc.length,
insert: doc.value,
},
});
}
view.dispatch({
effects: [editableStateEffect.of(editable && !doc.isBinary)],
});
getLanguage(doc.filePath).then((languageSupport) => {
if (!languageSupport) {
return;
}
view.dispatch({
effects: [languageCompartment.reconfigure([languageSupport]), reconfigureTheme(theme)],
});
requestAnimationFrame(() => {
const currentLeft = view.scrollDOM.scrollLeft;
const currentTop = view.scrollDOM.scrollTop;
const newLeft = doc.scroll?.left ?? 0;
const newTop = doc.scroll?.top ?? 0;
const needsScrolling = currentLeft !== newLeft || currentTop !== newTop;
if (autoFocus && editable) {
if (needsScrolling) {
// we have to wait until the scroll position was changed before we can set the focus
view.scrollDOM.addEventListener(
'scroll',
() => {
view.focus();
},
{ once: true },
);
} else {
// if the scroll position is still the same we can focus immediately
view.focus();
}
}
view.scrollDOM.scrollTo(newLeft, newTop);
});
});
}
function getReadOnlyTooltip(state: EditorState) {
if (!state.readOnly) {
return [];
}
return state.selection.ranges
.filter((range) => {
return range.empty;
})
.map((range) => {
return {
pos: range.head,
above: true,
strictSide: true,
arrow: true,
create: () => {
const divElement = document.createElement('div');
divElement.className = 'cm-readonly-tooltip';
divElement.textContent = 'Cannot edit file while AI response is being generated';
return { dom: divElement };
},
};
});
}
================================================
FILE: app/components/editor/codemirror/cm-theme.ts
================================================
import { Compartment, type Extension } from '@codemirror/state';
import { EditorView } from '@codemirror/view';
import { vscodeDark, vscodeLight } from '@uiw/codemirror-theme-vscode';
import type { Theme } from '~/types/theme.js';
import type { EditorSettings } from './CodeMirrorEditor.js';
export const darkTheme = EditorView.theme({}, { dark: true });
export const themeSelection = new Compartment();
export function getTheme(theme: Theme, settings: EditorSettings = {}): Extension {
return [
getEditorTheme(settings),
theme === 'dark' ? themeSelection.of([getDarkTheme()]) : themeSelection.of([getLightTheme()]),
];
}
export function reconfigureTheme(theme: Theme) {
return themeSelection.reconfigure(theme === 'dark' ? getDarkTheme() : getLightTheme());
}
function getEditorTheme(settings: EditorSettings) {
return EditorView.theme({
'&': {
fontSize: settings.fontSize ?? '12px',
},
'&.cm-editor': {
height: '100%',
background: 'var(--cm-backgroundColor)',
color: 'var(--cm-textColor)',
},
'.cm-cursor': {
borderLeft: 'var(--cm-cursor-width) solid var(--cm-cursor-backgroundColor)',
},
'.cm-scroller': {
lineHeight: '1.5',
'&:focus-visible': {
outline: 'none',
},
},
'.cm-line': {
padding: '0 0 0 4px',
},
'&.cm-focused > .cm-scroller > .cm-selectionLayer .cm-selectionBackground': {
backgroundColor: 'var(--cm-selection-backgroundColorFocused) !important',
opacity: 'var(--cm-selection-backgroundOpacityFocused, 0.3)',
},
'&:not(.cm-focused) > .cm-scroller > .cm-selectionLayer .cm-selectionBackground': {
backgroundColor: 'var(--cm-selection-backgroundColorBlured)',
opacity: 'var(--cm-selection-backgroundOpacityBlured, 0.3)',
},
'&.cm-focused > .cm-scroller .cm-matchingBracket': {
backgroundColor: 'var(--cm-matching-bracket)',
},
'.cm-activeLine': {
background: 'var(--cm-activeLineBackgroundColor)',
},
'.cm-gutters': {
background: 'var(--cm-gutter-backgroundColor)',
borderRight: 0,
color: 'var(--cm-gutter-textColor)',
},
'.cm-gutter': {
'&.cm-lineNumbers': {
fontFamily: 'Roboto Mono, monospace',
fontSize: settings.gutterFontSize ?? settings.fontSize ?? '12px',
minWidth: '40px',
},
'& .cm-activeLineGutter': {
background: 'transparent',
color: 'var(--cm-gutter-activeLineTextColor)',
},
'&.cm-foldGutter .cm-gutterElement > .fold-icon': {
cursor: 'pointer',
color: 'var(--cm-foldGutter-textColor)',
transform: 'translateY(2px)',
'&:hover': {
color: 'var(--cm-foldGutter-textColorHover)',
},
},
},
'.cm-foldGutter .cm-gutterElement': {
padding: '0 4px',
},
'.cm-tooltip-autocomplete > ul > li': {
minHeight: '18px',
},
'.cm-panel.cm-search label': {
marginLeft: '2px',
fontSize: '12px',
},
'.cm-panel.cm-search .cm-button': {
fontSize: '12px',
},
'.cm-panel.cm-search .cm-textfield': {
fontSize: '12px',
},
'.cm-panel.cm-search input[type=checkbox]': {
position: 'relative',
transform: 'translateY(2px)',
marginRight: '4px',
},
'.cm-panels': {
borderColor: 'var(--cm-panels-borderColor)',
},
'.cm-panels-bottom': {
borderTop: '1px solid var(--cm-panels-borderColor)',
backgroundColor: 'transparent',
},
'.cm-panel.cm-search': {
background: 'var(--cm-search-backgroundColor)',
color: 'var(--cm-search-textColor)',
padding: '8px',
},
'.cm-search .cm-button': {
background: 'var(--cm-search-button-backgroundColor)',
borderColor: 'var(--cm-search-button-borderColor)',
color: 'var(--cm-search-button-textColor)',
borderRadius: '4px',
'&:hover': {
color: 'var(--cm-search-button-textColorHover)',
},
'&:focus-visible': {
outline: 'none',
borderColor: 'var(--cm-search-button-borderColorFocused)',
},
'&:hover:not(:focus-visible)': {
background: 'var(--cm-search-button-backgroundColorHover)',
borderColor: 'var(--cm-search-button-borderColorHover)',
},
'&:hover:focus-visible': {
background: 'var(--cm-search-button-backgroundColorHover)',
borderColor: 'var(--cm-search-button-borderColorFocused)',
},
},
'.cm-panel.cm-search [name=close]': {
top: '6px',
right: '6px',
padding: '0 6px',
fontSize: '1rem',
backgroundColor: 'var(--cm-search-closeButton-backgroundColor)',
color: 'var(--cm-search-closeButton-textColor)',
'&:hover': {
'border-radius': '6px',
color: 'var(--cm-search-closeButton-textColorHover)',
backgroundColor: 'var(--cm-search-closeButton-backgroundColorHover)',
},
},
'.cm-search input': {
background: 'var(--cm-search-input-backgroundColor)',
borderColor: 'var(--cm-search-input-borderColor)',
color: 'var(--cm-search-input-textColor)',
outline: 'none',
borderRadius: '4px',
'&:focus-visible': {
borderColor: 'var(--cm-search-input-borderColorFocused)',
},
},
'.cm-tooltip': {
background: 'var(--cm-tooltip-backgroundColor)',
border: '1px solid transparent',
borderColor: 'var(--cm-tooltip-borderColor)',
color: 'var(--cm-tooltip-textColor)',
},
'.cm-tooltip.cm-tooltip-autocomplete ul li[aria-selected]': {
background: 'var(--cm-tooltip-backgroundColorSelected)',
color: 'var(--cm-tooltip-textColorSelected)',
},
'.cm-searchMatch': {
backgroundColor: 'var(--cm-searchMatch-backgroundColor)',
},
'.cm-tooltip.cm-readonly-tooltip': {
padding: '4px',
whiteSpace: 'nowrap',
backgroundColor: 'var(--bolt-elements-bg-depth-2)',
borderColor: 'var(--bolt-elements-borderColorActive)',
'& .cm-tooltip-arrow:before': {
borderTopColor: 'var(--bolt-elements-borderColorActive)',
},
'& .cm-tooltip-arrow:after': {
borderTopColor: 'transparent',
},
},
});
}
function getLightTheme() {
return vscodeLight;
}
function getDarkTheme() {
return vscodeDark;
}
================================================
FILE: app/components/editor/codemirror/indent.ts
================================================
import { indentLess } from '@codemirror/commands';
import { indentUnit } from '@codemirror/language';
import { EditorSelection, EditorState, Line, type ChangeSpec } from '@codemirror/state';
import { EditorView, type KeyBinding } from '@codemirror/view';
export const indentKeyBinding: KeyBinding = {
key: 'Tab',
run: indentMore,
shift: indentLess,
};
function indentMore({ state, dispatch }: EditorView) {
if (state.readOnly) {
return false;
}
dispatch(
state.update(
changeBySelectedLine(state, (from, to, changes) => {
changes.push({ from, to, insert: state.facet(indentUnit) });
}),
{ userEvent: 'input.indent' },
),
);
return true;
}
function changeBySelectedLine(
state: EditorState,
cb: (from: number, to: number | undefined, changes: ChangeSpec[], line: Line) => void,
) {
return state.changeByRange((range) => {
const changes: ChangeSpec[] = [];
const line = state.doc.lineAt(range.from);
// just insert single indent unit at the current cursor position
if (range.from === range.to) {
cb(range.from, undefined, changes, line);
}
// handle the case when multiple characters are selected in a single line
else if (range.from < range.to && range.to <= line.to) {
cb(range.from, range.to, changes, line);
} else {
let atLine = -1;
// handle the case when selection spans multiple lines
for (let pos = range.from; pos <= range.to; ) {
const line = state.doc.lineAt(pos);
if (line.number > atLine && (range.empty || range.to > line.from)) {
cb(line.from, undefined, changes, line);
atLine = line.number;
}
pos = line.to + 1;
}
}
const changeSet = state.changes(changes);
return {
changes,
range: EditorSelection.range(changeSet.mapPos(range.anchor, 1), changeSet.mapPos(range.head, 1)),
};
});
}
================================================
FILE: app/components/editor/codemirror/languages.ts
================================================
import { LanguageDescription } from '@codemirror/language';
export const supportedLanguages = [
LanguageDescription.of({
name: 'TS',
extensions: ['ts'],
async load() {
return import('@codemirror/lang-javascript').then((module) => module.javascript({ typescript: true }));
},
}),
LanguageDescription.of({
name: 'JS',
extensions: ['js', 'mjs', 'cjs'],
async load() {
return import('@codemirror/lang-javascript').then((module) => module.javascript());
},
}),
LanguageDescription.of({
name: 'TSX',
extensions: ['tsx'],
async load() {
return import('@codemirror/lang-javascript').then((module) => module.javascript({ jsx: true, typescript: true }));
},
}),
LanguageDescription.of({
name: 'JSX',
extensions: ['jsx'],
async load() {
return import('@codemirror/lang-javascript').then((module) => module.javascript({ jsx: true }));
},
}),
LanguageDescription.of({
name: 'HTML',
extensions: ['html'],
async load() {
return import('@codemirror/lang-html').then((module) => module.html());
},
}),
LanguageDescription.of({
name: 'CSS',
extensions: ['css'],
async load() {
return import('@codemirror/lang-css').then((module) => module.css());
},
}),
LanguageDescription.of({
name: 'SASS',
extensions: ['sass'],
async load() {
return import('@codemirror/lang-sass').then((module) => module.sass({ indented: true }));
},
}),
LanguageDescription.of({
name: 'SCSS',
extensions: ['scss'],
async load() {
return import('@codemirror/lang-sass').then((module) => module.sass({ indented: false }));
},
}),
LanguageDescription.of({
name: 'JSON',
extensions: ['json'],
async load() {
return import('@codemirror/lang-json').then((module) => module.json());
},
}),
LanguageDescription.of({
name: 'Markdown',
extensions: ['md'],
async load() {
return import('@codemirror/lang-markdown').then((module) => module.markdown());
},
}),
LanguageDescription.of({
name: 'Wasm',
extensions: ['wat'],
async load() {
return import('@codemirror/lang-wast').then((module) => module.wast());
},
}),
LanguageDescription.of({
name: 'Python',
extensions: ['py'],
async load() {
return import('@codemirror/lang-python').then((module) => module.python());
},
}),
LanguageDescription.of({
name: 'C++',
extensions: ['cpp'],
async load() {
return import('@codemirror/lang-cpp').then((module) => module.cpp());
},
}),
];
export async function getLanguage(fileName: string) {
const languageDescription = LanguageDescription.matchFilename(supportedLanguages, fileName);
if (languageDescription) {
return await languageDescription.load();
}
return undefined;
}
================================================
FILE: app/components/header/Header.tsx
================================================
import { useStore } from '@nanostores/react';
import { ClientOnly } from 'remix-utils/client-only';
import { chatStore } from '~/lib/stores/chat';
import { classNames } from '~/utils/classNames';
import { HeaderActionButtons } from './HeaderActionButtons.client';
import { ChatDescription } from '~/lib/persistence/ChatDescription.client';
export function Header() {
const chat = useStore(chatStore);
return (
<header
className={classNames(
'flex items-center bg-bolt-elements-background-depth-1 p-5 border-b h-[var(--header-height)]',
{
'border-transparent': !chat.started,
'border-bolt-elements-borderColor': chat.started,
},
)}
>
<div className="flex items-center gap-2 z-logo text-bolt-elements-textPrimary cursor-pointer">
<div className="i-ph:sidebar-simple-duotone text-xl" />
<a href="/" className="text-2xl font-semibold text-accent flex items-center">
<span className="i-bolt:logo-text?mask w-[46px] inline-block" />
</a>
</div>
<span className="flex-1 px-4 truncate text-center text-bolt-elements-textPrimary">
<ClientOnly>{() => <ChatDescription />}</ClientOnly>
</span>
{chat.started && (
<ClientOnly>
{() => (
<div className="mr-1">
<HeaderActionButtons />
</div>
)}
</ClientOnly>
)}
</header>
);
}
================================================
FILE: app/components/header/HeaderActionButtons.client.tsx
================================================
import { useStore } from '@nanostores/react';
import { chatStore } from '~/lib/stores/chat';
import { workbenchStore } from '~/lib/stores/workbench';
import { classNames } from '~/utils/classNames';
interface HeaderActionButtonsProps {}
export function HeaderActionButtons({}: HeaderActionButtonsProps) {
const showWorkbench = useStore(workbenchStore.showWorkbench);
const { showChat } = useStore(chatStore);
const canHideChat = showWorkbench || !showChat;
return (
<div className="flex">
<div className="flex border border-bolt-elements-borderColor rounded-md overflow-hidden">
<Button
active={showChat}
disabled={!canHideChat}
onClick={() => {
if (canHideChat) {
chatStore.setKey('showChat', !showChat);
}
}}
>
<div className="i-bolt:chat text-sm" />
</Button>
<div className="w-[1px] bg-bolt-elements-borderColor" />
<Button
active={showWorkbench}
onClick={() => {
if (showWorkbench && !showChat) {
chatStore.setKey('showChat', true);
}
workbenchStore.showWorkbench.set(!showWorkbench);
}}
>
<div className="i-ph:code-bold" />
</Button>
</div>
</div>
);
}
interface ButtonProps {
active?: boolean;
disabled?: boolean;
children?: any;
onClick?: VoidFunction;
}
function Button({ active = false, disabled = false, children, onClick }: ButtonProps) {
return (
<button
className={classNames('flex items-center p-1.5', {
'bg-bolt-elements-item-backgroundDefault hover:bg-bolt-elements-item-backgroundActive text-bolt-elements-textTertiary hover:text-bolt-elements-textPrimary':
!active,
'bg-bolt-elements-item-backgroundAccent text-bolt-elements-item-contentAccent': active && !disabled,
'bg-bolt-elements-item-backgroundDefault text-alpha-gray-20 dark:text-alpha-white-20 cursor-not-allowed':
disabled,
})}
onClick={onClick}
>
{children}
</button>
);
}
================================================
FILE: app/components/sidebar/HistoryItem.tsx
================================================
import * as Dialog from '@radix-ui/react-dialog';
import { useEffect, useRef, useState } from 'react';
import { type ChatHistoryItem } from '~/lib/persistence';
interface HistoryItemProps {
item: ChatHistoryItem;
onDelete?: (event: React.UIEvent) => void;
}
export function HistoryItem({ item, onDelete }: HistoryItemProps) {
const [hovering, setHovering] = useState(false);
const hoverRef = useRef<HTMLDivElement>(null);
useEffect(() => {
let timeout: NodeJS.Timeout | undefined;
function mouseEnter() {
setHovering(true);
if (timeout) {
clearTimeout(timeout);
}
}
function mouseLeave() {
setHovering(false);
}
hoverRef.current?.addEventListener('mouseenter', mouseEnter);
hoverRef.current?.addEventListener('mouseleave', mouseLeave);
return () => {
hoverRef.current?.removeEventListener('mouseenter', mouseEnter);
hoverRef.current?.removeEventListener('mouseleave', mouseLeave);
};
}, []);
return (
<div
ref={hoverRef}
className="group rounded-md text-bolt-elements-textSecondary hover:text-bolt-elements-textPrimary hover:bg-bolt-elements-background-depth-3 overflow-hidden flex justify-between items-center px-2 py-1"
>
<a href={`/chat/${item.urlId}`} className="flex w-full relative truncate block">
{item.description}
<div className="absolute right-0 z-1 top-0 bottom-0 bg-gradient-to-l from-bolt-elements-background-depth-2 group-hover:from-bolt-elements-background-depth-3 to-transparent w-10 flex justify-end group-hover:w-15 group-hover:from-45%">
{hovering && (
<div className="flex items-center p-1 text-bolt-elements-textSecondary hover:text-bolt-elements-item-contentDanger">
<Dialog.Trigger asChild>
<button
className="i-ph:trash scale-110"
onClick={(event) => {
// we prevent the default so we don't trigger the anchor above
event.preventDefault();
onDelete?.(event);
}}
/>
</Dialog.Trigger>
</div>
)}
</div>
</a>
</div>
);
}
================================================
FILE: app/components/sidebar/Menu.client.tsx
================================================
import { motion, type Variants } from 'framer-motion';
import { useCallback, useEffect, useRef, useState } from 'react';
import { toast } from 'react-toastify';
import { Dialog, DialogButton, DialogDescription, DialogRoot, DialogTitle } from '~/components/ui/Dialog';
import { IconButton } from '~/components/ui/IconButton';
import { ThemeSwitch } from '~/components/ui/ThemeSwitch';
import { db, deleteById, getAll, chatId, type ChatHistoryItem } from '~/lib/persistence';
import { cubicEasingFn } from '~/utils/easings';
import { logger } from '~/utils/logger';
import { HistoryItem } from './HistoryItem';
import { binDates } from './date-binning';
const menuVariants = {
closed: {
opacity: 0,
visibility: 'hidden',
left: '-150px',
transition: {
duration: 0.2,
ease: cubicEasingFn,
},
},
open: {
opacity: 1,
visibility: 'initial',
left: 0,
transition: {
duration: 0.2,
ease: cubicEasingFn,
},
},
} satisfies Variants;
type DialogContent = { type: 'delete'; item: ChatHistoryItem } | null;
export function Menu() {
const menuRef = useRef<HTMLDivElement>(null);
const [list, setList] = useState<ChatHistoryItem[]>([]);
const [open, setOpen] = useState(false);
const [dialogContent, setDialogContent] = useState<DialogContent>(null);
const loadEntries = useCallback(() => {
if (db) {
getAll(db)
.then((list) => list.filter((item) => item.urlId && item.description))
.then(setList)
.catch((error) => toast.error(error.message));
}
}, []);
const deleteItem = useCallback((event: React.UIEvent, item: ChatHistoryItem) => {
event.preventDefault();
if (db) {
deleteById(db, item.id)
.then(() => {
loadEntries();
if (chatId.get() === item.id) {
// hard page navigation to clear the stores
window.location.pathname = '/';
}
})
.catch((error) => {
toast.error('Failed to delete conversation');
logger.error(error);
});
}
}, []);
const closeDialog = () => {
setDialogContent(null);
};
useEffect(() => {
if (open) {
loadEntries();
}
}, [open]);
useEffect(() => {
const enterThreshold = 40;
const exitThreshold = 40;
function onMouseMove(event: MouseEvent) {
if (event.pageX < enterThreshold) {
setOpen(true);
}
if (menuRef.current && event.clientX > menuRef.current.getBoundingClientRect().right + exitThreshold) {
setOpen(false);
}
}
window.addEventListener('mousemove', onMouseMove);
return () => {
window.removeEventListener('mousemove', onMouseMove);
};
}, []);
return (
<motion.div
ref={menuRef}
initial="closed"
animate={open ? 'open' : 'closed'}
variants={menuVariants}
className="flex flex-col side-menu fixed top-0 w-[350px] h-full bg-bolt-elements-background-depth-2 border-r rounded-r-3xl border-bolt-elements-borderColor z-sidebar shadow-xl shadow-bolt-elements-sidebar-dropdownShadow text-sm"
>
<div className="flex items-center h-[var(--header-height)]">{/* Placeholder */}</div>
<div className="flex-1 flex flex-col h-full w-full overflow-hidden">
<div className="p-4">
<a
href="/"
className="flex gap-2 items-center bg-bolt-elements-sidebar-buttonBackgroundDefault text-bolt-elements-sidebar-buttonText hover:bg-bolt-elements-sidebar-buttonBackgroundHover rounded-md p-2 transition-theme"
>
<span className="inline-block i-bolt:chat scale-110" />
Start new chat
</a>
</div>
<div className="text-bolt-elements-textPrimary font-medium pl-6 pr-5 my-2">Your Chats</div>
<div className="flex-1 overflow-scroll pl-4 pr-5 pb-5">
{list.length === 0 && <div className="pl-2 text-bolt-elements-textTertiary">No previous conversations</div>}
<DialogRoot open={dialogContent !== null}>
{binDates(list).map(({ category, items }) => (
<div key={category} className="mt-4 first:mt-0 space-y-1">
<div className="text-bolt-elements-textTertiary sticky top-0 z-1 bg-bolt-elements-background-depth-2 pl-2 pt-2 pb-1">
{category}
</div>
{items.map((item) => (
<HistoryItem key={item.id} item={item} onDelete={() => setDialogContent({ type: 'delete', item })} />
))}
</div>
))}
<Dialog onBackdrop={closeDialog} onClose={closeDialog}>
{dialogContent?.type === 'delete' && (
<>
<DialogTitle>Delete Chat?</DialogTitle>
<DialogDescription asChild>
<div>
<p>
You are about to delete <strong>{dialogContent.item.description}</strong>.
</p>
<p className="mt-1">Are you sure you want to delete this chat?</p>
</div>
</DialogDescription>
<div className="px-5 pb-4 bg-bolt-elements-background-depth-2 flex gap-2 justify-end">
<DialogButton type="secondary" onClick={closeDialog}>
Cancel
</DialogButton>
<DialogButton
type="danger"
onClick={(event) => {
deleteItem(event, dialogContent.item);
closeDialog();
}}
>
Delete
</DialogButton>
</div>
</>
)}
</Dialog>
</DialogRoot>
</div>
<div className="flex items-center border-t border-bolt-elements-borderColor p-4">
<ThemeSwitch className="ml-auto" />
</div>
</div>
</motion.div>
);
}
================================================
FILE: app/components/sidebar/date-binning.ts
================================================
import { format, isAfter, isThisWeek, isThisYear, isToday, isYesterday, subDays } from 'date-fns';
import type { ChatHistoryItem } from '~/lib/persistence';
type Bin = { category: string; items: ChatHistoryItem[] };
export function binDates(_list: ChatHistoryItem[]) {
const list = _list.toSorted((a, b) => Date.parse(b.timestamp) - Date.parse(a.timestamp));
const binLookup: Record<string, Bin> = {};
const bins: Array<Bin> = [];
list.forEach((item) => {
const category = dateCategory(new Date(item.timestamp));
if (!(category in binLookup)) {
const bin = {
category,
items: [item],
};
binLookup[category] = bin;
bins.push(bin);
} else {
binLookup[category].items.push(item);
}
});
return bins;
}
function dateCategory(date: Date) {
if (isToday(date)) {
return 'Today';
}
if (isYesterday(date)) {
return 'Yesterday';
}
if (isThisWeek(date)) {
// e.g., "Monday"
return format(date, 'eeee');
}
const thirtyDaysAgo = subDays(new Date(), 30);
if (isAfter(date, thirtyDaysAgo)) {
return 'Last 30 Days';
}
if (isThisYear(date)) {
// e.g., "July"
return format(date, 'MMMM');
}
// e.g., "July 2023"
return format(date, 'MMMM yyyy');
}
================================================
FILE: app/components/ui/Dialog.tsx
================================================
import * as RadixDialog from '@radix-ui/react-dialog';
import { motion, type Variants } from 'framer-motion';
import React, { memo, type ReactNode } from 'react';
import { classNames } from '~/utils/classNames';
import { cubicEasingFn } from '~/utils/easings';
import { IconButton } from './IconButton';
export { Close as DialogClose, Root as DialogRoot } from '@radix-ui/react-dialog';
const transition = {
duration: 0.15,
ease: cubicEasingFn,
};
export const dialogBackdropVariants = {
closed: {
opacity: 0,
transition,
},
open: {
opacity: 1,
transition,
},
} satisfies Variants;
export const dialogVariants = {
closed: {
x: '-50%',
y: '-40%',
scale: 0.96,
opacity: 0,
transition,
},
open: {
x: '-50%',
y: '-50%',
scale: 1,
opacity: 1,
transition,
},
} satisfies Variants;
interface DialogButtonProps {
type: 'primary' | 'secondary' | 'danger';
children: ReactNode;
onClick?: (event: React.UIEvent) => void;
}
export const DialogButton = memo(({ type, children, onClick }: DialogButtonProps) => {
return (
<button
className={classNames(
'inline-flex h-[35px] items-center justify-center rounded-lg px-4 text-sm leading-none focus:outline-none',
{
'bg-bolt-elements-button-primary-background text-bolt-elements-button-primary-text hover:bg-bolt-elements-button-primary-backgroundHover':
type === 'primary',
'bg-bolt-elements-button-secondary-background text-bolt-elements-button-secondary-text hover:bg-bolt-elements-button-secondary-backgroundHover':
type === 'secondary',
'bg-bolt-elements-button-danger-background text-bolt-elements-button-danger-text hover:bg-bolt-elements-button-danger-backgroundHover':
type === 'danger',
},
)}
onClick={onClick}
>
{children}
</button>
);
});
export const DialogTitle = memo(({ className, children, ...props }: RadixDialog.DialogTitleProps) => {
return (
<RadixDialog.Title
className={classNames(
'px-5 py-4 flex items-center justify-between border-b border-bolt-elements-borderColor text-lg font-semibold leading-6 text-bolt-elements-textPrimary',
className,
)}
{...props}
>
{children}
</RadixDialog.Title>
);
});
export const DialogDescription = memo(({ className, children, ...props }: RadixDialog.DialogDescriptionProps) => {
return (
<RadixDialog.Description
className={classNames('px-5 py-4 text-bolt-elements-textPrimary text-md', className)}
{...props}
>
{children}
</RadixDialog.Description>
);
});
interface DialogProps {
children: ReactNode | ReactNode[];
className?: string;
onBackdrop?: (event: React.UIEvent) => void;
onClose?: (event: React.UIEvent) => void;
}
export const Dialog = memo(({ className, children, onBackdrop, onClose }: DialogProps) => {
return (
<RadixDialog.Portal>
<RadixDialog.Overlay onClick={onBackdrop} asChild>
<motion.div
className="bg-black/50 fixed inset-0 z-max"
initial="closed"
animate="open"
exit="closed"
variants={dialogBackdropVariants}
/>
</RadixDialog.Overlay>
<RadixDialog.Content asChild>
<motion.div
className={classNames(
'fixed top-[50%] left-[50%] z-max max-h-[85vh] w-[90vw] max-w-[450px] translate-x-[-50%] translate-y-[-50%] border border-bolt-elements-borderColor rounded-lg bg-bolt-elements-background-depth-2 shadow-lg focus:outline-none overflow-hidden',
className,
)}
initial="closed"
animate="open"
exit="closed"
variants={dialogVariants}
>
{children}
<RadixDialog.Close asChild onClick={onClose}>
<IconButton icon="i-ph:x" className="absolute top-[10px] right-[10px]" />
</RadixDialog.Close>
</motion.div>
</RadixDialog.Content>
</RadixDialog.Portal>
);
});
================================================
FILE: app/components/ui/IconButton.tsx
================================================
import { memo } from 'react';
import { classNames } from '~/utils/classNames';
type IconSize = 'sm' | 'md' | 'lg' | 'xl' | 'xxl';
interface BaseIconButtonProps {
size?: IconSize;
className?: string;
iconClassName?: string;
disabledClassName?: string;
title?: string;
disabled?: boolean;
onClick?: (event: React.MouseEvent<HTMLButtonElement, MouseEvent>) => void;
}
type IconButtonWithoutChildrenProps = {
icon: string;
children?: undefined;
} & BaseIconButtonProps;
type IconButtonWithChildrenProps = {
icon?: undefined;
children: string | JSX.Element | JSX.Element[];
} & BaseIconButtonProps;
type IconButtonProps = IconButtonWithoutChildrenProps | IconButtonWithChildrenProps;
export const IconButton = memo(
({
icon,
size = 'xl',
className,
iconClassName,
disabledClassName,
disabled = false,
title,
onClick,
children,
}: IconButtonProps) => {
return (
<button
className={classNames(
'flex items-center text-bolt-elements-item-contentDefault bg-transparent enabled:hover:text-bolt-elements-item-contentActive rounded-md p-1 enabled:hover:bg-bolt-elements-item-backgroundActive disabled:cursor-not-allowed',
{
[classNames('opacity-30', disabledClassName)]: disabled,
},
className,
)}
title={title}
disabled={disabled}
onClick={(event) => {
if (disabled) {
return;
}
onClick?.(event);
}}
>
{children ? children : <div className={classNames(icon, getIconSize(size), iconClassName)}></div>}
</button>
);
},
);
function getIconSize(size: IconSize) {
if (size === 'sm') {
return 'text-sm';
} else if (size === 'md') {
return 'text-md';
} else if (size === 'lg') {
return 'text-lg';
} else if (size === 'xl') {
return 'text-xl';
} else {
return 'text-2xl';
}
}
================================================
FILE: app/components/ui/LoadingDots.tsx
================================================
import { memo, useEffect, useState } from 'react';
interface LoadingDotsProps {
text: string;
}
export const LoadingDots = memo(({ text }: LoadingDotsProps) => {
const [dotCount, setDotCount] = useState(0);
useEffect(() => {
const interval = setInterval(() => {
setDotCount((prevDotCount) => (prevDotCount + 1) % 4);
}, 500);
return () => clearInterval(interval);
}, []);
return (
<div className="flex justify-center items-center h-full">
<div className="relative">
<span>{text}</span>
<span className="absolute left-[calc(100%-12px)]">{'.'.repeat(dotCount)}</span>
<span className="invisible">...</span>
</div>
</div>
);
});
================================================
FILE: app/components/ui/PanelHeader.tsx
================================================
import { memo } from 'react';
import { classNames } from '~/utils/classNames';
interface PanelHeaderProps {
className?: string;
children: React.ReactNode;
}
export const PanelHeader = memo(({ className, children }: PanelHeaderProps) => {
return (
<div
className={classNames(
'flex items-center gap-2 bg-bolt-elements-background-depth-2 text-bolt-elements-textSecondary border-b border-bolt-elements-borderColor px-4 py-1 min-h-[34px] text-sm',
className,
)}
>
{children}
</div>
);
});
================================================
FILE: app/components/ui/PanelHeaderButton.tsx
================================================
import { memo } from 'react';
import { classNames } from '~/utils/classNames';
interface PanelHeaderButtonProps {
className?: string;
disabledClassName?: string;
disabled?: boolean;
children: string | JSX.Element | Array<JSX.Element | string>;
onClick?: (event: React.MouseEvent<HTMLButtonElement, MouseEvent>) => void;
}
export const PanelHeaderButton = memo(
({ className, disabledClassName, disabled = false, children, onClick }: PanelHeaderButtonProps) => {
return (
<button
className={classNames(
'flex items-center shrink-0 gap-1.5 px-1.5 rounded-md py-0.5 text-bolt-elements-item-contentDefault bg-transparent enabled:hover:text-bolt-elements-item-contentActive enabled:hover:bg-bolt-elements-item-backgroundActive disabled:cursor-not-allowed',
{
[classNames('opacity-30', disabledClassName)]: disabled,
},
className,
)}
disabled={disabled}
onClick={(event) => {
if (disabled) {
return;
}
onClick?.(event);
}}
>
{children}
</button>
);
},
);
================================================
FILE: app/components/ui/Slider.tsx
================================================
import { motion } from 'framer-motion';
import { memo } from 'react';
import { classNames } from '~/utils/classNames';
import { cubicEasingFn } from '~/utils/easings';
import { genericMemo } from '~/utils/react';
interface SliderOption<T> {
value: T;
text: string;
}
export interface SliderOptions<T> {
left: SliderOption<T>;
right: SliderOption<T>;
}
interface SliderProps<T> {
selected: T;
options: SliderOptions<T>;
setSelected?: (selected: T) => void;
}
export const Slider = genericMemo(<T,>({ selected, options, setSelected }: SliderProps<T>) => {
const isLeftSelected = selected === options.left.value;
return (
<div className="flex items-center flex-wrap shrink-0 gap-1 bg-bolt-elements-background-depth-1 overflow-hidden rounded-full p-1">
<SliderButton selected={isLeftSelected} setSelected={() => setSelected?.(options.left.value)}>
{options.left.text}
</SliderButton>
<SliderButton selected={!isLeftSelected} setSelected={() => setSelected?.(options.right.value)}>
{options.right.text}
</SliderButton>
</div>
);
});
interface SliderButtonProps {
selected: boolean;
children: string | JSX.Element | Array<JSX.Element | string>;
setSelected: () => void;
}
const SliderButton = memo(({ selected, children, setSelected }: SliderButtonProps) => {
return (
<button
onClick={setSelected}
className={classNames(
'bg-transparent text-sm px-2.5 py-0.5 rounded-full relative',
selected
? 'text-bolt-elements-item-contentAccent'
: 'text-bolt-elements-item-contentDefault hover:text-bolt-elements-item-contentActive',
)}
>
<span className="relative z-10">{children}</span>
{selected && (
<motion.span
layoutId="pill-tab"
transition={{ duration: 0.2, ease: cubicEasingFn }}
className="absolute inset-0 z-0 bg-bolt-elements-item-backgroundAccent rounded-full"
></motion.span>
)}
</button>
);
});
================================================
FILE: app/components/ui/ThemeSwitch.tsx
================================================
import { useStore } from '@nanostores/react';
import { memo, useEffect, useState } from 'react';
import { themeStore, toggleTheme } from '~/lib/stores/theme';
import { IconButton } from './IconButton';
interface ThemeSwitchProps {
className?: string;
}
export const ThemeSwitch = memo(({ className }: ThemeSwitchProps) => {
const theme = useStore(themeStore);
const [domLoaded, setDomLoaded] = useState(false);
useEffect(() => {
setDomLoaded(true);
}, []);
return (
domLoaded && (
<IconButton
className={className}
icon={theme === 'dark' ? 'i-ph-sun-dim-duotone' : 'i-ph-moon-stars-duotone'}
size="xl"
title="Toggle Theme"
onClick={toggleTheme}
/>
)
);
});
================================================
FILE: app/components/workbench/EditorPanel.tsx
================================================
import { useStore } from '@nanostores/react';
import { memo, useEffect, useMemo, useRef, useState } from 'react';
import { Panel, PanelGroup, PanelResizeHandle, type ImperativePanelHandle } from 'react-resizable-panels';
import {
CodeMirrorEditor,
type EditorDocument,
type EditorSettings,
type OnChangeCallback as OnEditorChange,
type OnSaveCallback as OnEditorSave,
type OnScrollCallback as OnEditorScroll,
} from '~/components/editor/codemirror/CodeMirrorEditor';
import { IconButton } from '~/components/ui/IconButton';
import { PanelHeader } from '~/components/ui/PanelHeader';
import { PanelHeaderButton } from '~/components/ui/PanelHeaderButton';
import { shortcutEventEmitter } from '~/lib/hooks';
import type { FileMap } from '~/lib/stores/files';
import { themeStore } from '~/lib/stores/theme';
import { workbenchStore } from '~/lib/stores/workbench';
import { classNames } from '~/utils/classNames';
import { WORK_DIR } from '~/utils/constants';
import { renderLogger } from '~/utils/logger';
import { isMobile } from '~/utils/mobile';
import { FileBreadcrumb } from './FileBreadcrumb';
import { FileTree } from './FileTree';
import { Terminal, type TerminalRef } from './terminal/Terminal';
interface EditorPanelProps {
files?: FileMap;
unsavedFiles?: Set<string>;
editorDocument?: EditorDocument;
selectedFile?: string | undefined;
isStreaming?: boolean;
onEditorChange?: OnEditorChange;
onEditorScroll?: OnEditorScroll;
onFileSelect?: (value?: string) => void;
onFileSave?: OnEditorSave;
onFileReset?: () => void;
}
const MAX_TERMINALS = 3;
const DEFAULT_TERMINAL_SIZE = 25;
const DEFAULT_EDITOR_SIZE = 100 - DEFAULT_TERMINAL_SIZE;
const editorSettings: EditorSettings = { tabSize: 2 };
export const EditorPanel = memo(
({
files,
unsavedFiles,
editorDocument,
selectedFile,
isStreaming,
onFileSelect,
onEditorChange,
onEditorScroll,
onFileSave,
onFileReset,
}: EditorPanelProps) => {
renderLogger.trace('EditorPanel');
const theme = useStore(themeStore);
const showTerminal = useStore(workbenchStore.showTerminal);
const terminalRefs = useRef<Array<TerminalRef | null>>([]);
const terminalPanelRef = useRef<ImperativePanelHandle>(null);
const terminalToggledByShortcut = useRef(false);
const [activeTerminal, setActiveTerminal] = useState(0);
const [terminalCount, setTerminalCount] = useState(1);
const activeFileSegments = useMemo(() => {
if (!editorDocument) {
return undefined;
}
return editorDocument.filePath.split('/');
}, [editorDocument]);
const activeFileUnsaved = useMemo(() => {
return editorDocument !== undefined && unsavedFiles?.has(editorDocument.filePath);
}, [editorDocument, unsavedFiles]);
useEffect(() => {
const unsubscribeFromEventEmitter = shortcutEventEmitter.on('toggleTerminal', () => {
terminalToggledByShortcut.current = true;
});
const unsubscribeFromThemeStore = themeStore.subscribe(() => {
for (const ref of Object.values(terminalRefs.current)) {
ref?.reloadStyles();
}
});
return () => {
unsubscribeFromEventEmitter();
unsubscribeFromThemeStore();
};
}, []);
useEffect(() => {
const { current: terminal } = terminalPanelRef;
if (!terminal) {
return;
}
const isCollapsed = terminal.isCollapsed();
if (!showTerminal && !isCollapsed) {
terminal.collapse();
} else if (showTerminal && isCollapsed) {
terminal.resize(DEFAULT_TERMINAL_SIZE);
}
terminalToggledByShortcut.current = false;
}, [showTerminal]);
const addTerminal = () => {
if (terminalCount < MAX_TERMINALS) {
setTerminalCount(terminalCount + 1);
setActiveTerminal(terminalCount);
}
};
return (
<PanelGroup direction="vertical">
<Panel defaultSize={showTerminal ? DEFAULT_EDITOR_SIZE : 100} minSize={20}>
<PanelGroup direction="horizontal">
<Panel defaultSize={20} minSize={10} collapsible>
<div className="flex flex-col border-r border-bolt-elements-borderColor h-full">
<PanelHeader>
<div className="i-ph:tree-structure-duotone shrink-0" />
Files
</PanelHeader>
<FileTree
className="h-full"
files={files}
hideRoot
unsavedFiles={unsavedFiles}
rootFolder={WORK_DIR}
selectedFile={selectedFile}
onFileSelect={onFileSelect}
/>
</div>
</Panel>
<PanelResizeHandle />
<Panel className="flex flex-col" defaultSize={80} minSize={20}>
<PanelHeader className="overflow-x-auto">
{activeFileSegments?.length && (
<div className="flex items-center flex-1 text-sm">
<FileBreadcrumb pathSegments={activeFileSegments} files={files} onFileSelect={onFileSelect} />
{activeFileUnsaved && (
<div className="flex gap-1 ml-auto -mr-1.5">
<PanelHeaderButton onClick={onFileSave}>
<div className="i-ph:floppy-disk-duotone" />
Save
</PanelHeaderButton>
<PanelHeaderButton onClick={onFileReset}>
<div className="i-ph:clock-counter-clockwise-duotone" />
Reset
</PanelHeaderButton>
</div>
)}
</div>
)}
</PanelHeader>
<div className="h-full flex-1 overflow-hidden">
<CodeMirrorEditor
theme={theme}
editable={!isStreaming && editorDocument !== undefined}
settings={editorSettings}
doc={editorDocument}
autoFocusOnDocumentChange={!isMobile()}
onScroll={onEditorScroll}
onChange={onEditorChange}
onSave={onFileSave}
/>
</div>
</Panel>
</PanelGroup>
</Panel>
<PanelResizeHandle />
<Panel
ref={terminalPanelRef}
defaultSize={showTerminal ? DEFAULT_TERMINAL_SIZE : 0}
minSize={10}
collapsible
onExpand={() => {
if (!terminalToggledByShortcut.current) {
workbenchStore.toggleTerminal(true);
}
}}
onCollapse={() => {
if (!terminalToggledByShortcut.current) {
workbenchStore.toggleTerminal(false);
}
}}
>
<div className="h-full">
<div className="bg-bolt-elements-terminals-background h-full flex flex-col">
<div className="flex items-center bg-bolt-elements-background-depth-2 border-y border-bolt-elements-borderColor gap-1.5 min-h-[34px] p-2">
{Array.from({ length: terminalCount }, (_, index) => {
const isActive = activeTerminal === index;
return (
<button
key={index}
className={classNames(
'flex items-center text-sm cursor-pointer gap-1.5 px-3 py-2 h-full whitespace-nowrap rounded-full',
{
'bg-bolt-elements-terminals-buttonBackground text-bolt-elements-textPrimary': isActive,
'bg-bolt-elements-background-depth-2 text-bolt-elements-textSecondary hover:bg-bolt-elements-terminals-buttonBackground':
!isActive,
},
)}
onClick={() => setActiveTerminal(index)}
>
<div className="i-ph:terminal-window-duotone text-lg" />
Terminal {terminalCount > 1 && index + 1}
</button>
);
})}
{terminalCount < MAX_TERMINALS && <IconButton icon="i-ph:plus" size="md" onClick={addTerminal} />}
<IconButton
className="ml-auto"
icon="i-ph:caret-down"
title="Close"
size="md"
onClick={() => workbenchStore.toggleTerminal(false)}
/>
</div>
{Array.from({ length: terminalCount }, (_, index) => {
const isActive = activeTerminal === index;
return (
<Terminal
key={index}
className={classNames('h-full overflow-hidden', {
hidden: !isActive,
})}
ref={(ref) => {
terminalRefs.current.push(ref);
}}
onTerminalReady={(terminal) => workbenchStore.attachTerminal(terminal)}
onTerminalResize={(cols, rows) => workbenchStore.onTerminalResize(cols, rows)}
theme={theme}
/>
);
})}
</div>
</div>
</Panel>
</PanelGroup>
);
},
);
================================================
FILE: app/components/workbench/FileBreadcrumb.tsx
================================================
import * as DropdownMenu from '@radix-ui/react-dropdown-menu';
import { AnimatePresence, motion, type Variants } from 'framer-motion';
import { memo, useEffect, useRef, useState } from 'react';
import type { FileMap } from '~/lib/stores/files';
import { classNames } from '~/utils/classNames';
import { WORK_DIR } from '~/utils/constants';
import { cubicEasingFn } from '~/utils/easings';
import { renderLogger } from '~/utils/logger';
import FileTree from './FileTree';
const WORK_DIR_REGEX = new RegExp(`^${WORK_DIR.split('/').slice(0, -1).join('/').replaceAll('/', '\\/')}/`);
interface FileBreadcrumbProps {
files?: FileMap;
pathSegments?: string[];
onFileSelect?: (filePath: string) => void;
}
const contextMenuVariants = {
open: {
y: 0,
opacity: 1,
transition: {
duration: 0.15,
ease: cubicEasingFn,
},
},
close: {
y: 6,
opacity: 0,
transition: {
duration: 0.15,
ease: cubicEasingFn,
},
},
} satisfies Variants;
export const FileBreadcrumb = memo<FileBreadcrumbProps>(({ files, pathSegments = [], onFileSelect }) => {
renderLogger.trace('FileBreadcrumb');
const [activeIndex, setActiveIndex] = useState<number | null>(null);
const contextMenuRef = useRef<HTMLDivElement | null>(null);
const segmentRefs = useRef<(HTMLSpanElement | null)[]>([]);
const handleSegmentClick = (index: number) => {
setActiveIndex((prevIndex) => (prevIndex === index ? null : index));
};
useEffect(() => {
const handleOutsideClick = (event: MouseEvent) => {
if (
activeIndex !== null &&
!contextMenuRef.current?.contains(event.target as Node) &&
!segmentRefs.current.some((ref) => ref?.contains(event.target as Node))
) {
setActiveIndex(null);
}
};
document.addEventListener('mousedown', handleOutsideClick);
return () => {
document.removeEventListener('mousedown', handleOutsideClick);
};
}, [activeIndex]);
if (files === undefined || pathSegments.length === 0) {
return null;
}
return (
<div className="flex">
{pathSegments.map((segment, index) => {
const isLast = index === pathSegments.length - 1;
const path = pathSegments.slice(0, index).join('/');
if (!WORK_DIR_REGEX.test(path)) {
return null;
}
const isActive = activeIndex === index;
return (
<div key={index} className="relative flex items-center">
<DropdownMenu.Root open={isActive} modal={false}>
<DropdownMenu.Trigger asChild>
<span
ref={(ref) => (segmentRefs.current[index] = ref)}
className={classNames('flex items-center gap-1.5 cursor-pointer shrink-0', {
'text-bolt-elements-textTertiary hover:text-bolt-elements-textPrimary': !isActive,
'text-bolt-elements-textPrimary underline': isActive,
'pr-4': isLast,
})}
onClick={() => handleSegmentClick(index)}
>
{isLast && <div className="i-ph:file-duotone" />}
{segment}
</span>
</DropdownMenu.Trigger>
{index > 0 && !isLast && <span className="i-ph:caret-right inline-block mx-1" />}
<AnimatePresence>
{isActive && (
<DropdownMenu.Portal>
<DropdownMenu.Content
className="z-file-tree-breadcrumb"
asChild
align="start"
side="bottom"
avoidCollisions={false}
>
<motion.div
ref={contextMenuRef}
initial="close"
animate="open"
exit="close"
variants={contextMenuVariants}
>
<div className="rounded-lg overflow-hidden">
<div className="max-h-[50vh] min-w-[300px] overflow-scroll bg-bolt-elements-background-depth-1 border border-bolt-elements-borderColor shadow-sm rounded-lg">
<FileTree
files={files}
hideRoot
rootFolder={path}
collapsed
allowFolderSelection
selectedFile={`${path}/${segment}`}
onFileSelect={(filePath) => {
setActiveIndex(null);
onFileSelect?.(filePath);
}}
/>
</div>
</div>
<DropdownMenu.Arrow className="fill-bolt-elements-borderColor" />
</motion.div>
</DropdownMenu.Content>
</DropdownMenu.Portal>
)}
</AnimatePresence>
</DropdownMenu.Root>
</div>
);
})}
</div>
);
});
================================================
FILE: app/components/workbench/FileTree.tsx
================================================
import { memo, useEffect, useMemo, useState, type ReactNode } from 'react';
import type { FileMap } from '~/lib/stores/files';
import { classNames } from '~/utils/classNames';
import { createScopedLogger, renderLogger } from '~/utils/logger';
const logger = createScopedLogger('FileTree');
const NODE_PADDING_LEFT = 8;
const DEFAULT_HIDDEN_FILES = [/\/node_modules\//, /\/\.next/, /\/\.astro/];
interface Props {
files?: FileMap;
selectedFile?: string;
onFileSelect?: (filePath: string) => void;
rootFolder?: string;
hideRoot?: boolean;
collapsed?: boolean;
allowFolderSelection?: boolean;
hiddenFiles?: Array<string | RegExp>;
unsavedFiles?: Set<string>;
className?: string;
}
export const FileTree = memo(
({
files = {},
onFileSelect,
selectedFile,
rootFolder,
hideRoot = false,
collapsed = false,
allowFolderSelection = false,
hiddenFiles,
className,
unsavedFiles,
}: Props) => {
renderLogger.trace('FileTree');
const computedHiddenFiles = useMemo(() => [...DEFAULT_HIDDEN_FILES, ...(hiddenFiles ?? [])], [hiddenFiles]);
const fileList = useMemo(() => {
return buildFileList(files, rootFolder, hideRoot, computedHiddenFiles);
}, [files, rootFolder, hideRoot, computedHiddenFiles]);
const [collapsedFolders, setCollapsedFolders] = useState(() => {
return collapsed
? new Set(fileList.filter((item) => item.kind === 'folder').map((item) => item.fullPath))
: new Set<string>();
});
useEffect(() => {
if (collapsed) {
setCollapsedFolders(new Set(fileList.filter((item) => item.kind === 'folder').map((item) => item.fullPath)));
return;
}
setCollapsedFolders((prevCollapsed) => {
const newCollapsed = new Set<string>();
for (const folder of fileList) {
if (folder.kind === 'folder' && prevCollapsed.has(folder.fullPath)) {
newCollapsed.add(folder.fullPath);
}
}
return newCollapsed;
});
}, [fileList, collapsed]);
const filteredFileList = useMemo(() => {
const list = [];
let lastDepth = Number.MAX_SAFE_INTEGER;
for (const fileOrFolder of fileList) {
const depth = fileOrFolder.depth;
// if the depth is equal we reached the end of the collaped group
if (lastDepth === depth) {
lastDepth = Number.MAX_SAFE_INTEGER;
}
// ignore collapsed folders
if (collapsedFolders.has(fileOrFolder.fullPath)) {
lastDepth = Math.min(lastDepth, depth);
}
// ignore files and folders below the last collapsed folder
if (lastDepth < depth) {
continue;
}
list.push(fileOrFolder);
}
return list;
}, [fileList, collapsedFolders]);
const toggleCollapseState = (fullPath: string) => {
setCollapsedFolders((prevSet) => {
const newSet = new Set(prevSet);
if (newSet.has(fullPath)) {
newSet.delete(fullPath);
} else {
newSet.add(fullPath);
}
return newSet;
});
};
return (
<div className={classNames('text-sm', className)}>
{filteredFileList.map((fileOrFolder) => {
switch (fileOrFolder.kind) {
case 'file': {
return (
<File
key={fileOrFolder.id}
selected={selectedFile === fileOrFolder.fullPath}
file={fileOrFolder}
unsavedChanges={unsavedFiles?.has(fileOrFolder.fullPath)}
onClick={() => {
onFileSelect?.(fileOrFolder.fullPath);
}}
/>
);
}
case 'folder': {
return (
<Folder
key={fileOrFolder.id}
folder={fileOrFolder}
selected={allowFolderSelection && selectedFile === fileOrFolder.fullPath}
collapsed={collapsedFolders.has(fileOrFolder.fullPath)}
onClick={() => {
toggleCollapseState(fileOrFolder.fullPath);
}}
/>
);
}
default: {
return undefined;
}
}
})}
</div>
);
},
);
export default FileTree;
interface FolderProps {
folder: FolderNode;
collapsed: boolean;
selected?: boolean;
onClick: () => void;
}
function Folder({ folder: { depth, name }, collapsed, selected = false, onClick }: FolderProps) {
return (
<NodeButton
className={classNames('group', {
'bg-transparent text-bolt-elements-item-contentDefault hover:text-bolt-elements-item-contentActive hover:bg-bolt-elements-item-backgroundActive':
!selected,
'bg-bolt-elements-item-backgroundAccent text-bolt-elements-item-contentAccent': selected,
})}
depth={depth}
iconClasses={classNames({
'i-ph:caret-right scale-98': collapsed,
'i-ph:caret-down scale-98': !collapsed,
})}
onClick={onClick}
>
{name}
</NodeButton>
);
}
interface FileProps {
file: FileNode;
selected: boolean;
unsavedChanges?: boolean;
onClick: () => void;
}
function File({ file: { depth, name }, onClick, selected, unsavedChanges = false }: FileProps) {
return (
<NodeButton
className={classNames('group', {
'bg-transparent hover:bg-bolt-elements-item-backgroundActive text-bolt-elements-item-contentDefault': !selected,
'bg-bolt-elements-item-backgroundAccent text-bolt-elements-item-contentAccent': selected,
})}
depth={depth}
iconClasses={classNames('i-ph:file-duotone scale-98', {
'group-hover:text-bolt-elements-item-contentActive': !selected,
})}
onClick={onClick}
>
<div
className={classNames('flex items-center', {
'group-hover:text-bolt-elements-item-contentActive': !selected,
})}
>
<div className="flex-1 truncate pr-2">{name}</div>
{unsavedChanges && <span className="i-ph:circle-fill scale-68 shrink-0 text-orange-500" />}
</div>
</NodeButton>
);
}
interface ButtonProps {
depth: number;
iconClasses: string;
children: ReactNode;
className?: string;
onClick?: () => void;
}
function NodeButton({ depth, iconClasses, onClick, className, children }: ButtonProps) {
return (
<button
className={classNames(
'flex items-center gap-1.5 w-full pr-2 border-2 border-transparent text-faded py-0.5',
className,
)}
style={{ paddingLeft: `${6 + depth * NODE_PADDING_LEFT}px` }}
onClick={() => onClick?.()}
>
<div className={classNames('scale-120 shrink-0', iconClasses)}></div>
<div className="truncate w-full text-left">{children}</div>
</button>
);
}
type Node = FileNode | FolderNode;
interface BaseNode {
id: number;
depth: number;
name: string;
fullPath: string;
}
interface FileNode extends BaseNode {
kind: 'file';
}
interface FolderNode extends BaseNode {
kind: 'folder';
}
function buildFileList(
files: FileMap,
rootFolder = '/',
hideRoot: boolean,
hiddenFiles: Array<string | RegExp>,
): Node[] {
const folderPaths = new Set<string>();
const fileList: Node[] = [];
let defaultDepth = 0;
if (rootFolder === '/' && !hideRoot) {
defaultDepth = 1;
fileList.push({ kind: 'folder', name: '/', depth: 0, id: 0, fullPath: '/' });
}
for (const [filePath, dirent] of Object.entries(files)) {
const segments = filePath.split('/').filter((segment) => segment);
const fileName = segments.at(-1);
if (!fileName || isHiddenFile(filePath, fileName, hiddenFiles)) {
continue;
}
let currentPath = '';
let i = 0;
let depth = 0;
while (i < segments.length) {
const name = segments[i];
const fullPath = (currentPath += `/${name}`);
if (!fullPath.startsWith(rootFolder) || (hideRoot && fullPath === rootFolder)) {
i++;
continue;
}
if (i === segments.length - 1 && dirent?.type === 'file') {
fileList.push({
kind: 'file',
id: fileList.length,
name,
fullPath,
depth: depth + defaultDepth,
});
} else if (!folderPaths.has(fullPath)) {
folderPaths.add(fullPath);
fileList.push({
kind: 'folder',
id: fileList.length,
name,
fullPath,
depth: depth + defaultDepth,
});
}
i++;
depth++;
}
}
return sortFileList(rootFolder, fileList, hideRoot);
}
function isHiddenFile(filePath: string, fileName: string, hiddenFiles: Array<string | RegExp>) {
return hiddenFiles.some((pathOrRegex) => {
if (typeof pathOrRegex === 'string') {
return fileName === pathOrRegex;
}
return pathOrRegex.test(filePath);
});
}
/**
* Sorts the given list of nodes into a tree structure (still a flat list).
*
* This function organizes the nodes into a hierarchical structure based on their paths,
* with folders appearing before files and all items sorted alphabetically within their level.
*
* @note This function mutates the given `nodeList` array for performance reasons.
*
* @param rootFolder - The path of the root folder to start the sorting from.
* @param nodeList - The list of nodes to be sorted.
*
* @returns A new array of nodes sorted in depth-first order.
*/
function sortFileList(rootFolder: string, nodeList: Node[], hideRoot: boolean): Node[] {
logger.trace('sortFileList');
const nodeMap = new Map<string, Node>();
const childrenMap = new Map<string, Node[]>();
// pre-sort nodes by name and type
nodeList.sort((a, b) => compareNodes(a, b));
for (const node of nodeList) {
nodeMap.set(node.fullPath, node);
const parentPath = node.fullPath.slice(0, node.fullPath.lastIndexOf('/'));
if (parentPath !== rootFolder.slice(0, rootFolder.lastIndexOf('/'))) {
if (!childrenMap.has(parentPath)) {
childrenMap.set(parentPath, []);
}
childrenMap.get(parentPath)?.push(node);
}
}
const sortedList: Node[] = [];
const depthFirstTraversal = (path: string): void => {
const node = nodeMap.get(path);
if (node) {
sortedList.push(node);
}
const children = childrenMap.get(path);
if (children) {
for (const child of children) {
if (child.kind === 'folder') {
depthFirstTraversal(child.fullPath);
} else {
sortedList.push(child);
}
}
}
};
if (hideRoot) {
// if root is hidden, start traversal from its immediate children
const rootChildren = childrenMap.get(rootFolder) || [];
for (const child of rootChildren) {
depthFirstTraversal(child.fullPath);
}
} else {
depthFirstTraversal(rootFolder);
}
return sortedList;
}
function compareNodes(a: Node, b: Node): number {
if (a.kind !== b.kind) {
return a.kind === 'folder' ? -1 : 1;
}
return a.name.localeCompare(b.name, undefined, { numeric: true, sensitivity: 'base' });
}
================================================
FILE: app/components/workbench/PortDropdown.tsx
================================================
import { memo, useEffect, useRef } from 'react';
import { IconButton } from '~/components/ui/IconButton';
import type { PreviewInfo } from '~/lib/stores/previews';
interface PortDropdownProps {
activePreviewIndex: number;
setActivePreviewIndex: (index: number) => void;
isDropdownOpen: boolean;
setIsDropdownOpen: (value: boolean) => void;
setHasSelectedPreview: (value: boolean) => void;
previews: PreviewInfo[];
}
export const PortDropdown = memo(
({
activePreviewIndex,
setActivePreviewIndex,
isDropdownOpen,
setIsDropdownOpen,
setHasSelectedPreview,
previews,
}: PortDropdownProps) => {
const dropdownRef = useRef<HTMLDivElement>(null);
// sort previews, preserving original index
const sortedPreviews = previews
.map((previewInfo, index) => ({ ...previewInfo, index }))
.sort((a, b) => a.port - b.port);
// close dropdown if user clicks outside
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
setIsDropdownOpen(false);
}
};
if (isDropdownOpen) {
window.addEventListener('mousedown', handleClickOutside);
} else {
window.removeEventListener('mousedown', handleClickOutside);
}
return () => {
window.removeEventListener('mousedown', handleClickOutside);
};
}, [isDropdownOpen]);
return (
<div className="relative z-port-dropdown" ref={dropdownRef}>
<IconButton icon="i-ph:plug" onClick={() => setIsDropdownOpen(!isDropdownOpen)} />
{isDropdownOpen && (
<div className="absolute right-0 mt-2 bg-bolt-elements-background-depth-2 border border-bolt-elements-borderColor rounded shadow-sm min-w-[140px] dropdown-animation">
<div className="px-4 py-2 border-b border-bolt-elements-borderColor text-sm font-semibold text-bolt-elements-textPrimary">
Ports
</div>
{sortedPreviews.map((preview) => (
<div
key={preview.port}
className="flex items-center px-4 py-2 cursor-pointer hover:bg-bolt-elements-item-backgroundActive"
onClick={() => {
setActivePreviewIndex(preview.index);
setIsDropdownOpen(false);
setHasSelectedPreview(true);
}}
>
<span
className={
activePreviewIndex === preview.index
? 'text-bolt-elements-item-contentAccent'
: 'text-bolt-elements-item-contentDefault group-hover:text-bolt-elements-item-contentActive'
}
>
{preview.port}
</span>
</div>
))}
</div>
)}
</div>
);
},
);
================================================
FILE: app/components/workbench/Preview.tsx
================================================
import { useStore } from '@nanostores/react';
import { memo, useCallback, useEffect, useRef, useState } from 'react';
import { IconButton } from '~/components/ui/IconButton';
import { workbenchStore } from '~/lib/stores/workbench';
import { PortDropdown } from './PortDropdown';
export const Preview = memo(() => {
const iframeRef = useRef<HTMLIFrameElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
const [activePreviewIndex, setActivePreviewIndex] = useState(0);
const [isPortDropdownOpen, setIsPortDropdownOpen] = useState(false);
const hasSelectedPreview = useRef(false);
const previews = useStore(workbenchStore.previews);
const activePreview = previews[activePreviewIndex];
const [url, setUrl] = useState('');
const [iframeUrl, setIframeUrl] = useState<string | undefined>();
useEffect(() => {
if (!activePreview) {
setUrl('');
setIframeUrl(undefined);
return;
}
const { baseUrl } = activePreview;
setUrl(baseUrl);
setIframeUrl(baseUrl);
}, [activePreview, iframeUrl]);
const validateUrl = useCallback(
(value: string) => {
if (!activePreview) {
return false;
}
const { baseUrl } = activePreview;
if (value === baseUrl) {
return true;
} else if (value.startsWith(baseUrl)) {
return ['/', '?', '#'].includes(value.charAt(baseUrl.length));
}
return false;
},
[activePreview],
);
const findMinPortIndex = useCallback(
(minIndex: number, preview: { port: number }, index: number, array: { port: number }[]) => {
return preview.port < array[minIndex].port ? index : minIndex;
},
[],
);
// when previews change, display the lowest port if user hasn't selected a preview
useEffect(() => {
if (previews.length > 1 && !hasSelectedPreview.current) {
const minPortIndex = previews.reduce(findMinPortIndex, 0);
setActivePreviewIndex(minPortIndex);
}
}, [previews]);
const reloadPreview = () => {
if (iframeRef.current) {
iframeRef.current.src = iframeRef.current.src;
}
};
return (
<div className="w-full h-full flex flex-col">
{isPortDropdownOpen && (
<div className="z-iframe-overlay w-full h-full absolute" onClick={() => setIsPortDropdownOpen(false)} />
)}
<div className="bg-bolt-elements-background-depth-2 p-2 flex items-center gap-1.5">
<IconButton icon="i-ph:arrow-clockwise" onClick={reloadPreview} />
<div
className="flex items-center gap-1 flex-grow bg-bolt-elements-preview-addressBar-background border border-bolt-elements-borderColor text-bolt-elements-preview-addressBar-text rounded-full px-3 py-1 text-sm hover:bg-bolt-elements-preview-addressBar-backgroundHover hover:focus-within:bg-bolt-elements-preview-addressBar-backgroundActive focus-within:bg-bolt-elements-preview-addressBar-backgroundActive
focus-within-border-bolt-elements-borderColorActive focus-within:text-bolt-elements-preview-addressBar-textActive"
>
<input
ref={inputRef}
className="w-full bg-transparent outline-none"
type="text"
value={url}
onChange={(event) => {
setUrl(event.target.value);
}}
onKeyDown={(event) => {
if (event.key === 'Enter' && validateUrl(url)) {
setIframeUrl(url);
if (inputRef.current) {
inputRef.current.blur();
}
}
}}
/>
</div>
{previews.length > 1 && (
<PortDropdown
activePreviewIndex={activePreviewIndex}
setActivePreviewIndex={setActivePreviewIndex}
isDropdownOpen={isPortDropdownOpen}
setHasSelectedPreview={(value) => (hasSelectedPreview.current = value)}
setIsDropdownOpen={setIsPortDropdownOpen}
previews={previews}
/>
)}
</div>
<div className="flex-1 border-t border-bolt-elements-borderColor">
{activePreview ? (
<iframe ref={iframeRef} className="border-none w-full h-full bg-white" src={iframeUrl} />
) : (
<div className="flex w-full h-full justify-center items-center bg-white">No preview available</div>
)}
</div>
</div>
);
});
================================================
FILE: app/components/workbench/Workbench.client.tsx
================================================
import { useStore } from '@nanostores/react';
import { motion, type HTMLMotionProps, type Variants } from 'framer-motion';
import { computed } from 'nanostores';
import { memo, useCallback, useEffect } from 'react';
import { toast } from 'react-toastify';
import {
type OnChangeCallback as OnEditorChange,
type OnScrollCallback as OnEditorScroll,
} from '~/components/editor/codemirror/CodeMirrorEditor';
import { IconButton } from '~/components/ui/IconButton';
import { PanelHeaderButton } from '~/components/ui/PanelHeaderButton';
import { Slider, type SliderOptions } from '~/components/ui/Slider';
import { workbenchStore, type WorkbenchViewType } from '~/lib/stores/workbench';
import { classNames } from '~/utils/classNames';
import { cubicEasingFn } from '~/utils/easings';
import { renderLogger } from '~/utils/logger';
import { EditorPanel } from './EditorPanel';
import { Preview } from './Preview';
interface WorkspaceProps {
chatStarted?: boolean;
isStreaming?: boolean;
}
const viewTransition = { ease: cubicEasingFn };
const sliderOptions: SliderOptions<WorkbenchViewType> = {
left: {
value: 'code',
text: 'Code',
},
right: {
value: 'preview',
text: 'Preview',
},
};
const workbenchVariants = {
closed: {
width: 0,
transition: {
duration: 0.2,
ease: cubicEasingFn,
},
},
open: {
width: 'var(--workbench-width)',
transition: {
duration: 0.2,
ease: cubicEasingFn,
},
},
} satisfies Variants;
export const Workbench = memo(({ chatStarted, isStreaming }: WorkspaceProps) => {
renderLogger.trace('Workbench');
const hasPreview = useStore(computed(workbenchStore.previews, (previews) => previews.length > 0));
const showWorkbench = useStore(workbenchStore.showWorkbench);
const selectedFile = useStore(workbenchStore.selectedFile);
const currentDocument = useStore(workbenchStore.currentDocument);
const unsavedFiles = useStore(workbenchStore.unsavedFiles);
const files = useStore(workbenchStore.files);
const selectedView = useStore(workbenchStore.currentView);
const setSelectedView = (view: WorkbenchViewType) => {
workbenchStore.currentView.set(view);
};
useEffect(() => {
if (hasPreview) {
setSelectedView('preview');
}
}, [hasPreview]);
useEffect(() => {
workbenchStore.setDocuments(files);
}, [files]);
const onEditorChange = useCallback<OnEditorChange>((update) => {
workbenchStore.setCurrentDocumentContent(update.content);
}, []);
const onEditorScroll = useCallback<OnEditorScroll>((position) => {
workbenchStore.setCurrentDocumentScrollPosition(position);
}, []);
const onFileSelect = useCallback((filePath: string | undefined) => {
workbenchStore.setSelectedFile(filePath);
}, []);
const onFileSave = useCallback(() => {
workbenchStore.saveCurrentDocument().catch(() => {
toast.error('Failed to update file content');
});
}, []);
const onFileReset = useCallback(() => {
workbenchStore.resetCurrentDocument();
}, []);
return (
chatStarted && (
<motion.div
initial="closed"
animate={showWorkbench ? 'open' : 'closed'}
variants={workbenchVariants}
className="z-workbench"
>
<div
className={classNames(
'fixed top-[calc(var(--header-height)+1.5rem)] bottom-6 w-[var(--workbench-inner-width)] mr-4 z-0 transition-[left,width] duration-200 bolt-ease-cubic-bezier',
{
'left-[var(--workbench-left)]': showWorkbench,
'left-[100%]': !showWorkbench,
},
)}
>
<div className="absolute inset-0 px-6">
<div className="h-full flex flex-col bg-bolt-elements-background-depth-2 border border-bolt-elements-borderColor shadow-sm rounded-lg overflow-hidden">
<div className="flex items-center px-3 py-2 border-b border-bolt-elements-borderColor">
<Slider selected={selectedView} options={sliderOptions} setSelected={setSelectedView} />
<div className="ml-auto" />
{selectedView === 'code' && (
<PanelHeaderButton
className="mr-1 text-sm"
onClick={() => {
workbenchStore.toggleTerminal(!workbenchStore.showTerminal.get());
}}
>
<div className="i-ph:terminal" />
Toggle Terminal
</PanelHeaderButton>
)}
<IconButton
icon="i-ph:x-circle"
className="-mr-1"
size="xl"
onClick={() => {
workbenchStore.showWorkbench.set(false);
}}
/>
</div>
<div className="relative flex-1 overflow-hidden">
<View
initial={{ x: selectedView === 'code' ? 0 : '-100%' }}
animate={{ x: selectedView === 'code' ? 0 : '-100%' }}
>
<EditorPanel
editorDocument={currentDocument}
isStreaming={isStreaming}
selectedFile={selectedFile}
files={files}
unsavedFiles={unsavedFiles}
onFileSelect={onFileSelect}
onEditorScroll={onEditorScroll}
onEditorChange={onEditorChange}
onFileSave={onFileSave}
onFileReset={onFileReset}
/>
</View>
<View
initial={{ x: selectedView === 'preview' ? 0 : '100%' }}
animate={{ x: selectedView === 'preview' ? 0 : '100%' }}
>
<Preview />
</View>
</div>
</div>
</div>
</div>
</motion.div>
)
);
});
interface ViewProps extends HTMLMotionProps<'div'> {
children: JSX.Element;
}
const View = memo(({ children, ...props }: ViewProps) => {
return (
<motion.div className="absolute inset-0" transition={viewTransition} {...props}>
{children}
</motion.div>
);
});
================================================
FILE: app/components/workbench/terminal/Terminal.tsx
================================================
import { FitAddon } from '@xterm/addon-fit';
import { WebLinksAddon } from '@xterm/addon-web-links';
import { Terminal as XTerm } from '@xterm/xterm';
import { forwardRef, memo, useEffect, useImperativeHandle, useRef } from 'react';
import type { Theme } from '~/lib/stores/theme';
import { createScopedLogger } from '~/utils/logger';
import { getTerminalTheme } from './theme';
const logger = createScopedLogger('Terminal');
export interface TerminalRef {
reloadStyles: () => void;
}
export interface TerminalProps {
className?: string;
theme: Theme;
readonly?: boolean;
onTerminalReady?: (terminal: XTerm) => void;
onTerminalResize?: (cols: number, rows: number) => void;
}
export const Terminal = memo(
forwardRef<TerminalRef, TerminalProps>(({ className, theme, readonly, onTerminalReady, onTerminalResize }, ref) => {
const terminalElementRef = useRef<HTMLDivElement>(null);
const terminalRef = useRef<XTerm>();
useEffect(() => {
const element = terminalElementRef.current!;
const fitAddon = new FitAddon();
const webLinksAddon = new WebLinksAddon();
const terminal = new XTerm({
cursorBlink: true,
convertEol: true,
disableStdin: readonly,
theme: getTerminalTheme(readonly ? { cursor: '#00000000' } : {}),
fontSize: 12,
fontFamily: 'Menlo, courier-new, courier, monospace',
});
terminalRef.current = terminal;
terminal.loadAddon(fitAddon);
terminal.loadAddon(webLinksAddon);
terminal.open(element);
const resizeObserver = new ResizeObserver(() => {
fitAddon.fit();
onTerminalResize?.(terminal.cols, terminal.rows);
});
resizeObserver.observe(element);
logger.info('Attach terminal');
onTerminalReady?.(terminal);
return () => {
resizeObserver.disconnect();
terminal.dispose();
};
}, []);
useEffect(() => {
const terminal = terminalRef.current!;
// we render a transparent cursor in case the terminal is readonly
terminal.options.theme = getTerminalTheme(readonly ? { cursor: '#00000000' } : {});
terminal.options.disableStdin = readonly;
}, [theme, readonly]);
useImperativeHandle(ref, () => {
return {
reloadStyles: () => {
const terminal = terminalRef.current!;
terminal.options.theme = getTerminalTheme(readonly ? { cursor: '#00000000' } : {});
},
};
}, []);
return <div className={className} ref={terminalElementRef} />;
}),
);
================================================
FILE: app/components/workbench/terminal/theme.ts
================================================
import type { ITheme } from '@xterm/xterm';
const style = getComputedStyle(document.documentElement);
const cssVar = (token: string) => style.getPropertyValue(token) || undefined;
export function getTerminalTheme(overrides?: ITheme): ITheme {
return {
cursor: cssVar('--bolt-elements-terminal-cursorColor'),
cursorAccent: cssVar('--bolt-elements-terminal-cursorColorAccent'),
foreground: cssVar('--bolt-elements-terminal-textColor'),
background: cssVar('--bolt-elements-terminal-backgroundColor'),
selectionBackground: cssVar('--bolt-elements-terminal-selection-backgroundColor'),
selectionForeground: cssVar('--bolt-elements-terminal-selection-textColor'),
selectionInactiveBackground: cssVar('--bolt-elements-terminal-selection-backgroundColorInactive'),
// ansi escape code colors
black: cssVar('--bolt-elements-terminal-color-black'),
red: cssVar('--bolt-elements-terminal-color-red'),
green: cssVar('--bolt-elements-terminal-color-green'),
yellow: cssVar('--bolt-elements-terminal-color-yellow'),
blue: cssVar('--bolt-elements-terminal-color-blue'),
magenta: cssVar('--bolt-elements-terminal-color-magenta'),
cyan: cssVar('--bolt-elements-terminal-color-cyan'),
white: cssVar('--bolt-elements-terminal-color-white'),
brightBlack: cssVar('--bolt-elements-terminal-color-brightBlack'),
brightRed: cssVar('--bolt-elements-terminal-color-brightRed'),
brightGreen: cssVar('--bolt-elements-terminal-color-brightGreen'),
brightYellow: cssVar('--bolt-elements-terminal-color-brightYellow'),
brightBlue: cssVar('--bolt-elements-terminal-color-brightBlue'),
brightMagenta: cssVar('--bolt-elements-terminal-color-brightMagenta'),
brightCyan: cssVar('--bolt-elements-terminal-color-brightCyan'),
brightWhite: cssVar('--bolt-elements-terminal-color-brightWhite'),
...overrides,
};
}
================================================
FILE: app/entry.client.tsx
================================================
import { RemixBrowser } from '@remix-run/react';
import { startTransition } from 'react';
import { hydrateRoot } from 'react-dom/client';
startTransition(() => {
hydrateRoot(document.getElementById('root')!, <RemixBrowser />);
});
================================================
FILE: app/entry.server.tsx
================================================
import type { AppLoadContext, EntryContext } from '@remix-run/cloudflare';
import { RemixServer } from '@remix-run/react';
import { isbot } from 'isbot';
import { renderToReadableStream } from 'react-dom/server';
import { renderHeadToString } from 'remix-island';
import { Head } from './root';
import { themeStore } from '~/lib/stores/theme';
export default async function handleRequest(
request: Request,
responseStatusCode: number,
responseHeaders: Headers,
remixContext: EntryContext,
_loadContext: AppLoadContext,
) {
const readable = await renderToReadableStream(<RemixServer context={remixContext} url={request.url} />, {
signal: request.signal,
onError(error: unknown) {
console.error(error);
responseStatusCode = 500;
},
});
const body = new ReadableStream({
start(controller) {
const head = renderHeadToString({ request, remixContext, Head });
controller.enqueue(
new Uint8Array(
new TextEncoder().encode(
`<!DOCTYPE html><html lang="en" data-theme="${themeStore.value}"><head>${head}</head><body><div id="root" class="w-full h-full">`,
),
),
);
const reader = readable.getReader();
function read() {
reader
.read()
.then(({ done, value }) => {
if (done) {
controller.enqueue(new Uint8Array(new TextEncoder().encode(`</div></body></html>`)));
controller.close();
return;
}
controller.enqueue(value);
read();
})
.catch((error) => {
controller.error(error);
readable.cancel();
});
}
read();
},
cancel() {
readable.cancel();
},
});
if (isbot(request.headers.get('user-agent') || '')) {
await readable.allReady;
}
responseHeaders.set('Content-Type', 'text/html');
responseHeaders.set('Cross-Origin-Embedder-Policy', 'require-corp');
responseHeaders.set('Cross-Origin-Opener-Policy', 'same-origin');
return new Response(body, {
headers: responseHeaders,
status: responseStatusCode,
});
}
================================================
FILE: app/lib/.server/llm/api-key.ts
================================================
import { env } from 'node:process';
export function getAPIKey(cloudflareEnv: Env) {
/**
* The `cloudflareEnv` is only used when deployed or when previewing locally.
* In development the environment variables are available through `env`.
*/
return env.ANTHROPIC_API_KEY || cloudflareEnv.ANTHROPIC_API_KEY;
}
================================================
FILE: app/lib/.server/llm/constants.ts
================================================
// see https://docs.anthropic.com/en/docs/about-claude/models
export const MAX_TOKENS = 8192;
// limits the number of model responses that can be returned in a single request
export const MAX_RESPONSE_SEGMENTS = 2;
================================================
FILE: app/lib/.server/llm/model.ts
================================================
import { createAnthropic } from '@ai-sdk/anthropic';
export function getAnthropicModel(apiKey: string) {
const anthropic = createAnthropic({
apiKey,
});
return anthropic('claude-3-5-sonnet-20240620');
}
================================================
FILE: app/lib/.server/llm/prompts.ts
================================================
import { MODIFICATIONS_TAG_NAME, WORK_DIR } from '~/utils/constants';
import { allowedHTMLElements } from '~/utils/markdown';
import { stripIndents } from '~/utils/stripIndent';
export const getSystemPrompt = (cwd: string = WORK_DIR) => `
You are Bolt, an expert AI assistant and exceptional senior software developer with vast knowledge across multiple programming languages, frameworks, and best practices.
<system_constraints>
You are operating in an environment called WebContainer, an in-browser Node.js runtime that emulates a Linux system to some degree. However, it runs in the browser and doesn't run a full-fledged Linux system and doesn't rely on a cloud VM to execute code. All code is executed in the browser. It does come with a shell that emulates zsh. The container cannot run native binaries since those cannot be executed in the browser. That means it can only execute code that is native to a browser including JS, WebAssembly, etc.
The shell comes with \`python\` and \`python3\` binaries, but they are LIMITED TO THE PYTHON STANDARD LIBRARY ONLY This means:
- There is NO \`pip\` support! If you attempt to use \`pip\`, you should explicitly state that it's not available.
- CRITICAL: Third-party libraries cannot be installed or imported.
- Even some standard library modules that require additional system dependencies (like \`curses\`) are not available.
- Only modules from the core Python standard library can be used.
Additionally, there is no \`g++\` or any C/C++ compiler available. WebContainer CANNOT run native binaries or compile C/C++ code!
Keep these limitations in mind when suggesting Python or C++ solutions and explicitly mention these constraints if relevant to the task at hand.
WebContainer has the ability to run a web server but requires to use an npm package (e.g., Vite, servor, serve, http-server) or use the Node.js APIs to implement a web server.
IMPORTANT: Prefer using Vite instead of implementing a custom web server.
IMPORTANT: Git is NOT available.
IMPORTANT: Prefer writing Node.js scripts instead of shell scripts. The environment doesn't fully support shell scripts, so use Node.js for scripting tasks whenever possible!
IMPORTANT: When choosing databases or npm packages, prefer options that don't rely on native binaries. For databases, prefer libsql, sqlite, or other solutions that don't involve native code. WebContainer CANNOT execute arbitrary native binaries.
Available shell commands: cat, chmod, cp, echo, hostname, kill, ln, ls, mkdir, mv, ps, pwd, rm, rmdir, xxd, alias, cd, clear, curl, env, false, getconf, head, sort, tail, touch, true, uptime, which, code, jq, loadenv, node, python3, wasm, xdg-open, command, exit, export, source
</system_constraints>
<code_formatting_info>
Use 2 spaces for code indentation
</code_formatting_info>
<message_formatting_info>
You can make the output pretty by using only the following available HTML elements: ${allowedHTMLElements.map((tagName) => `<${tagName}>`).join(', ')}
</message_formatting_info>
<diff_spec>
For user-made file modifications, a \`<${MODIFICATIONS_TAG_NAME}>\` section will appear at the start of the user message. It will contain either \`<diff>\` or \`<file>\` elements for each modified file:
- \`<diff path="/some/file/path.ext">\`: Contains GNU unified diff format changes
- \`<file path="/some/file/path.ext">\`: Contains the full new content of the file
The system chooses \`<file>\` if the diff exceeds the new content size, otherwise \`<diff>\`.
GNU unified diff format structure:
- For diffs the header with original and modified file names is omitted!
- Changed sections start with @@ -X,Y +A,B @@ where:
- X: Original file starting line
- Y: Original file line count
- A: Modified file starting line
- B: Modified file line count
- (-) lines: Removed from original
- (+) lines: Added in modified version
- Unmarked lines: Unchanged context
Example:
<${MODIFICATIONS_TAG_NAME}>
<diff path="/home/project/src/main.js">
@@ -2,7 +2,10 @@
return a + b;
}
-console.log('Hello, World!');
+console.log('Hello, Bolt!');
+
function greet() {
- return 'Greetings!';
+ return 'Greetings!!';
}
+
+console.log('The End');
</diff>
<file path="/home/project/package.json">
// full file content here
</file>
</${MODIFICATIONS_TAG_NAME}>
</diff_spec>
<artifact_info>
Bolt creates a SINGLE, comprehensive artifact for each project. The artifact contains all necessary steps and components, including:
- Shell commands to run including dependencies to install using a package manager (NPM)
- Files to create and their contents
- Folders to create if necessary
<artifact_instructions>
1. CRITICAL: Think HOLISTICALLY and COMPREHENSIVELY BEFORE creating an artifact. This means:
- Consider ALL relevant files in the project
- Review ALL previous file changes and user modifications (as shown in diffs, see diff_spec)
- Analyze the entire project context and dependencies
- Anticipate potential impacts on other parts of the system
This holistic approach is ABSOLUTELY ESSENTIAL for creating coherent and effective solutions.
2. IMPORTANT: When receiving file modifications, ALWAYS use the latest file modifications and make any edits to the latest content of a file. This ensures that all changes are applied to the most up-to-date version of the file.
3. The current working directory is \`${cwd}\`.
4. Wrap the content in opening and closing \`<boltArtifact>\` tags. These tags contain more specific \`<boltAction>\` elements.
5. Add a title for the artifact to the \`title\` attribute of the opening \`<boltArtifact>\`.
6. Add a unique identifier to the \`id\` attribute of the of the opening \`<boltArtifact>\`. For updates, reuse the prior identifier. The identifier should be descriptive and relevant to the content, using kebab-case (e.g., "example-code-snippet"). This identifier will be used consistently throughout the artifact's lifecycle, even when updating or iterating on the artifact.
7. Use \`<boltAction>\` tags to define specific actions to perform.
8. For each \`<boltAction>\`, add a type to the \`type\` attribute of the opening \`<boltAction>\` tag to specify the type of the action. Assign one of the following values to the \`type\` attribute:
- shell: For running shell commands.
- When Using \`npx\`, ALWAYS provide the \`--yes\` flag.
- When running multiple shell commands, use \`&&\` to run them sequentially.
- ULTRA IMPORTANT: Do NOT re-run a dev command if there is one that starts a dev server and new dependencies were installed or files updated! If a dev server has started already, assume that installing dependencies will be executed in a different process and will be picked up by the dev server.
- file: For writing new files or updating existing files. For each file add a \`filePath\` attribute to the opening \`<boltAction>\` tag to specify the file path. The content of the file artifact is the file contents. All file paths MUST BE relative to the current working directory.
9. The order of the actions is VERY IMPORTANT. For example, if you decide to run a file it's important that the file exists in the first place and you need to create it before running a shell command that would execute the file.
10. ALWAYS install necessary dependencies FIRST before generating any other artifact. If that requires a \`package.json\` then you should create that first!
IMPORTANT: Add all required dependencies to the \`package.json\` already and try to avoid \`npm i <pkg>\` if possible!
11. CRITICAL: Always provide the FULL, updated content of the artifact. This means:
- Include ALL code, even if parts are unchanged
- NEVER use placeholders like "// rest of the code remains the same..." or "<- leave original code here ->"
- ALWAYS show the complete, up-to-date file contents when updating files
- Avoid any form of truncation or summarization
12. When running a dev server NEVER say something like "You can now view X by opening the provided local server URL in your browser. The preview will be opened automatically or by the user manually!
13. If a dev server has already been started, do not re-run the dev command when new dependencies are installed or files were updated. Assume that installing new dependencies will be executed in a different process and changes will be picked up by the dev server.
14. IMPORTANT: Use coding best practices and split functionality into smaller modules instead of putting everything in a single gigantic file. Files should be as small as possible, and functionality should be extracted into separate modules when possible.
- Ensure code is clean, readable, and maintainable.
- Adhere to proper naming conventions and consistent formatting.
- Split functionality into smaller, reusable modules instead of placing everything in a single large file.
- Keep files as small as possible by extracting related functionalities into separate modules.
- Use imports to connect these modules together effectively.
</artifact_instructions>
</artifact_info>
NEVER use the word "artifact". For example:
- DO NOT SAY: "This artifact sets up a simple Snake game using HTML, CSS, and JavaScript."
- INSTEAD SAY: "We set up a simple Snake game using HTML, CSS, and JavaScript."
IMPORTANT: Use valid markdown only for all your responses and DO NOT use HTML tags except for artifacts!
ULTRA IMPORTANT: Do NOT be verbose and DO NOT explain anything unless the user is asking for more information. That is VERY important.
ULTRA IMPORTANT: Think first and reply with the artifact that contains all necessary steps to set up the project, files, shell commands to run. It is SUPER IMPORTANT to respond with this first.
Here are some examples of correct usage of artifacts:
<examples>
<example>
<user_query>Can you help me create a JavaScript function to calculate the factorial of a number?</user_query>
<assistant_response>
Certainly, I can help you create a JavaScript function to calculate the factorial of a number.
<boltArtifact id="factorial-function" title="JavaScript Factorial Function">
<boltAction type="file" filePath="index.js">
function factorial(n) {
...
}
...
</boltAction>
<boltAction type="shell">
node index.js
</boltAction>
</boltArtifact>
</assistant_response>
</example>
<example>
<user_query>Build a snake game</user_query>
<assistant_response>
Certainly! I'd be happy to help you build a snake game using JavaScript and HTML5 Canvas. This will be a basic implementation that you can later expand upon. Let's create the game step by step.
<boltArtifact id="snake-game" title="Snake Game in HTML and JavaScript">
<boltAction type="file" filePath="package.json">
{
"name": "snake",
"scripts": {
"dev": "vite"
}
...
}
</boltAction>
<boltAction type="shell">
npm install --save-dev vite
</boltAction>
<boltAction type="file" filePath="index.html">
...
</boltAction>
<boltAction type="shell">
npm run dev
</boltAction>
</boltArtifact>
Now you can play the Snake game by opening the provided local server URL in your browser. Use the arrow keys to control the snake. Eat the red food to grow and increase your score. The game ends if you hit the wall or your own tail.
</assistant_response>
</example>
<example>
<user_query>Make a bouncing ball with real gravity using React</user_query>
<assistant_response>
Certainly! I'll create a bouncing ball with real gravity using React. We'll use the react-spring library for physics-based animations.
<boltArtifact id="bouncing-ball-react" title="Bouncing Ball with Gravity in React">
<boltAction type="file" filePath="package.json">
{
"name": "bouncing-ball",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-spring": "^9.7.1"
},
"devDependencies": {
"@types/react": "^18.0.28",
"@types/react-dom": "^18.0.11",
"@vitejs/plugin-react": "^3.1.0",
"vite": "^4.2.0"
}
}
</boltAction>
<boltAction type="file" filePath="index.html">
...
</boltAction>
<boltAction type="file" filePath="src/main.jsx">
...
</boltAction>
<boltAction type="file" filePath="src/index.css">
...
</boltAction>
<boltAction type="file" filePath="src/App.jsx">
...
</boltAction>
<boltAction type="shell">
npm run dev
</boltAction>
</boltArtifact>
You can now view the bouncing ball animation in the preview. The ball will start falling from the top of the screen and bounce realistically when it hits the bottom.
</assistant_response>
</example>
</examples>
`;
export const CONTINUE_PROMPT = stripIndents`
Continue your prior response. IMPORTANT: Immediately begin from where you left off without any interruptions.
Do not repeat any content, including artifact and action tags.
`;
================================================
FILE: app/lib/.server/llm/stream-text.ts
================================================
import { streamText as _streamText, convertToCoreMessages } from 'ai';
import { getAPIKey } from '~/lib/.server/llm/api-key';
import { getAnthropicModel } from '~/lib/.server/llm/model';
import { MAX_TOKENS } from './constants';
import { getSystemPrompt } from './prompts';
interface ToolResult<Name extends string, Args, Result> {
toolCallId: string;
toolName: Name;
args: Args;
result: Result;
}
interface Message {
role: 'user' | 'assistant';
content: string;
toolInvocations?: ToolResult<string, unknown, unknown>[];
}
export type Messages = Message[];
export type StreamingOptions = Omit<Parameters<typeof _streamText>[0], 'model'>;
export function streamText(messages: Messages, env: Env, options?: StreamingOptions) {
return _streamText({
model: getAnthropicModel(getAPIKey(env)),
system: getSystemPrompt(),
maxTokens: MAX_TOKENS,
headers: {
'anthropic-beta': 'max-tokens-3-5-sonnet-2024-07-15',
},
messages: convertToCoreMessages(messages),
...options,
});
}
================================================
FILE: app/lib/.server/llm/switchable-stream.ts
================================================
export default class SwitchableStream extends TransformStream {
private _controller: TransformStreamDefaultController | null = null;
private _currentReader: ReadableStreamDefaultReader | null = null;
private _switches = 0;
constructor() {
let controllerRef: TransformStreamDefaultController | undefined;
super({
start(controller) {
controllerRef = controller;
},
});
if (controllerRef === undefined) {
throw new Error('Controller not properly initialized');
}
this._controller = controllerRef;
}
async switchSource(newStream: ReadableStream) {
if (this._currentReader) {
await this._currentReader.cancel();
}
this._currentReader = newStream.getReader();
this._pumpStream();
this._switches++;
}
private async _pumpStream() {
if (!this._currentReader || !this._controller) {
throw new Error('Stream is not properly initialized');
}
try {
while (true) {
const { done, value } = await this._currentReader.read();
if (done) {
break;
}
this._controller.enqueue(value);
}
} catch (error) {
console.log(error);
this._controller.error(error);
}
}
close() {
if (this._currentReader) {
this._currentReader.cancel();
}
this._controller?.terminate();
}
get switches() {
return this._switches;
}
}
================================================
FILE: app/lib/crypto.ts
================================================
const encoder = new TextEncoder();
const decoder = new TextDecoder();
const IV_LENGTH = 16;
export async function encrypt(key: string, data: string) {
const iv = crypto.getRandomValues(new Uint8Array(IV_LENGTH));
const cryptoKey = await getKey(key);
const ciphertext = await crypto.subtle.encrypt(
{
name: 'AES-CBC',
iv,
},
cryptoKey,
encoder.encode(data),
);
const bundle = new Uint8Array(IV_LENGTH + ciphertext.byteLength);
bundle.set(new Uint8Array(ciphertext));
bundle.set(iv, ciphertext.byteLength);
return decodeBase64(bundle);
}
export async function decrypt(key: string, payload: string) {
const bundle = encodeBase64(payload);
const iv = new Uint8Array(bundle.buffer, bundle.byteLength - IV_LENGTH);
const ciphertext = new Uint8Array(bundle.buffer, 0, bundle.byteLength - IV_LENGTH);
const cryptoKey = await getKey(key);
const plaintext = await crypto.subtle.decrypt(
{
name: 'AES-CBC',
iv,
},
cryptoKey,
ciphertext,
);
return decoder.decode(plaintext);
}
async function getKey(key: string) {
return await crypto.subtle.importKey('raw', encodeBase64(key), { name: 'AES-CBC' }, false, ['encrypt', 'decrypt']);
}
function decodeBase64(encoded: Uint8Array) {
const byteChars = Array.from(encoded, (byte) => String.fromCodePoint(byte));
return btoa(byteChars.join(''));
}
function encodeBase64(data: string) {
return Uint8Array.from(atob(data), (ch) => ch.codePointAt(0)!);
}
================================================
FILE: app/lib/fetch.ts
================================================
type CommonRequest = Omit<RequestInit, 'body'> & { body?: URLSearchParams };
export async function request(url: string, init?: CommonRequest) {
if (import.meta.env.DEV) {
const nodeFetch = await import('node-fetch');
const https = await import('node:https');
const agent = url.startsWith('https') ? new https.Agent({ rejectUnauthorized: false }) : undefined;
return nodeFetch.default(url, { ...init, agent });
}
return fetch(url, init);
}
================================================
FILE: app/lib/hooks/index.ts
================================================
export * from './useMessageParser';
export * from './usePromptEnhancer';
export * from './useShortcuts';
export * from './useSnapScroll';
================================================
FILE: app/lib/hooks/useMessageParser.ts
================================================
import type { Message } from 'ai';
import { useCallback, useState } from 'react';
import { StreamingMessageParser } from '~/lib/runtime/message-parser';
import { workbenchStore } from '~/lib/stores/workbench';
import { createScopedLogger } from '~/utils/logger';
const logger = createScopedLogger('useMessageParser');
const messageParser = new StreamingMessageParser({
callbacks: {
onArtifactOpen: (data) => {
logger.trace('onArtifactOpen', data);
workbenchStore.showWorkbench.set(true);
workbenchStore.addArtifact(data);
},
onArtifactClose: (data) => {
logger.trace('onArtifactClose');
workbenchStore.updateArtifact(data, { closed: true });
},
onActionOpen: (data) => {
logger.trace('onActionOpen', data.action);
// we only add shell actions when when the close tag got parsed because only then we have the content
if (data.action.type !== 'shell') {
workbenchStore.addAction(data);
}
},
onActionClose: (data) => {
logger.trace('onActionClose', data.action);
if (data.action.type === 'shell') {
workbenchStore.addAction(data);
}
workbenchStore.runAction(data);
},
},
});
export function useMessageParser() {
const [parsedMessages, setParsedMessages] = useState<{ [key: number]: string }>({});
const parseMessages = useCallback((messages: Message[], isLoading: boolean) => {
let reset = false;
if (import.meta.env.DEV && !isLoading) {
reset = true;
messageParser.reset();
}
for (const [index, message] of messages.entries()) {
if (message.role === 'assistant') {
const newParsedContent = messageParser.parse(message.id, message.content);
setParsedMessages((prevParsed) => ({
...prevParsed,
[index]: !reset ? (prevParsed[index] || '') + newParsedContent : newParsedContent,
}));
}
}
}, []);
return { parsedMessages, parseMessages };
}
================================================
FILE: app/lib/hooks/usePromptEnhancer.ts
================================================
import { useState } from 'react';
import { createScopedLogger } from '~/utils/logger';
const logger = createScopedLogger('usePromptEnhancement');
export function usePromptEnhancer() {
const [enhancingPrompt, setEnhancingPrompt] = useState(false);
const [promptEnhanced, setPromptEnhanced] = useState(false);
const resetEnhancer = () => {
setEnhancingPrompt(false);
setPromptEnhanced(false);
};
const enhancePrompt = async (input: string, setInput: (value: string) => void) => {
setEnhancingPrompt(true);
setPromptEnhanced(false);
const response = await fetch('/api/enhancer', {
method: 'POST',
body: JSON.stringify({
message: input,
}),
});
const reader = response.body?.getReader();
const originalInput = input;
if (reader) {
const decoder = new TextDecoder();
let _input = '';
let _error;
try {
setInput('');
while (true) {
const { value, done } = await reader.read();
if (done) {
break;
}
_input += decoder.decode(value);
logger.trace('Set input', _input);
setInput(_input);
}
} catch (error) {
_error = error;
setInput(originalInput);
} finally {
if (_error) {
logger.error(_error);
}
setEnhancingPrompt(false);
setPromptEnhanced(true);
setTimeout(() => {
setInput(_input);
});
}
}
};
return { enhancingPrompt, promptEnhanced, enhancePrompt, resetEnhancer };
}
================================================
FILE: app/lib/hooks/useShortcuts.ts
================================================
import { useStore } from '@nanostores/react';
import { useEffect } from 'react';
import { shortcutsStore, type Shortcuts } from '~/lib/stores/settings';
class ShortcutEventEmitter {
#emitter = new EventTarget();
dispatch(type: keyof Shortcuts) {
this.#emitter.dispatchEvent(new Event(type));
}
on(type: keyof Shortcuts, cb: VoidFunction) {
this.#emitter.addEventListener(type, cb);
return () => {
this.#emitter.removeEventListener(type, cb);
};
}
}
export const shortcutEventEmitter = new ShortcutEventEmitter();
export function useShortcuts(): void {
const shortcuts = useStore(shortcutsStore);
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent): void => {
const { key, ctrlKey, shiftKey, altKey, metaKey } = event;
for (const name in shortcuts) {
const shortcut = shortcuts[name as keyof Shortcuts];
if (
shortcut.key.toLowerCase() === key.toLowerCase() &&
(shortcut.ctrlOrMetaKey
? ctrlKey || metaKey
: (shortcut.ctrlKey === undefined || shortcut.ctrlKey === ctrlKey) &&
(shortcut.metaKey === undefined || shortcut.metaKey === metaKey)) &&
(shortcut.shiftKey === undefined || shortcut.shiftKey === shiftKey) &&
(shortcut.altKey === undefined || shortcut.altKey === altKey)
) {
shortcutEventEmitter.dispatch(name as keyof Shortcuts);
event.preventDefault();
event.stopPropagation();
shortcut.action();
break;
}
}
};
window.addEventListener('keydown', handleKeyDown);
return () => {
window.removeEventListener('keydown', handleKeyDown);
};
}, [shortcuts]);
}
================================================
FILE: app/lib/hooks/useSnapScroll.ts
================================================
import { useRef, useCallback } from 'react';
export function useSnapScroll() {
const autoScrollRef = useRef(true);
const scrollNodeRef = useRef<HTMLDivElement>();
const onScrollRef = useRef<() => void>();
const observerRef = useRef<ResizeObserver>();
const messageRef = useCallback((node: HTMLDivElement | null) => {
if (node) {
const observer = new ResizeObserver(() => {
if (autoScrollRef.current && scrollNodeRef.current) {
const { scrollHeight, clientHeight } = scrollNodeRef.current;
const scrollTarget = scrollHeight - clientHeight;
scrollNodeRef.current.scrollTo({
top: scrollTarget,
});
}
});
observer.observe(node);
} else {
observerRef.current?.disconnect();
observerRef.current = undefined;
}
}, []);
const scrollRef = useCallback((node: HTMLDivElement | null) => {
if (node) {
onScrollRef.current = () => {
const { scrollTop, scrollHeight, clientHeight } = node;
const scrollTarget = scrollHeight - clientHeight;
autoScrollRef.current = Math.abs(scrollTop - scrollTarget) <= 10;
};
node.addEventListener('scroll', onScrollRef.current);
scrollNodeRef.current = node;
} else {
if (onScrollRef.current) {
scrollNodeRef.current?.removeEventListener('scroll', onScrollRef.current);
}
scrollNodeRef.current = undefined;
onScrollRef.current = undefined;
}
}, []);
return [messageRef, scrollRef];
}
================================================
FILE: app/lib/persistence/ChatDescription.client.tsx
================================================
import { useStore } from '@nanostores/react';
import { description } from './useChatHistory';
export function ChatDescription() {
return useStore(description);
}
================================================
FILE: app/lib/persistence/db.ts
================================================
import type { Message } from 'ai';
import { createScopedLogger } from '~/utils/logger';
import type { ChatHistoryItem } from './useChatHistory';
const logger = createScopedLogger('ChatHistory');
// this is used at the top level and never rejects
export async function openDatabase(): Promise<IDBDatabase | undefined> {
return new Promise((resolve) => {
const request = indexedDB.open('boltHistory', 1);
request.onupgradeneeded = (event: IDBVersionChangeEvent) => {
const db = (event.target as IDBOpenDBRequest).result;
if (!db.objectStoreNames.contains('chats')) {
const store = db.createObjectStore('chats', { keyPath: 'id' });
store.createIndex('id', 'id', { unique: true });
store.createIndex('urlId', 'urlId', { unique: true });
}
};
request.onsuccess = (event: Event) => {
resolve((event.target as IDBOpenDBRequest).result);
};
request.onerror = (event: Event) => {
resolve(undefined);
logger.error((event.target as IDBOpenDBRequest).error);
};
});
}
export async function getAll(db: IDBDatabase): Promise<ChatHistoryItem[]> {
return new Promise((resolve, reject) => {
const transaction = db.transaction('chats', 'readonly');
const store = transaction.objectStore('chats');
const request = store.getAll();
request.onsuccess = () => resolve(request.result as ChatHistoryItem[]);
request.onerror = () => reject(request.error);
});
}
export async function setMessages(
db: IDBDatabase,
id: string,
messages: Message[],
urlId?: string,
description?: string,
): Promise<void> {
return new Promise((resolve, reject) => {
const transaction = db.transaction('chats', 'readwrite');
const store = transaction.objectStore('chats');
const request = store.put({
id,
messages,
urlId,
description,
timestamp: new Date().toISOString(),
});
request.onsuccess = () => resolve();
request.onerror = () => reject(request.error);
});
}
export async function getMessages(db: IDBDatabase, id: string): Promise<ChatHistoryItem> {
return (await getMessagesById(db, id)) || (await getMessagesByUrlId(db, id));
}
export async function getMessagesByUrlId(db: IDBDatabase, id: string): Promise<ChatHistoryItem> {
return new Promise((resolve, reject) => {
const transaction = db.transaction('chats', 'readonly');
const store = transaction.objectStore('chats');
const index = store.index('urlId');
const request = index.get(id);
request.onsuccess = () => resolve(request.result as ChatHistoryItem);
request.onerror = () => reject(request.error);
});
}
export async function getMessagesById(db: IDBDatabase, id: string): Promise<ChatHistoryItem> {
return new Promise((resolve, reject) => {
const transaction = db.transaction('chats', 'readonly');
const store = transaction.objectStore('chats');
const request = store.get(id);
request.onsuccess = () => resolve(request.result as ChatHistoryItem);
request.onerror = () => reject(request.error);
});
}
export async function deleteById(db: IDBDatabase, id: string): Promise<void> {
return new Promise((resolve, reject) => {
const transaction = db.transaction('chats', 'readwrite');
const store = transaction.objectStore('chats');
const request = store.delete(id);
request.onsuccess = () => resolve(undefined);
request.onerror = () => reject(request.error);
});
}
export async function getNextId(db: IDBDatabase): Promise<string> {
return new Promise((resolve, reject) => {
const transaction = db.transaction('chats', 'readonly');
const store = transaction.objectStore('chats');
const request = store.getAllKeys();
request.onsuccess = () => {
const highestId = request.result.reduce((cur, acc) => Math.max(+cur, +acc), 0);
resolve(String(+highestId + 1));
};
request.onerror = () => reject(request.error);
});
}
export async function getUrlId(db: IDBDatabase, id: string): Promise<string> {
const idList = await getUrlIds(db);
if (!idList.includes(id)) {
return id;
} else {
let i = 2;
while (idList.includes(`${id}-${i}`)) {
i++;
}
return `${id}-${i}`;
}
}
async function getUrlIds(db: IDBDatabase): Promise<string[]> {
return new Promise((resolve, reject) => {
const transaction = db.transaction('chats', 'readonly');
const store = transaction.objectStore('chats');
const idList: string[] = [];
const request = store.openCursor();
request.onsuccess = (event: Event) => {
const cursor = (event.target as IDBRequest<IDBCursorWithValue>).result;
if (cursor) {
idList.push(cursor.value.urlId);
cursor.continue();
} else {
resolve(idList);
}
};
request.onerror = () => {
reject(request.error);
};
});
}
================================================
FILE: app/lib/persistence/index.ts
================================================
export * from './db';
export * from './useChatHistory';
================================================
FILE: app/lib/persistence/useChatHistory.ts
================================================
import { useLoaderData, useNavigate } from '@remix-run/react';
import { useState, useEffect } from 'react';
import { atom } from 'nanostores';
import type { Message } from 'ai';
import { toast } from 'react-toastify';
import { workbenchStore } from '~/lib/stores/workbench';
import { getMessages, getNextId, getUrlId, openDatabase, setMessages } from './db';
export interface ChatHistoryItem {
id: string;
urlId?: string;
description?: string;
messages: Message[];
timestamp: string;
}
const persistenceEnabled = !import.meta.env.VITE_DISABLE_PERSISTENCE;
export const db = persistenceEnabled ? await openDatabase() : undefined;
export const chatId = atom<string | undefined>(undefined);
export const description = atom<string | undefined>(undefined);
export function useChatHistory() {
const navigate = useNavigate();
const { id: mixedId } = useLoaderData<{ id?: string }>();
const [initialMessages, setInitialMessages] = useState<Message[]>([]);
const [ready, setReady] = useState<boolean>(false);
const [urlId, setUrlId] = useState<string | undefined>();
useEffect(() => {
if (!db) {
setReady(true);
if (persistenceEnabled) {
toast.error(`Chat persistence is unavailable`);
}
return;
}
if (mixedId) {
getMessages(db, mixedId)
.then((storedMessages) => {
if (storedMessages && storedMessages.messages.length > 0) {
setInitialMessages(storedMessages.messages);
setUrlId(storedMessages.urlId);
description.set(storedMessages.description);
chatId.set(storedMessages.id);
} else {
navigate(`/`, { replace: true });
}
setReady(true);
})
.catch((error) => {
toast.error(error.message);
});
}
}, []);
return {
ready: !mixedId || ready,
initialMessages,
storeMessageHistory: async (messages: Message[]) => {
if (!db || messages.length === 0) {
return;
}
const { firstArtifact } = workbenchStore;
if (!urlId && firstArtifact?.id) {
const urlId = await getUrlId(db, firstArtifact.id);
navigateChat(urlId);
setUrlId(urlId);
}
if (!description.get() && firstArtifact?.title) {
description.set(firstArtifact?.title);
}
if (initialMessages.length === 0 && !chatId.get()) {
const nextId = await getNextId(db);
chatId.set(nextId);
if (!urlId) {
navigateChat(nextId);
}
}
await setMessages(db, chatId.get() as string, messages, urlId, description.get());
},
};
}
function navigateChat(nextId: string) {
/**
* FIXME: Using the intended navigate function causes a rerender for <Chat /> that breaks the app.
*
* `navigate(`/chat/${nextId}`, { replace: true });`
*/
const url = new URL(window.location.href);
url.pathname = `/chat/${nextId}`;
window.history.replaceState({}, '', url);
}
================================================
FILE: app/lib/runtime/__snapshots__/message-parser.spec.ts.snap
================================================
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`StreamingMessageParser > valid artifacts with actions > should correctly parse chunks and strip out bolt artifacts (0) > onActionClose 1`] = `
{
"action": {
"content": "npm install",
"type": "shell",
},
"actionId": "0",
"artifactId": "artifact_1",
"messageId": "message_1",
}
`;
exports[`StreamingMessageParser > valid artifacts with actions > should correctly parse chunks and strip out bolt artifacts (0) > onActionOpen 1`] = `
{
"action": {
"content": "",
"type": "shell",
},
"actionId": "0",
"artifactId": "artifact_1",
"messageId": "message_1",
}
`;
exports[`StreamingMessageParser > valid artifacts with actions > should correctly parse chunks and strip out bolt artifacts (0) > onArtifactClose 1`] = `
{
"id": "artifact_1",
"messageId": "message_1",
"title": "Some title",
}
`;
exports[`StreamingMessageParser > valid artifacts with actions > should correctly parse chunks and strip out bolt artifacts (0) > onArtifactOpen 1`] = `
{
"id": "artifact_1",
"messageId": "message_1",
"title": "Some title",
}
`;
exports[`StreamingMessageParser > valid artifacts with actions > should correctly parse chunks and strip out bolt artifacts (1) > onActionClose 1`] = `
{
"action": {
"content": "npm install",
"type": "shell",
},
"actionId": "0",
"artifactId": "artifact_1",
"messageId": "message_1",
}
`;
exports[`StreamingMessageParser > valid artifacts with actions > should correctly parse chunks and strip out bolt artifacts (1) > onActionClose 2`] = `
{
"action": {
"content": "some content
",
"filePath": "index.js",
"type": "file",
},
"actionId": "1",
"artifactId": "artifact_1",
"messageId": "message_1",
}
`;
exports[`StreamingMessageParser > valid artifacts with actions > should correctly parse chunks and strip out bolt artifacts (1) > onActionOpen 1`] = `
{
"action": {
"content": "",
"type": "shell",
},
"actionId": "0",
"artifactId": "artifact_1",
"messageId": "message_1",
}
`;
exports[`StreamingMessageParser > valid artifacts with actions > should correctly parse chunks and strip out bolt artifacts (1) > onActionOpen 2`] = `
{
"action": {
"content": "",
"filePath": "index.js",
"type": "file",
},
"actionId": "1",
"artifactId": "artifact_1",
"messageId": "message_1",
}
`;
exports[`StreamingMessageParser > valid artifacts with actions > should correctly parse chunks and strip out bolt artifacts (1) > onArtifactClose 1`] = `
{
"id": "artifact_1",
"messageId": "message_1",
"title": "Some title",
}
`;
exports[`StreamingMessageParser > valid artifacts with actions > should correctly parse chunks and strip out bolt artifacts (1) > onArtifactOpen 1`] = `
{
"id": "artifact_1",
"messageId": "message_1",
"title": "Some title",
}
`;
exports[`StreamingMessageParser > valid artifacts without actions > should correctly parse chunks and strip out bolt artifacts (0) > onArtifactClose 1`] = `
{
"id": "artifact_1",
"messageId": "message_1",
"title": "Some title",
}
`;
exports[`StreamingMessageParser > valid artifacts without actions > should correctly parse chunks and strip out bolt artifacts (0) > onArtifactOpen 1`] = `
{
"id": "artifact_1",
"messageId": "message_1",
"title": "Some title",
}
`;
exports[`StreamingMessageParser > valid artifacts without actions > should correctly parse chunks and strip out bolt artifacts (1) > onArtifactClose 1`] = `
{
"id": "artifact_1",
"messageId": "message_1",
"title": "Some title",
}
`;
exports[`StreamingMessageParser > valid artifacts without actions > should correctly parse chunks and strip out bolt artifacts (1) > onArtifactOpen 1`] = `
{
"id": "artifact_1",
"messageId": "message_1",
"title": "Some title",
}
`;
exports[`StreamingMessageParser > valid artifacts without actions > should correctly parse chunks and strip out bolt artifacts (2) > onArtifactClose 1`] = `
{
"id": "artifact_1",
"messageId": "message_1",
"title": "Some title",
}
`;
exports[`StreamingMessageParser > valid artifacts without actions > should correctly parse chunks and strip out bolt artifacts (2) > onArtifactOpen 1`] = `
{
"id": "artifact_1",
"messageId": "message_1",
"title": "Some title",
}
`;
exports[`StreamingMessageParser > valid artifacts without actions > should correctly parse chunks and strip out bolt artifacts (3) > onArtifactClose 1`] = `
{
"id": "artifact_1",
"messageId": "message_1",
"title": "Some title",
}
`;
exports[`StreamingMessageParser > valid artifacts without actions > should correctly parse chunks and strip out bolt artifacts (3) > onArtifactOpen 1`] = `
{
"id": "artifact_1",
"messageId": "message_1",
"title": "Some title",
}
`;
exports[`StreamingMessageParser > valid artifacts without actions > should correctly parse chunks and strip out bolt artifacts (4) > onArtifactClose 1`] = `
{
"id": "artifact_1",
"messageId": "message_1",
"title": "Some title",
}
`;
exports[`StreamingMessageParser > valid artifacts without actions > should correctly parse chunks and strip out bolt artifacts (4) > onArtifactOpen 1`] = `
{
"id": "artifact_1",
"messageId": "message_1",
"title": "Some title",
}
`;
exports[`StreamingMessageParser > valid artifacts without actions > should correctly parse chunks and strip out bolt artifacts (5) > onArtifactClose 1`] = `
{
"id": "artifact_1",
"messageId": "message_1",
"title": "Some title",
}
`;
exports[`StreamingMessageParser > valid artifacts without actions > should correctly parse chunks and strip out bolt artifacts (5) > onArtifactOpen 1`] = `
{
"id": "artifact_1",
"messageId": "message_1",
"title": "Some title",
}
`;
exports[`StreamingMessageParser > valid artifacts without actions > should correctly parse chunks and strip out bolt artifacts (6) > onArtifactClose 1`] = `
{
"id": "artifact_1",
"messageId": "message_1",
"title": "Some title",
}
`;
exports[`StreamingMessageParser > valid artifacts without actions > should correctly parse chunks and strip out bolt artifacts (6) > onArtifactOpen 1`] = `
{
"id": "artifact_1",
"messageId": "message_1",
"title": "Some title",
}
`;
================================================
FILE: app/lib/runtime/action-runner.ts
================================================
import { WebContainer } from '@webcontainer/api';
import { map, type MapStore } from 'nanostores';
import * as nodePath from 'node:path';
import type { BoltAction } from '~/types/actions';
import { createScopedLogger } from '~/utils/logger';
import { unreachable } from '~/utils/unreachable';
import type { ActionCallbackData } from './message-parser';
const logger = createScopedLogger('ActionRunner');
export type ActionStatus = 'pending' | 'running' | 'complete' | 'aborted' | 'failed';
export type BaseActionState = BoltAction & {
status: Exclude<ActionStatus, 'failed'>;
abort: () => void;
executed: boolean;
abortSignal: AbortSignal;
};
export type FailedActionState = BoltAction &
Omit<BaseActionState, 'status'> & {
status: Extract<ActionStatus, 'failed'>;
error: string;
};
export type ActionState = BaseActionState | FailedActionState;
type BaseActionUpdate = Partial<Pick<BaseActionState, 'status' | 'abort' | 'executed'>>;
export type ActionStateUpdate =
| BaseActionUpdate
| (Omit<BaseActionUpdate, 'status'> & { status: 'failed'; error: string });
type ActionsMap = MapStore<Record<string, ActionState>>;
export class ActionRunner {
#webcontainer: Promise<WebContainer>;
#currentExecutionPromise: Promise<void> = Promise.resolve();
actions: ActionsMap = map({});
constructor(webcontainerPromise: Promise<WebContainer>) {
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;
cons
gitextract_pf42qdrs/ ├── .editorconfig ├── .github/ │ ├── ISSUE_TEMPLATE/ │ │ ├── bug_report.yml │ │ ├── config.yml │ │ └── feature_request.md │ ├── actions/ │ │ └── setup-and-build/ │ │ └── action.yaml │ └── workflows/ │ ├── ci.yaml │ └── semantic-pr.yaml ├── .gitignore ├── .husky/ │ └── commit-msg ├── .prettierignore ├── .prettierrc ├── .tool-versions ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── app/ │ ├── components/ │ │ ├── chat/ │ │ │ ├── Artifact.tsx │ │ │ ├── AssistantMessage.tsx │ │ │ ├── BaseChat.module.scss │ │ │ ├── BaseChat.tsx │ │ │ ├── Chat.client.tsx │ │ │ ├── CodeBlock.module.scss │ │ │ ├── CodeBlock.tsx │ │ │ ├── Markdown.module.scss │ │ │ ├── Markdown.tsx │ │ │ ├── Messages.client.tsx │ │ │ ├── SendButton.client.tsx │ │ │ └── UserMessage.tsx │ │ ├── editor/ │ │ │ └── codemirror/ │ │ │ ├── BinaryContent.tsx │ │ │ ├── CodeMirrorEditor.tsx │ │ │ ├── cm-theme.ts │ │ │ ├── indent.ts │ │ │ └── languages.ts │ │ ├── header/ │ │ │ ├── Header.tsx │ │ │ └── HeaderActionButtons.client.tsx │ │ ├── sidebar/ │ │ │ ├── HistoryItem.tsx │ │ │ ├── Menu.client.tsx │ │ │ └── date-binning.ts │ │ ├── ui/ │ │ │ ├── Dialog.tsx │ │ │ ├── IconButton.tsx │ │ │ ├── LoadingDots.tsx │ │ │ ├── PanelHeader.tsx │ │ │ ├── PanelHeaderButton.tsx │ │ │ ├── Slider.tsx │ │ │ └── ThemeSwitch.tsx │ │ └── workbench/ │ │ ├── EditorPanel.tsx │ │ ├── FileBreadcrumb.tsx │ │ ├── FileTree.tsx │ │ ├── PortDropdown.tsx │ │ ├── Preview.tsx │ │ ├── Workbench.client.tsx │ │ └── terminal/ │ │ ├── Terminal.tsx │ │ └── theme.ts │ ├── entry.client.tsx │ ├── entry.server.tsx │ ├── lib/ │ │ ├── .server/ │ │ │ └── llm/ │ │ │ ├── api-key.ts │ │ │ ├── constants.ts │ │ │ ├── model.ts │ │ │ ├── prompts.ts │ │ │ ├── stream-text.ts │ │ │ └── switchable-stream.ts │ │ ├── crypto.ts │ │ ├── fetch.ts │ │ ├── hooks/ │ │ │ ├── index.ts │ │ │ ├── useMessageParser.ts │ │ │ ├── usePromptEnhancer.ts │ │ │ ├── useShortcuts.ts │ │ │ └── useSnapScroll.ts │ │ ├── persistence/ │ │ │ ├── ChatDescription.client.tsx │ │ │ ├── db.ts │ │ │ ├── index.ts │ │ │ └── useChatHistory.ts │ │ ├── runtime/ │ │ │ ├── __snapshots__/ │ │ │ │ └── message-parser.spec.ts.snap │ │ │ ├── action-runner.ts │ │ │ ├── message-parser.spec.ts │ │ │ └── message-parser.ts │ │ ├── stores/ │ │ │ ├── chat.ts │ │ │ ├── editor.ts │ │ │ ├── files.ts │ │ │ ├── previews.ts │ │ │ ├── settings.ts │ │ │ ├── terminal.ts │ │ │ ├── theme.ts │ │ │ └── workbench.ts │ │ └── webcontainer/ │ │ ├── auth.client.ts │ │ └── index.ts │ ├── root.tsx │ ├── routes/ │ │ ├── _index.tsx │ │ ├── api.chat.ts │ │ ├── api.enhancer.ts │ │ └── chat.$id.tsx │ ├── styles/ │ │ ├── animations.scss │ │ ├── components/ │ │ │ ├── code.scss │ │ │ ├── editor.scss │ │ │ ├── resize-handle.scss │ │ │ ├── terminal.scss │ │ │ └── toast.scss │ │ ├── index.scss │ │ ├── variables.scss │ │ └── z-index.scss │ ├── types/ │ │ ├── actions.ts │ │ ├── artifact.ts │ │ ├── terminal.ts │ │ └── theme.ts │ └── utils/ │ ├── buffer.ts │ ├── classNames.ts │ ├── constants.ts │ ├── debounce.ts │ ├── diff.ts │ ├── easings.ts │ ├── logger.ts │ ├── markdown.ts │ ├── mobile.ts │ ├── promises.ts │ ├── react.ts │ ├── shell.ts │ ├── stripIndent.ts │ ├── terminal.ts │ └── unreachable.ts ├── bindings.sh ├── eslint.config.mjs ├── functions/ │ └── [[path]].ts ├── load-context.ts ├── package.json ├── tsconfig.json ├── types/ │ └── istextorbinary.d.ts ├── uno.config.ts ├── vite.config.ts ├── worker-configuration.d.ts └── wrangler.toml
SYMBOL INDEX (329 symbols across 86 files)
FILE: app/components/chat/Artifact.tsx
type ArtifactProps (line 23) | interface ArtifactProps {
type ShellCodeBlockProps (line 104) | interface ShellCodeBlockProps {
function ShellCodeBlock (line 109) | function ShellCodeBlock({ classsName, code }: ShellCodeBlockProps) {
type ActionListProps (line 123) | interface ActionListProps {
function getIconColor (line 192) | function getIconColor(status: ActionState['status']) {
FILE: app/components/chat/AssistantMessage.tsx
type AssistantMessageProps (line 4) | interface AssistantMessageProps {
FILE: app/components/chat/BaseChat.tsx
type BaseChatProps (line 13) | interface BaseChatProps {
constant EXAMPLE_PROMPTS (line 30) | const EXAMPLE_PROMPTS = [
constant TEXTAREA_MIN_HEIGHT (line 38) | const TEXTAREA_MIN_HEIGHT = 76;
FILE: app/components/chat/Chat.client.tsx
function Chat (line 23) | function Chat() {
type ChatProps (line 62) | interface ChatProps {
FILE: app/components/chat/CodeBlock.tsx
type CodeBlockProps (line 10) | interface CodeBlockProps {
FILE: app/components/chat/Markdown.tsx
type MarkdownProps (line 13) | interface MarkdownProps {
FILE: app/components/chat/Messages.client.tsx
type MessagesProps (line 7) | interface MessagesProps {
FILE: app/components/chat/SendButton.client.tsx
type SendButtonProps (line 3) | interface SendButtonProps {
function SendButton (line 11) | function SendButton({ show, isStreaming, onClick }: SendButtonProps) {
FILE: app/components/chat/UserMessage.tsx
type UserMessageProps (line 4) | interface UserMessageProps {
function UserMessage (line 8) | function UserMessage({ content }: UserMessageProps) {
function sanitizeUserMessage (line 16) | function sanitizeUserMessage(content: string) {
FILE: app/components/editor/codemirror/BinaryContent.tsx
function BinaryContent (line 1) | function BinaryContent() {
FILE: app/components/editor/codemirror/CodeMirrorEditor.tsx
type EditorDocument (line 31) | interface EditorDocument {
type EditorSettings (line 38) | interface EditorSettings {
type TextEditorDocument (line 44) | type TextEditorDocument = EditorDocument & {
type ScrollPosition (line 48) | interface ScrollPosition {
type EditorUpdate (line 53) | interface EditorUpdate {
type OnChangeCallback (line 58) | type OnChangeCallback = (update: EditorUpdate) => void;
type OnScrollCallback (line 59) | type OnScrollCallback = (position: ScrollPosition) => void;
type OnSaveCallback (line 60) | type OnSaveCallback = () => void;
type Props (line 62) | interface Props {
type EditorStates (line 77) | type EditorStates = Map<string, EditorState>;
method update (line 83) | update(_tooltips, transaction) {
method create (line 104) | create() {
method update (line 107) | update(value, transaction) {
method dispatchTransactions (line 165) | dispatchTransactions(transactions) {
function newEditorState (line 269) | function newEditorState(
function setNoDocument (line 363) | function setNoDocument(view: EditorView) {
function setEditorDocument (line 376) | function setEditorDocument(
function getReadOnlyTooltip (line 437) | function getReadOnlyTooltip(state: EditorState) {
FILE: app/components/editor/codemirror/cm-theme.ts
function getTheme (line 10) | function getTheme(theme: Theme, settings: EditorSettings = {}): Extension {
function reconfigureTheme (line 17) | function reconfigureTheme(theme: Theme) {
function getEditorTheme (line 21) | function getEditorTheme(settings: EditorSettings) {
function getLightTheme (line 186) | function getLightTheme() {
function getDarkTheme (line 190) | function getDarkTheme() {
FILE: app/components/editor/codemirror/indent.ts
function indentMore (line 12) | function indentMore({ state, dispatch }: EditorView) {
function changeBySelectedLine (line 29) | function changeBySelectedLine(
FILE: app/components/editor/codemirror/languages.ts
method load (line 7) | async load() {
method load (line 14) | async load() {
method load (line 21) | async load() {
method load (line 28) | async load() {
method load (line 35) | async load() {
method load (line 42) | async load() {
method load (line 49) | async load() {
method load (line 56) | async load() {
method load (line 63) | async load() {
method load (line 70) | async load() {
method load (line 77) | async load() {
method load (line 84) | async load() {
method load (line 91) | async load() {
function getLanguage (line 97) | async function getLanguage(fileName: string) {
FILE: app/components/header/Header.tsx
function Header (line 8) | function Header() {
FILE: app/components/header/HeaderActionButtons.client.tsx
type HeaderActionButtonsProps (line 6) | interface HeaderActionButtonsProps {}
function HeaderActionButtons (line 8) | function HeaderActionButtons({}: HeaderActionButtonsProps) {
type ButtonProps (line 46) | interface ButtonProps {
function Button (line 53) | function Button({ active = false, disabled = false, children, onClick }:...
FILE: app/components/sidebar/HistoryItem.tsx
type HistoryItemProps (line 5) | interface HistoryItemProps {
function HistoryItem (line 10) | function HistoryItem({ item, onDelete }: HistoryItemProps) {
FILE: app/components/sidebar/Menu.client.tsx
type DialogContent (line 34) | type DialogContent = { type: 'delete'; item: ChatHistoryItem } | null;
function Menu (line 36) | function Menu() {
FILE: app/components/sidebar/date-binning.ts
type Bin (line 4) | type Bin = { category: string; items: ChatHistoryItem[] };
function binDates (line 6) | function binDates(_list: ChatHistoryItem[]) {
function dateCategory (line 32) | function dateCategory(date: Date) {
FILE: app/components/ui/Dialog.tsx
type DialogButtonProps (line 43) | interface DialogButtonProps {
type DialogProps (line 95) | interface DialogProps {
FILE: app/components/ui/IconButton.tsx
type IconSize (line 4) | type IconSize = 'sm' | 'md' | 'lg' | 'xl' | 'xxl';
type BaseIconButtonProps (line 6) | interface BaseIconButtonProps {
type IconButtonWithoutChildrenProps (line 16) | type IconButtonWithoutChildrenProps = {
type IconButtonWithChildrenProps (line 21) | type IconButtonWithChildrenProps = {
type IconButtonProps (line 26) | type IconButtonProps = IconButtonWithoutChildrenProps | IconButtonWithCh...
function getIconSize (line 65) | function getIconSize(size: IconSize) {
FILE: app/components/ui/LoadingDots.tsx
type LoadingDotsProps (line 3) | interface LoadingDotsProps {
FILE: app/components/ui/PanelHeader.tsx
type PanelHeaderProps (line 4) | interface PanelHeaderProps {
FILE: app/components/ui/PanelHeaderButton.tsx
type PanelHeaderButtonProps (line 4) | interface PanelHeaderButtonProps {
FILE: app/components/ui/Slider.tsx
type SliderOption (line 7) | interface SliderOption<T> {
type SliderOptions (line 12) | interface SliderOptions<T> {
type SliderProps (line 17) | interface SliderProps<T> {
type SliderButtonProps (line 38) | interface SliderButtonProps {
FILE: app/components/ui/ThemeSwitch.tsx
type ThemeSwitchProps (line 6) | interface ThemeSwitchProps {
FILE: app/components/workbench/EditorPanel.tsx
type EditorPanelProps (line 27) | interface EditorPanelProps {
constant MAX_TERMINALS (line 40) | const MAX_TERMINALS = 3;
constant DEFAULT_TERMINAL_SIZE (line 41) | const DEFAULT_TERMINAL_SIZE = 25;
constant DEFAULT_EDITOR_SIZE (line 42) | const DEFAULT_EDITOR_SIZE = 100 - DEFAULT_TERMINAL_SIZE;
FILE: app/components/workbench/FileBreadcrumb.tsx
constant WORK_DIR_REGEX (line 11) | const WORK_DIR_REGEX = new RegExp(`^${WORK_DIR.split('/').slice(0, -1).j...
type FileBreadcrumbProps (line 13) | interface FileBreadcrumbProps {
FILE: app/components/workbench/FileTree.tsx
constant NODE_PADDING_LEFT (line 8) | const NODE_PADDING_LEFT = 8;
constant DEFAULT_HIDDEN_FILES (line 9) | const DEFAULT_HIDDEN_FILES = [/\/node_modules\//, /\/\.next/, /\/\.astro/];
type Props (line 11) | interface Props {
type FolderProps (line 155) | interface FolderProps {
function Folder (line 162) | function Folder({ folder: { depth, name }, collapsed, selected = false, ...
type FileProps (line 182) | interface FileProps {
function File (line 189) | function File({ file: { depth, name }, onClick, selected, unsavedChanges...
type ButtonProps (line 214) | interface ButtonProps {
function NodeButton (line 222) | function NodeButton({ depth, iconClasses, onClick, className, children }...
type Node (line 238) | type Node = FileNode | FolderNode;
type BaseNode (line 240) | interface BaseNode {
type FileNode (line 247) | interface FileNode extends BaseNode {
type FolderNode (line 251) | interface FolderNode extends BaseNode {
function buildFileList (line 255) | function buildFileList(
function isHiddenFile (line 321) | function isHiddenFile(filePath: string, fileName: string, hiddenFiles: A...
function sortFileList (line 344) | function sortFileList(rootFolder: string, nodeList: Node[], hideRoot: bo...
function compareNodes (line 403) | function compareNodes(a: Node, b: Node): number {
FILE: app/components/workbench/PortDropdown.tsx
type PortDropdownProps (line 5) | interface PortDropdownProps {
FILE: app/components/workbench/Workbench.client.tsx
type WorkspaceProps (line 20) | interface WorkspaceProps {
type ViewProps (line 177) | interface ViewProps extends HTMLMotionProps<'div'> {
FILE: app/components/workbench/terminal/Terminal.tsx
type TerminalRef (line 11) | interface TerminalRef {
type TerminalProps (line 15) | interface TerminalProps {
FILE: app/components/workbench/terminal/theme.ts
function getTerminalTheme (line 6) | function getTerminalTheme(overrides?: ITheme): ITheme {
FILE: app/entry.server.tsx
function handleRequest (line 9) | async function handleRequest(
FILE: app/lib/.server/llm/api-key.ts
function getAPIKey (line 3) | function getAPIKey(cloudflareEnv: Env) {
FILE: app/lib/.server/llm/constants.ts
constant MAX_TOKENS (line 2) | const MAX_TOKENS = 8192;
constant MAX_RESPONSE_SEGMENTS (line 5) | const MAX_RESPONSE_SEGMENTS = 2;
FILE: app/lib/.server/llm/model.ts
function getAnthropicModel (line 3) | function getAnthropicModel(apiKey: string) {
FILE: app/lib/.server/llm/prompts.ts
constant CONTINUE_PROMPT (line 281) | const CONTINUE_PROMPT = stripIndents`
FILE: app/lib/.server/llm/stream-text.ts
type ToolResult (line 7) | interface ToolResult<Name extends string, Args, Result> {
type Message (line 14) | interface Message {
type Messages (line 20) | type Messages = Message[];
type StreamingOptions (line 22) | type StreamingOptions = Omit<Parameters<typeof _streamText>[0], 'model'>;
function streamText (line 24) | function streamText(messages: Messages, env: Env, options?: StreamingOpt...
FILE: app/lib/.server/llm/switchable-stream.ts
class SwitchableStream (line 1) | class SwitchableStream extends TransformStream {
method constructor (line 6) | constructor() {
method switchSource (line 22) | async switchSource(newStream: ReadableStream) {
method _pumpStream (line 34) | private async _pumpStream() {
method close (line 55) | close() {
method switches (line 63) | get switches() {
FILE: app/lib/crypto.ts
constant IV_LENGTH (line 3) | const IV_LENGTH = 16;
function encrypt (line 5) | async function encrypt(key: string, data: string) {
function decrypt (line 26) | async function decrypt(key: string, payload: string) {
function getKey (line 46) | async function getKey(key: string) {
function decodeBase64 (line 50) | function decodeBase64(encoded: Uint8Array) {
function encodeBase64 (line 56) | function encodeBase64(data: string) {
FILE: app/lib/fetch.ts
type CommonRequest (line 1) | type CommonRequest = Omit<RequestInit, 'body'> & { body?: URLSearchParam...
function request (line 3) | async function request(url: string, init?: CommonRequest) {
FILE: app/lib/hooks/useMessageParser.ts
function useMessageParser (line 42) | function useMessageParser() {
FILE: app/lib/hooks/usePromptEnhancer.ts
function usePromptEnhancer (line 6) | function usePromptEnhancer() {
FILE: app/lib/hooks/useShortcuts.ts
class ShortcutEventEmitter (line 5) | class ShortcutEventEmitter {
method dispatch (line 8) | dispatch(type: keyof Shortcuts) {
method on (line 12) | on(type: keyof Shortcuts, cb: VoidFunction) {
function useShortcuts (line 23) | function useShortcuts(): void {
FILE: app/lib/hooks/useSnapScroll.ts
function useSnapScroll (line 3) | function useSnapScroll() {
FILE: app/lib/persistence/ChatDescription.client.tsx
function ChatDescription (line 4) | function ChatDescription() {
FILE: app/lib/persistence/db.ts
function openDatabase (line 8) | async function openDatabase(): Promise<IDBDatabase | undefined> {
function getAll (line 33) | async function getAll(db: IDBDatabase): Promise<ChatHistoryItem[]> {
function setMessages (line 44) | async function setMessages(
function getMessages (line 68) | async function getMessages(db: IDBDatabase, id: string): Promise<ChatHis...
function getMessagesByUrlId (line 72) | async function getMessagesByUrlId(db: IDBDatabase, id: string): Promise<...
function getMessagesById (line 84) | async function getMessagesById(db: IDBDatabase, id: string): Promise<Cha...
function deleteById (line 95) | async function deleteById(db: IDBDatabase, id: string): Promise<void> {
function getNextId (line 106) | async function getNextId(db: IDBDatabase): Promise<string> {
function getUrlId (line 121) | async function getUrlId(db: IDBDatabase, id: string): Promise<string> {
function getUrlIds (line 137) | async function getUrlIds(db: IDBDatabase): Promise<string[]> {
FILE: app/lib/persistence/useChatHistory.ts
type ChatHistoryItem (line 9) | interface ChatHistoryItem {
function useChatHistory (line 24) | function useChatHistory() {
function navigateChat (line 99) | function navigateChat(nextId: string) {
FILE: app/lib/runtime/action-runner.ts
type ActionStatus (line 11) | type ActionStatus = 'pending' | 'running' | 'complete' | 'aborted' | 'fa...
type BaseActionState (line 13) | type BaseActionState = BoltAction & {
type FailedActionState (line 20) | type FailedActionState = BoltAction &
type ActionState (line 26) | type ActionState = BaseActionState | FailedActionState;
type BaseActionUpdate (line 28) | type BaseActionUpdate = Partial<Pick<BaseActionState, 'status' | 'abort'...
type ActionStateUpdate (line 30) | type ActionStateUpdate =
type ActionsMap (line 34) | type ActionsMap = MapStore<Record<string, ActionState>>;
class ActionRunner (line 36) | class ActionRunner {
method constructor (line 42) | constructor(webcontainerPromise: Promise<WebContainer>) {
method addAction (line 46) | addAction(data: ActionCallbackData) {
method runAction (line 75) | async runAction(data: ActionCallbackData) {
method #executeAction (line 98) | async #executeAction(actionId: string) {
method #runShellAction (line 124) | async #runShellAction(action: ActionState) {
method #runFileAction (line 152) | async #runFileAction(action: ActionState) {
method #updateAction (line 181) | #updateAction(id: string, newState: ActionStateUpdate) {
FILE: app/lib/runtime/message-parser.spec.ts
type ExpectedResult (line 4) | interface ExpectedResult {
function runTest (line 157) | function runTest(input: string | string[], outputOrExpectedResult: strin...
FILE: app/lib/runtime/message-parser.ts
constant ARTIFACT_TAG_OPEN (line 6) | const ARTIFACT_TAG_OPEN = '<boltArtifact';
constant ARTIFACT_TAG_CLOSE (line 7) | const ARTIFACT_TAG_CLOSE = '</boltArtifact>';
constant ARTIFACT_ACTION_TAG_OPEN (line 8) | const ARTIFACT_ACTION_TAG_OPEN = '<boltAction';
constant ARTIFACT_ACTION_TAG_CLOSE (line 9) | const ARTIFACT_ACTION_TAG_CLOSE = '</boltAction>';
type ArtifactCallbackData (line 13) | interface ArtifactCallbackData extends BoltArtifactData {
type ActionCallbackData (line 17) | interface ActionCallbackData {
type ArtifactCallback (line 24) | type ArtifactCallback = (data: ArtifactCallbackData) => void;
type ActionCallback (line 25) | type ActionCallback = (data: ActionCallbackData) => void;
type ParserCallbacks (line 27) | interface ParserCallbacks {
type ElementFactoryProps (line 34) | interface ElementFactoryProps {
type ElementFactory (line 38) | type ElementFactory = (props: ElementFactoryProps) => string;
type StreamingMessageParserOptions (line 40) | interface StreamingMessageParserOptions {
type MessageState (line 45) | interface MessageState {
class StreamingMessageParser (line 54) | class StreamingMessageParser {
method constructor (line 57) | constructor(private _options: StreamingMessageParserOptions = {}) {}
method parse (line 59) | parse(messageId: string, input: string) {
method reset (line 237) | reset() {
method #parseActionTag (line 241) | #parseActionTag(input: string, actionOpenIndex: number, actionEndIndex...
method #extractAttribute (line 266) | #extractAttribute(tag: string, attributeName: string): string | undefi...
function camelToDashCase (line 283) | function camelToDashCase(input: string) {
FILE: app/lib/stores/editor.ts
type EditorDocuments (line 5) | type EditorDocuments = Record<string, EditorDocument>;
type SelectedFile (line 7) | type SelectedFile = WritableAtom<string | undefined>;
class EditorStore (line 9) | class EditorStore {
method constructor (line 23) | constructor(filesStore: FilesStore) {
method setDocuments (line 32) | setDocuments(files: FileMap) {
method setSelectedFile (line 59) | setSelectedFile(filePath: string | undefined) {
method updateScrollPosition (line 63) | updateScrollPosition(filePath: string, position: ScrollPosition) {
method updateFile (line 77) | updateFile(filePath: string, newContent: string) {
FILE: app/lib/stores/files.ts
type File (line 16) | interface File {
type Folder (line 22) | interface Folder {
type Dirent (line 26) | type Dirent = File | Folder;
type FileMap (line 28) | type FileMap = Record<string, Dirent | undefined>;
class FilesStore (line 30) | class FilesStore {
method filesCount (line 50) | get filesCount() {
method constructor (line 54) | constructor(webcontainerPromise: Promise<WebContainer>) {
method getFile (line 65) | getFile(filePath: string) {
method getFileModifications (line 75) | getFileModifications() {
method resetFileModifications (line 79) | resetFileModifications() {
method saveFile (line 83) | async saveFile(filePath: string, content: string) {
method #init (line 116) | async #init() {
method #processEventBuffer (line 125) | #processEventBuffer(events: Array<[events: PathWatcherEvent[]]>) {
method #decodeFileContent (line 186) | #decodeFileContent(buffer?: Uint8Array) {
function isBinaryFile (line 200) | function isBinaryFile(buffer: Uint8Array | undefined) {
function convertToBuffer (line 214) | function convertToBuffer(view: Uint8Array): Buffer {
FILE: app/lib/stores/previews.ts
type PreviewInfo (line 4) | interface PreviewInfo {
class PreviewsStore (line 10) | class PreviewsStore {
method constructor (line 16) | constructor(webcontainerPromise: Promise<WebContainer>) {
method #init (line 22) | async #init() {
FILE: app/lib/stores/settings.ts
type Shortcut (line 4) | interface Shortcut {
type Shortcuts (line 14) | interface Shortcuts {
type Settings (line 18) | interface Settings {
FILE: app/lib/stores/terminal.ts
class TerminalStore (line 7) | class TerminalStore {
method constructor (line 13) | constructor(webcontainerPromise: Promise<WebContainer>) {
method toggleTerminal (line 21) | toggleTerminal(value?: boolean) {
method attachTerminal (line 25) | async attachTerminal(terminal: ITerminal) {
method onTerminalResize (line 35) | onTerminalResize(cols: number, rows: number) {
FILE: app/lib/stores/theme.ts
type Theme (line 3) | type Theme = 'dark' | 'light';
function themeIsDark (line 7) | function themeIsDark() {
constant DEFAULT_THEME (line 11) | const DEFAULT_THEME = 'light';
function initStore (line 15) | function initStore() {
function toggleTheme (line 26) | function toggleTheme() {
FILE: app/lib/stores/workbench.ts
type ArtifactState (line 13) | interface ArtifactState {
type ArtifactUpdateState (line 20) | type ArtifactUpdateState = Pick<ArtifactState, 'title' | 'closed'>;
type Artifacts (line 22) | type Artifacts = MapStore<Record<string, ArtifactState>>;
type WorkbenchViewType (line 24) | type WorkbenchViewType = 'code' | 'preview';
class WorkbenchStore (line 26) | class WorkbenchStore {
method constructor (line 40) | constructor() {
method previews (line 49) | get previews() {
method files (line 53) | get files() {
method currentDocument (line 57) | get currentDocument(): ReadableAtom<EditorDocument | undefined> {
method selectedFile (line 61) | get selectedFile(): ReadableAtom<string | undefined> {
method firstArtifact (line 65) | get firstArtifact(): ArtifactState | undefined {
method filesCount (line 69) | get filesCount(): number {
method showTerminal (line 73) | get showTerminal() {
method toggleTerminal (line 77) | toggleTerminal(value?: boolean) {
method attachTerminal (line 81) | attachTerminal(terminal: ITerminal) {
method onTerminalResize (line 85) | onTerminalResize(cols: number, rows: number) {
method setDocuments (line 89) | setDocuments(files: FileMap) {
method setShowWorkbench (line 103) | setShowWorkbench(show: boolean) {
method setCurrentDocumentContent (line 107) | setCurrentDocumentContent(newContent: string) {
method setCurrentDocumentScrollPosition (line 140) | setCurrentDocumentScrollPosition(position: ScrollPosition) {
method setSelectedFile (line 152) | setSelectedFile(filePath: string | undefined) {
method saveFile (line 156) | async saveFile(filePath: string) {
method saveCurrentDocument (line 172) | async saveCurrentDocument() {
method resetCurrentDocument (line 182) | resetCurrentDocument() {
method saveAllFiles (line 199) | async saveAllFiles() {
method getFileModifcations (line 205) | getFileModifcations() {
method resetAllFileModifications (line 209) | resetAllFileModifications() {
method abortAllActions (line 213) | abortAllActions() {
method addArtifact (line 217) | addArtifact({ messageId, title, id }: ArtifactCallbackData) {
method updateArtifact (line 236) | updateArtifact({ messageId }: ArtifactCallbackData, state: Partial<Art...
method addAction (line 246) | async addAction(data: ActionCallbackData) {
method runAction (line 258) | async runAction(data: ActionCallbackData) {
method #getArtifact (line 270) | #getArtifact(id: string) {
FILE: app/lib/webcontainer/index.ts
type WebContainerContext (line 4) | interface WebContainerContext {
FILE: app/root.tsx
function Layout (line 65) | function Layout({ children }: { children: React.ReactNode }) {
function App (line 81) | function App() {
FILE: app/routes/_index.tsx
function Index (line 13) | function Index() {
FILE: app/routes/api.chat.ts
function action (line 7) | async function action(args: ActionFunctionArgs) {
function chatAction (line 11) | async function chatAction({ context, request }: ActionFunctionArgs) {
FILE: app/routes/api.enhancer.ts
function action (line 9) | async function action(args: ActionFunctionArgs) {
function enhancerAction (line 13) | async function enhancerAction({ context, request }: ActionFunctionArgs) {
FILE: app/routes/chat.$id.tsx
function loader (line 4) | async function loader(args: LoaderFunctionArgs) {
FILE: app/types/actions.ts
type ActionType (line 1) | type ActionType = 'file' | 'shell';
type BaseAction (line 3) | interface BaseAction {
type FileAction (line 7) | interface FileAction extends BaseAction {
type ShellAction (line 12) | interface ShellAction extends BaseAction {
type BoltAction (line 16) | type BoltAction = FileAction | ShellAction;
type BoltActionData (line 18) | type BoltActionData = BoltAction | BaseAction;
FILE: app/types/artifact.ts
type BoltArtifactData (line 1) | interface BoltArtifactData {
FILE: app/types/terminal.ts
type ITerminal (line 1) | interface ITerminal {
FILE: app/types/theme.ts
type Theme (line 1) | type Theme = 'dark' | 'light';
FILE: app/utils/buffer.ts
function bufferWatchEvents (line 1) | function bufferWatchEvents<T extends unknown[]>(timeInMs: number, cb: (e...
FILE: app/utils/classNames.ts
type ClassNamesArg (line 8) | type ClassNamesArg = undefined | string | Record<string, boolean> | Clas...
function classNames (line 17) | function classNames(...args: ClassNamesArg[]): string {
function parseValue (line 27) | function parseValue(arg: ClassNamesArg) {
function appendClass (line 51) | function appendClass(value: string, newClass: string | undefined) {
FILE: app/utils/constants.ts
constant WORK_DIR_NAME (line 1) | const WORK_DIR_NAME = 'project';
constant WORK_DIR (line 2) | const WORK_DIR = `/home/${WORK_DIR_NAME}`;
constant MODIFICATIONS_TAG_NAME (line 3) | const MODIFICATIONS_TAG_NAME = 'bolt_file_modifications';
FILE: app/utils/debounce.ts
function debounce (line 1) | function debounce<Args extends any[]>(fn: (...args: Args) => void, delay...
FILE: app/utils/diff.ts
type ModifiedFile (line 10) | interface ModifiedFile {
type FileModifications (line 15) | type FileModifications = Record<string, ModifiedFile>;
function computeFileModifications (line 17) | function computeFileModifications(files: FileMap, modifiedFiles: Map<str...
function diffFiles (line 61) | function diffFiles(fileName: string, oldFileContent: string, newFileCont...
function fileModificationsToHTML (line 92) | function fileModificationsToHTML(modifications: FileModifications) {
FILE: app/utils/logger.ts
type DebugLevel (line 1) | type DebugLevel = 'trace' | 'debug' | 'info' | 'warn' | 'error';
type LoggerFunction (line 3) | type LoggerFunction = (...messages: any[]) => void;
type Logger (line 5) | interface Logger {
function createScopedLogger (line 28) | function createScopedLogger(scope: string): Logger {
function setLevel (line 39) | function setLevel(level: DebugLevel) {
function log (line 47) | function log(level: DebugLevel, scope: string | undefined, messages: any...
function getLabelStyles (line 87) | function getLabelStyles(color: string, textColor: string) {
function getColorForLevel (line 91) | function getColorForLevel(level: DebugLevel): string {
FILE: app/utils/markdown.ts
function remarkPlugins (line 69) | function remarkPlugins(limitedMarkdown: boolean) {
function rehypePlugins (line 79) | function rehypePlugins(html: boolean) {
FILE: app/utils/mobile.ts
function isMobile (line 1) | function isMobile() {
FILE: app/utils/promises.ts
function withResolvers (line 1) | function withResolvers<T>(): PromiseWithResolvers<T> {
FILE: app/utils/shell.ts
function newShellProcess (line 5) | async function newShellProcess(webcontainer: WebContainer, terminal: ITe...
FILE: app/utils/stripIndent.ts
function stripIndents (line 3) | function stripIndents(arg0: string | TemplateStringsArray, ...values: an...
function _stripIndents (line 16) | function _stripIndents(value: string) {
FILE: app/utils/unreachable.ts
function unreachable (line 1) | function unreachable(message: string): never {
FILE: load-context.ts
type Cloudflare (line 3) | type Cloudflare = Omit<PlatformProxy<Env>, 'dispose'>;
type AppLoadContext (line 6) | interface AppLoadContext {
FILE: types/istextorbinary.d.ts
type EncodingOpts (line 6) | interface EncodingOpts {
FILE: uno.config.ts
constant BASE_COLORS (line 22) | const BASE_COLORS = {
constant COLOR_PRIMITIVES (line 90) | const COLOR_PRIMITIVES = {
function generateAlphaPalette (line 264) | function generateAlphaPalette(hex: string) {
FILE: vite.config.ts
function chrome129IssuePlugin (line 33) | function chrome129IssuePlugin() {
FILE: worker-configuration.d.ts
type Env (line 1) | interface Env {
Condensed preview — 129 files, each showing path, character count, and a content snippet. Download the .json file or copy for the full structured content (306K chars).
[
{
"path": ".editorconfig",
"chars": 210,
"preview": "root = true\n\n[*]\nindent_style = space\nend_of_line = lf\ncharset = utf-8\ntrim_trailing_whitespace = true\ninsert_final_newl"
},
{
"path": ".github/ISSUE_TEMPLATE/bug_report.yml",
"chars": 2745,
"preview": "name: \"Bug report\"\ndescription: Create a report to help us improve\nbody:\n - type: markdown\n attributes:\n value:"
},
{
"path": ".github/ISSUE_TEMPLATE/config.yml",
"chars": 528,
"preview": "blank_issues_enabled: false\ncontact_links:\n - name: Bolt.new Help Center\n url: https://support.bolt.new\n about: O"
},
{
"path": ".github/ISSUE_TEMPLATE/feature_request.md",
"chars": 637,
"preview": "---\nname: Feature request\nabout: Suggest an idea for this project\ntitle: ''\nlabels: ''\nassignees: ''\n---\n\n**Is your feat"
},
{
"path": ".github/actions/setup-and-build/action.yaml",
"chars": 671,
"preview": "name: Setup and Build\ndescription: Generic setup action\ninputs:\n pnpm-version:\n required: false\n type: string\n "
},
{
"path": ".github/workflows/ci.yaml",
"chars": 445,
"preview": "name: CI/CD\n\non:\n push:\n branches:\n - master\n pull_request:\n\njobs:\n test:\n name: Test\n runs-on: ubuntu-"
},
{
"path": ".github/workflows/semantic-pr.yaml",
"chars": 954,
"preview": "name: Semantic Pull Request\non:\n pull_request_target:\n types: [opened, reopened, edited, synchronize]\npermissions:\n "
},
{
"path": ".gitignore",
"chars": 290,
"preview": "logs\n*.log\nnpm-debug.log*\nyarn-debug.log*\nyarn-error.log*\npnpm-debug.log*\nlerna-debug.log*\n\nnode_modules\ndist\ndist-ssr\n*"
},
{
"path": ".husky/commit-msg",
"chars": 84,
"preview": "#!/usr/bin/env sh\n\n. \"$(dirname \"$0\")/_/husky.sh\"\n\nnpx commitlint --edit $1\n\nexit 0\n"
},
{
"path": ".prettierignore",
"chars": 22,
"preview": "pnpm-lock.yaml\n.astro\n"
},
{
"path": ".prettierrc",
"chars": 126,
"preview": "{\n \"printWidth\": 120,\n \"singleQuote\": true,\n \"useTabs\": false,\n \"tabWidth\": 2,\n \"semi\": true,\n \"bracketSpacing\": t"
},
{
"path": ".tool-versions",
"chars": 26,
"preview": "nodejs 20.15.1\npnpm 9.4.0\n"
},
{
"path": "CONTRIBUTING.md",
"chars": 5374,
"preview": "[](https://bolt.new)\n\n> Welcome to the **Bolt** open-sour"
},
{
"path": "LICENSE",
"chars": 1073,
"preview": "MIT License\n\nCopyright (c) 2024 StackBlitz, Inc.\n\nPermission is hereby granted, free of charge, to any person obtaining "
},
{
"path": "README.md",
"chars": 4079,
"preview": "[](https://bolt.new)"
},
{
"path": "app/components/chat/Artifact.tsx",
"chars": 7126,
"preview": "import { useStore } from '@nanostores/react';\nimport { AnimatePresence, motion } from 'framer-motion';\nimport { computed"
},
{
"path": "app/components/chat/AssistantMessage.tsx",
"chars": 323,
"preview": "import { memo } from 'react';\nimport { Markdown } from './Markdown';\n\ninterface AssistantMessageProps {\n content: strin"
},
{
"path": "app/components/chat/BaseChat.module.scss",
"chars": 369,
"preview": ".BaseChat {\n &[data-chat-visible='false'] {\n --workbench-inner-width: 100%;\n --workbench-left: 0;\n\n .Chat {\n "
},
{
"path": "app/components/chat/BaseChat.tsx",
"chars": 8535,
"preview": "import type { Message } from 'ai';\nimport React, { type RefCallback } from 'react';\nimport { ClientOnly } from 'remix-ut"
},
{
"path": "app/components/chat/Chat.client.tsx",
"chars": 7069,
"preview": "import { useStore } from '@nanostores/react';\nimport type { Message } from 'ai';\nimport { useChat } from 'ai/react';\nimp"
},
{
"path": "app/components/chat/CodeBlock.module.scss",
"chars": 172,
"preview": ".CopyButtonContainer {\n button:before {\n content: 'Copied';\n font-size: 12px;\n position: absolute;\n left: -"
},
{
"path": "app/components/chat/CodeBlock.tsx",
"chars": 2540,
"preview": "import { memo, useEffect, useState } from 'react';\nimport { bundledLanguages, codeToHtml, isSpecialLang, type BundledLan"
},
{
"path": "app/components/chat/Markdown.module.scss",
"chars": 2991,
"preview": "$font-mono: ui-monospace, 'Fira Code', Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace;\n$code-font-"
},
{
"path": "app/components/chat/Markdown.tsx",
"chars": 2218,
"preview": "import { memo, useMemo } from 'react';\nimport ReactMarkdown, { type Components } from 'react-markdown';\nimport type { Bu"
},
{
"path": "app/components/chat/Messages.client.tsx",
"chars": 2064,
"preview": "import type { Message } from 'ai';\nimport React from 'react';\nimport { classNames } from '~/utils/classNames';\nimport { "
},
{
"path": "app/components/chat/SendButton.client.tsx",
"chars": 1158,
"preview": "import { AnimatePresence, cubicBezier, motion } from 'framer-motion';\n\ninterface SendButtonProps {\n show: boolean;\n is"
},
{
"path": "app/components/chat/UserMessage.tsx",
"chars": 461,
"preview": "import { modificationsRegex } from '~/utils/diff';\nimport { Markdown } from './Markdown';\n\ninterface UserMessageProps {\n"
},
{
"path": "app/components/editor/codemirror/BinaryContent.tsx",
"chars": 253,
"preview": "export function BinaryContent() {\n return (\n <div className=\"flex items-center justify-center absolute inset-0 z-10 "
},
{
"path": "app/components/editor/codemirror/CodeMirrorEditor.tsx",
"chars": 11894,
"preview": "import { acceptCompletion, autocompletion, closeBrackets } from '@codemirror/autocomplete';\nimport { defaultKeymap, hist"
},
{
"path": "app/components/editor/codemirror/cm-theme.ts",
"chars": 6304,
"preview": "import { Compartment, type Extension } from '@codemirror/state';\nimport { EditorView } from '@codemirror/view';\nimport {"
},
{
"path": "app/components/editor/codemirror/indent.ts",
"chars": 1921,
"preview": "import { indentLess } from '@codemirror/commands';\nimport { indentUnit } from '@codemirror/language';\nimport { EditorSel"
},
{
"path": "app/components/editor/codemirror/languages.ts",
"chars": 2861,
"preview": "import { LanguageDescription } from '@codemirror/language';\n\nexport const supportedLanguages = [\n LanguageDescription.o"
},
{
"path": "app/components/header/Header.tsx",
"chars": 1441,
"preview": "import { useStore } from '@nanostores/react';\nimport { ClientOnly } from 'remix-utils/client-only';\nimport { chatStore }"
},
{
"path": "app/components/header/HeaderActionButtons.client.tsx",
"chars": 2115,
"preview": "import { useStore } from '@nanostores/react';\nimport { chatStore } from '~/lib/stores/chat';\nimport { workbenchStore } f"
},
{
"path": "app/components/sidebar/HistoryItem.tsx",
"chars": 2225,
"preview": "import * as Dialog from '@radix-ui/react-dialog';\nimport { useEffect, useRef, useState } from 'react';\nimport { type Cha"
},
{
"path": "app/components/sidebar/Menu.client.tsx",
"chars": 6026,
"preview": "import { motion, type Variants } from 'framer-motion';\nimport { useCallback, useEffect, useRef, useState } from 'react';"
},
{
"path": "app/components/sidebar/date-binning.ts",
"chars": 1272,
"preview": "import { format, isAfter, isThisWeek, isThisYear, isToday, isYesterday, subDays } from 'date-fns';\nimport type { ChatHis"
},
{
"path": "app/components/ui/Dialog.tsx",
"chars": 4037,
"preview": "import * as RadixDialog from '@radix-ui/react-dialog';\nimport { motion, type Variants } from 'framer-motion';\nimport Rea"
},
{
"path": "app/components/ui/IconButton.tsx",
"chars": 1937,
"preview": "import { memo } from 'react';\nimport { classNames } from '~/utils/classNames';\n\ntype IconSize = 'sm' | 'md' | 'lg' | 'xl"
},
{
"path": "app/components/ui/LoadingDots.tsx",
"chars": 705,
"preview": "import { memo, useEffect, useState } from 'react';\n\ninterface LoadingDotsProps {\n text: string;\n}\n\nexport const Loading"
},
{
"path": "app/components/ui/PanelHeader.tsx",
"chars": 541,
"preview": "import { memo } from 'react';\nimport { classNames } from '~/utils/classNames';\n\ninterface PanelHeaderProps {\n className"
},
{
"path": "app/components/ui/PanelHeaderButton.tsx",
"chars": 1134,
"preview": "import { memo } from 'react';\nimport { classNames } from '~/utils/classNames';\n\ninterface PanelHeaderButtonProps {\n cla"
},
{
"path": "app/components/ui/Slider.tsx",
"chars": 2008,
"preview": "import { motion } from 'framer-motion';\nimport { memo } from 'react';\nimport { classNames } from '~/utils/classNames';\ni"
},
{
"path": "app/components/ui/ThemeSwitch.tsx",
"chars": 739,
"preview": "import { useStore } from '@nanostores/react';\nimport { memo, useEffect, useState } from 'react';\nimport { themeStore, to"
},
{
"path": "app/components/workbench/EditorPanel.tsx",
"chars": 9475,
"preview": "import { useStore } from '@nanostores/react';\nimport { memo, useEffect, useMemo, useRef, useState } from 'react';\nimport"
},
{
"path": "app/components/workbench/FileBreadcrumb.tsx",
"chars": 5201,
"preview": "import * as DropdownMenu from '@radix-ui/react-dropdown-menu';\nimport { AnimatePresence, motion, type Variants } from 'f"
},
{
"path": "app/components/workbench/FileTree.tsx",
"chars": 11176,
"preview": "import { memo, useEffect, useMemo, useState, type ReactNode } from 'react';\nimport type { FileMap } from '~/lib/stores/f"
},
{
"path": "app/components/workbench/PortDropdown.tsx",
"chars": 2920,
"preview": "import { memo, useEffect, useRef } from 'react';\nimport { IconButton } from '~/components/ui/IconButton';\nimport type { "
},
{
"path": "app/components/workbench/Preview.tsx",
"chars": 4371,
"preview": "import { useStore } from '@nanostores/react';\nimport { memo, useCallback, useEffect, useRef, useState } from 'react';\nim"
},
{
"path": "app/components/workbench/Workbench.client.tsx",
"chars": 6247,
"preview": "import { useStore } from '@nanostores/react';\nimport { motion, type HTMLMotionProps, type Variants } from 'framer-motion"
},
{
"path": "app/components/workbench/terminal/Terminal.tsx",
"chars": 2557,
"preview": "import { FitAddon } from '@xterm/addon-fit';\nimport { WebLinksAddon } from '@xterm/addon-web-links';\nimport { Terminal a"
},
{
"path": "app/components/workbench/terminal/theme.ts",
"chars": 1883,
"preview": "import type { ITheme } from '@xterm/xterm';\n\nconst style = getComputedStyle(document.documentElement);\nconst cssVar = (t"
},
{
"path": "app/entry.client.tsx",
"chars": 234,
"preview": "import { RemixBrowser } from '@remix-run/react';\nimport { startTransition } from 'react';\nimport { hydrateRoot } from 'r"
},
{
"path": "app/entry.server.tsx",
"chars": 2157,
"preview": "import type { AppLoadContext, EntryContext } from '@remix-run/cloudflare';\nimport { RemixServer } from '@remix-run/react"
},
{
"path": "app/lib/.server/llm/api-key.ts",
"chars": 321,
"preview": "import { env } from 'node:process';\n\nexport function getAPIKey(cloudflareEnv: Env) {\n /**\n * The `cloudflareEnv` is o"
},
{
"path": "app/lib/.server/llm/constants.ts",
"chars": 216,
"preview": "// see https://docs.anthropic.com/en/docs/about-claude/models\nexport const MAX_TOKENS = 8192;\n\n// limits the number of m"
},
{
"path": "app/lib/.server/llm/model.ts",
"chars": 215,
"preview": "import { createAnthropic } from '@ai-sdk/anthropic';\n\nexport function getAnthropicModel(apiKey: string) {\n const anthro"
},
{
"path": "app/lib/.server/llm/prompts.ts",
"chars": 13917,
"preview": "import { MODIFICATIONS_TAG_NAME, WORK_DIR } from '~/utils/constants';\nimport { allowedHTMLElements } from '~/utils/markd"
},
{
"path": "app/lib/.server/llm/stream-text.ts",
"chars": 1024,
"preview": "import { streamText as _streamText, convertToCoreMessages } from 'ai';\nimport { getAPIKey } from '~/lib/.server/llm/api-"
},
{
"path": "app/lib/.server/llm/switchable-stream.ts",
"chars": 1415,
"preview": "export default class SwitchableStream extends TransformStream {\n private _controller: TransformStreamDefaultController "
},
{
"path": "app/lib/crypto.ts",
"chars": 1489,
"preview": "const encoder = new TextEncoder();\nconst decoder = new TextDecoder();\nconst IV_LENGTH = 16;\n\nexport async function encry"
},
{
"path": "app/lib/fetch.ts",
"chars": 465,
"preview": "type CommonRequest = Omit<RequestInit, 'body'> & { body?: URLSearchParams };\n\nexport async function request(url: string,"
},
{
"path": "app/lib/hooks/index.ts",
"chars": 138,
"preview": "export * from './useMessageParser';\nexport * from './usePromptEnhancer';\nexport * from './useShortcuts';\nexport * from '"
},
{
"path": "app/lib/hooks/useMessageParser.ts",
"chars": 1973,
"preview": "import type { Message } from 'ai';\nimport { useCallback, useState } from 'react';\nimport { StreamingMessageParser } from"
},
{
"path": "app/lib/hooks/usePromptEnhancer.ts",
"chars": 1585,
"preview": "import { useState } from 'react';\nimport { createScopedLogger } from '~/utils/logger';\n\nconst logger = createScopedLogge"
},
{
"path": "app/lib/hooks/useShortcuts.ts",
"chars": 1727,
"preview": "import { useStore } from '@nanostores/react';\nimport { useEffect } from 'react';\nimport { shortcutsStore, type Shortcuts"
},
{
"path": "app/lib/hooks/useSnapScroll.ts",
"chars": 1533,
"preview": "import { useRef, useCallback } from 'react';\n\nexport function useSnapScroll() {\n const autoScrollRef = useRef(true);\n "
},
{
"path": "app/lib/persistence/ChatDescription.client.tsx",
"chars": 165,
"preview": "import { useStore } from '@nanostores/react';\nimport { description } from './useChatHistory';\n\nexport function ChatDescr"
},
{
"path": "app/lib/persistence/db.ts",
"chars": 4873,
"preview": "import type { Message } from 'ai';\nimport { createScopedLogger } from '~/utils/logger';\nimport type { ChatHistoryItem } "
},
{
"path": "app/lib/persistence/index.ts",
"chars": 56,
"preview": "export * from './db';\nexport * from './useChatHistory';\n"
},
{
"path": "app/lib/persistence/useChatHistory.ts",
"chars": 2992,
"preview": "import { useLoaderData, useNavigate } from '@remix-run/react';\nimport { useState, useEffect } from 'react';\nimport { ato"
},
{
"path": "app/lib/runtime/__snapshots__/message-parser.spec.ts.snap",
"chars": 6223,
"preview": "// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html\n\nexports[`StreamingMessageParser > valid artifacts with ac"
},
{
"path": "app/lib/runtime/action-runner.ts",
"chars": 4948,
"preview": "import { WebContainer } from '@webcontainer/api';\nimport { map, type MapStore } from 'nanostores';\nimport * as nodePath "
},
{
"path": "app/lib/runtime/message-parser.spec.ts",
"chars": 7032,
"preview": "import { describe, expect, it, vi } from 'vitest';\nimport { StreamingMessageParser, type ActionCallback, type ArtifactCa"
},
{
"path": "app/lib/runtime/message-parser.ts",
"chars": 8215,
"preview": "import type { ActionType, BoltAction, BoltActionData, FileAction, ShellAction } from '~/types/actions';\nimport type { Bo"
},
{
"path": "app/lib/stores/chat.ts",
"chars": 124,
"preview": "import { map } from 'nanostores';\n\nexport const chatStore = map({\n started: false,\n aborted: false,\n showChat: true,\n"
},
{
"path": "app/lib/stores/editor.ts",
"chars": 2601,
"preview": "import { atom, computed, map, type MapStore, type WritableAtom } from 'nanostores';\nimport type { EditorDocument, Scroll"
},
{
"path": "app/lib/stores/files.ts",
"chars": 6119,
"preview": "import type { PathWatcherEvent, WebContainer } from '@webcontainer/api';\nimport { getEncoding } from 'istextorbinary';\ni"
},
{
"path": "app/lib/stores/previews.ts",
"chars": 1234,
"preview": "import type { WebContainer } from '@webcontainer/api';\nimport { atom } from 'nanostores';\n\nexport interface PreviewInfo "
},
{
"path": "app/lib/stores/settings.ts",
"chars": 744,
"preview": "import { map } from 'nanostores';\nimport { workbenchStore } from './workbench';\n\nexport interface Shortcut {\n key: stri"
},
{
"path": "app/lib/stores/terminal.ts",
"chars": 1343,
"preview": "import type { WebContainer, WebContainerProcess } from '@webcontainer/api';\nimport { atom, type WritableAtom } from 'nan"
},
{
"path": "app/lib/stores/theme.ts",
"chars": 884,
"preview": "import { atom } from 'nanostores';\n\nexport type Theme = 'dark' | 'light';\n\nexport const kTheme = 'bolt_theme';\n\nexport f"
},
{
"path": "app/lib/stores/workbench.ts",
"chars": 7253,
"preview": "import { atom, map, type MapStore, type ReadableAtom, type WritableAtom } from 'nanostores';\nimport type { EditorDocumen"
},
{
"path": "app/lib/webcontainer/auth.client.ts",
"chars": 209,
"preview": "/**\n * This client-only module that contains everything related to auth and is used\n * to avoid importing `@webcontainer"
},
{
"path": "app/lib/webcontainer/index.ts",
"chars": 868,
"preview": "import { WebContainer } from '@webcontainer/api';\nimport { WORK_DIR_NAME } from '~/utils/constants';\n\ninterface WebConta"
},
{
"path": "app/root.tsx",
"chars": 2238,
"preview": "import { useStore } from '@nanostores/react';\nimport type { LinksFunction } from '@remix-run/cloudflare';\nimport { Links"
},
{
"path": "app/routes/_index.tsx",
"chars": 684,
"preview": "import { json, type MetaFunction } from '@remix-run/cloudflare';\nimport { ClientOnly } from 'remix-utils/client-only';\ni"
},
{
"path": "app/routes/api.chat.ts",
"chars": 1902,
"preview": "import { type ActionFunctionArgs } from '@remix-run/cloudflare';\nimport { MAX_RESPONSE_SEGMENTS, MAX_TOKENS } from '~/li"
},
{
"path": "app/routes/api.enhancer.ts",
"chars": 1658,
"preview": "import { type ActionFunctionArgs } from '@remix-run/cloudflare';\nimport { StreamingTextResponse, parseStreamPart } from "
},
{
"path": "app/routes/chat.$id.tsx",
"chars": 248,
"preview": "import { json, type LoaderFunctionArgs } from '@remix-run/cloudflare';\nimport { default as IndexRoute } from './_index';"
},
{
"path": "app/styles/animations.scss",
"chars": 783,
"preview": ".animated {\n animation-fill-mode: both;\n animation-duration: var(--animate-duration, 0.2s);\n animation-timing-functio"
},
{
"path": "app/styles/components/code.scss",
"chars": 231,
"preview": ".actions .shiki {\n background-color: var(--bolt-elements-actions-code-background) !important;\n}\n\n.shiki {\n &:not(:has("
},
{
"path": "app/styles/components/editor.scss",
"chars": 6174,
"preview": ":root {\n --cm-backgroundColor: var(--bolt-elements-editor-backgroundColor, var(--bolt-elements-bg-depth-1));\n --cm-tex"
},
{
"path": "app/styles/components/resize-handle.scss",
"chars": 537,
"preview": "[data-resize-handle] {\n position: relative;\n\n &[data-panel-group-direction='horizontal']:after {\n content: '';\n "
},
{
"path": "app/styles/components/terminal.scss",
"chars": 28,
"preview": ".xterm {\n padding: 1rem;\n}\n"
},
{
"path": "app/styles/components/toast.scss",
"chars": 380,
"preview": ".Toastify__toast {\n --at-apply: shadow-md;\n\n background-color: var(--bolt-elements-bg-depth-2);\n color: var(--bolt-el"
},
{
"path": "app/styles/index.scss",
"chars": 316,
"preview": "@import './variables.scss';\n@import './z-index.scss';\n@import './animations.scss';\n@import './components/terminal.scss';"
},
{
"path": "app/styles/variables.scss",
"chars": 12222,
"preview": "/* Color Tokens Light Theme */\n:root,\n:root[data-theme='light'] {\n --bolt-elements-borderColor: theme('colors.alpha.gra"
},
{
"path": "app/styles/z-index.scss",
"chars": 351,
"preview": "$zIndexMax: 999;\n\n.z-logo {\n z-index: $zIndexMax - 1;\n}\n\n.z-sidebar {\n z-index: $zIndexMax - 2;\n}\n\n.z-port-dropdown {\n"
},
{
"path": "app/types/actions.ts",
"chars": 360,
"preview": "export type ActionType = 'file' | 'shell';\n\nexport interface BaseAction {\n content: string;\n}\n\nexport interface FileAct"
},
{
"path": "app/types/artifact.ts",
"chars": 69,
"preview": "export interface BoltArtifactData {\n id: string;\n title: string;\n}\n"
},
{
"path": "app/types/terminal.ts",
"chars": 186,
"preview": "export interface ITerminal {\n readonly cols?: number;\n readonly rows?: number;\n\n reset: () => void;\n write: (data: s"
},
{
"path": "app/types/theme.ts",
"chars": 38,
"preview": "export type Theme = 'dark' | 'light';\n"
},
{
"path": "app/utils/buffer.ts",
"chars": 783,
"preview": "export function bufferWatchEvents<T extends unknown[]>(timeInMs: number, cb: (events: T[]) => unknown) {\n let timeoutId"
},
{
"path": "app/utils/classNames.ts",
"chars": 1274,
"preview": "/**\n * Copyright (c) 2018 Jed Watson.\n * Licensed under the MIT License (MIT), see:\n *\n * @link http://jedwatson.github."
},
{
"path": "app/utils/constants.ts",
"chars": 155,
"preview": "export const WORK_DIR_NAME = 'project';\nexport const WORK_DIR = `/home/${WORK_DIR_NAME}`;\nexport const MODIFICATIONS_TAG"
},
{
"path": "app/utils/debounce.ts",
"chars": 356,
"preview": "export function debounce<Args extends any[]>(fn: (...args: Args) => void, delay = 100) {\n if (delay === 0) {\n return"
},
{
"path": "app/utils/diff.ts",
"chars": 2974,
"preview": "import { createTwoFilesPatch } from 'diff';\nimport type { FileMap } from '~/lib/stores/files';\nimport { MODIFICATIONS_TA"
},
{
"path": "app/utils/easings.ts",
"chars": 104,
"preview": "import { cubicBezier } from 'framer-motion';\n\nexport const cubicEasingFn = cubicBezier(0.4, 0, 0.2, 1);\n"
},
{
"path": "app/utils/logger.ts",
"chars": 3043,
"preview": "export type DebugLevel = 'trace' | 'debug' | 'info' | 'warn' | 'error';\n\ntype LoggerFunction = (...messages: any[]) => v"
},
{
"path": "app/utils/markdown.ts",
"chars": 2188,
"preview": "import rehypeRaw from 'rehype-raw';\nimport remarkGfm from 'remark-gfm';\nimport type { PluggableList, Plugin } from 'unif"
},
{
"path": "app/utils/mobile.ts",
"chars": 143,
"preview": "export function isMobile() {\n // we use sm: as the breakpoint for mobile. It's currently set to 640px\n return globalTh"
},
{
"path": "app/utils/promises.ts",
"chars": 416,
"preview": "export function withResolvers<T>(): PromiseWithResolvers<T> {\n if (typeof Promise.withResolvers === 'function') {\n r"
},
{
"path": "app/utils/react.ts",
"chars": 294,
"preview": "import { memo } from 'react';\n\nexport const genericMemo: <T extends keyof JSX.IntrinsicElements | React.JSXElementConstr"
},
{
"path": "app/utils/shell.ts",
"chars": 1253,
"preview": "import type { WebContainer } from '@webcontainer/api';\nimport type { ITerminal } from '~/types/terminal';\nimport { withR"
},
{
"path": "app/utils/stripIndent.ts",
"chars": 639,
"preview": "export function stripIndents(value: string): string;\nexport function stripIndents(strings: TemplateStringsArray, ...valu"
},
{
"path": "app/utils/terminal.ts",
"chars": 202,
"preview": "const reset = '\\x1b[0m';\n\nexport const escapeCodes = {\n reset,\n clear: '\\x1b[g',\n red: '\\x1b[1;31m',\n};\n\nexport const"
},
{
"path": "app/utils/unreachable.ts",
"chars": 102,
"preview": "export function unreachable(message: string): never {\n throw new Error(`Unreachable: ${message}`);\n}\n"
},
{
"path": "bindings.sh",
"chars": 400,
"preview": "#!/bin/bash\n\nbindings=\"\"\n\nwhile IFS= read -r line || [ -n \"$line\" ]; do\n if [[ ! \"$line\" =~ ^# ]] && [[ -n \"$line\" ]]; "
},
{
"path": "eslint.config.mjs",
"chars": 1157,
"preview": "import blitzPlugin from '@blitz/eslint-plugin';\nimport { jsFileExtensions } from '@blitz/eslint-plugin/dist/configs/java"
},
{
"path": "functions/[[path]].ts",
"chars": 367,
"preview": "import type { ServerBuild } from '@remix-run/cloudflare';\nimport { createPagesFunctionHandler } from '@remix-run/cloudfl"
},
{
"path": "load-context.ts",
"chars": 208,
"preview": "import { type PlatformProxy } from 'wrangler';\n\ntype Cloudflare = Omit<PlatformProxy<Env>, 'dispose'>;\n\ndeclare module '"
},
{
"path": "package.json",
"chars": 3298,
"preview": "{\n \"name\": \"bolt\",\n \"description\": \"StackBlitz AI Agent\",\n \"private\": true,\n \"license\": \"MIT\",\n \"packageManager\": \""
},
{
"path": "tsconfig.json",
"chars": 813,
"preview": "{\n \"compilerOptions\": {\n \"lib\": [\"DOM\", \"DOM.Iterable\", \"ESNext\"],\n \"types\": [\"@remix-run/cloudflare\", \"vite/clie"
},
{
"path": "types/istextorbinary.d.ts",
"chars": 470,
"preview": "/**\n * @note For some reason the types aren't picked up from node_modules so I declared the module here\n * with only the"
},
{
"path": "uno.config.ts",
"chars": 8885,
"preview": "import { globSync } from 'fast-glob';\nimport fs from 'node:fs/promises';\nimport { basename } from 'node:path';\nimport { "
},
{
"path": "vite.config.ts",
"chars": 1908,
"preview": "import { cloudflareDevProxyVitePlugin as remixCloudflareDevProxy, vitePlugin as remixVitePlugin } from '@remix-run/dev';"
},
{
"path": "worker-configuration.d.ts",
"chars": 47,
"preview": "interface Env {\n ANTHROPIC_API_KEY: string;\n}\n"
},
{
"path": "wrangler.toml",
"chars": 180,
"preview": "#:schema node_modules/wrangler/config-schema.json\nname = \"bolt\"\ncompatibility_flags = [\"nodejs_compat\"]\ncompatibility_da"
}
]
About this extraction
This page contains the full source code of the stackblitz/bolt.new GitHub repository, extracted and formatted as plain text for AI agents and large language models (LLMs). The extraction includes 129 files (281.1 KB), approximately 71.8k tokens, and a symbol index with 329 extracted functions, classes, methods, constants, and types. Use this with OpenClaw, Claude, ChatGPT, Cursor, Windsurf, or any other AI tool that accepts text input. You can copy the full output to your clipboard or download it as a .txt file.
Extracted by GitExtract — free GitHub repo to text converter for AI. Built by Nikandr Surkov.