0Scottzilla0 commited on
Commit
8481fea
·
verified ·
1 Parent(s): e68743d

Upload folder using huggingface_hub

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. .editorconfig +13 -0
  2. .github/ISSUE_TEMPLATE/bug_report.yml +75 -0
  3. .github/ISSUE_TEMPLATE/config.yml +11 -0
  4. .github/ISSUE_TEMPLATE/feature_request.md +23 -0
  5. .github/actions/setup-and-build/action.yaml +32 -0
  6. .github/workflows/ci.yaml +27 -0
  7. .github/workflows/semantic-pr.yaml +32 -0
  8. .gitignore +30 -0
  9. .husky/commit-msg +7 -0
  10. .prettierignore +2 -0
  11. .prettierrc +8 -0
  12. .tool-versions +2 -0
  13. CONTRIBUTING.md +110 -0
  14. LICENSE +21 -0
  15. README.md +54 -10
  16. app/components/chat/Artifact.tsx +213 -0
  17. app/components/chat/AssistantMessage.tsx +14 -0
  18. app/components/chat/BaseChat.module.scss +19 -0
  19. app/components/chat/BaseChat.tsx +213 -0
  20. app/components/chat/Chat.client.tsx +234 -0
  21. app/components/chat/CodeBlock.module.scss +10 -0
  22. app/components/chat/CodeBlock.tsx +82 -0
  23. app/components/chat/Markdown.module.scss +171 -0
  24. app/components/chat/Markdown.tsx +74 -0
  25. app/components/chat/Messages.client.tsx +53 -0
  26. app/components/chat/SendButton.client.tsx +33 -0
  27. app/components/chat/UserMessage.tsx +18 -0
  28. app/components/editor/codemirror/BinaryContent.tsx +7 -0
  29. app/components/editor/codemirror/CodeMirrorEditor.tsx +461 -0
  30. app/components/editor/codemirror/cm-theme.ts +192 -0
  31. app/components/editor/codemirror/indent.ts +68 -0
  32. app/components/editor/codemirror/languages.ts +105 -0
  33. app/components/header/Header.tsx +41 -0
  34. app/components/header/HeaderActionButtons.client.tsx +68 -0
  35. app/components/sidebar/HistoryItem.tsx +64 -0
  36. app/components/sidebar/Menu.client.tsx +172 -0
  37. app/components/sidebar/date-binning.ts +59 -0
  38. app/components/ui/Dialog.tsx +133 -0
  39. app/components/ui/IconButton.tsx +77 -0
  40. app/components/ui/LoadingDots.tsx +27 -0
  41. app/components/ui/PanelHeader.tsx +20 -0
  42. app/components/ui/PanelHeaderButton.tsx +36 -0
  43. app/components/ui/Slider.tsx +65 -0
  44. app/components/ui/ThemeSwitch.tsx +29 -0
  45. app/components/workbench/EditorPanel.tsx +256 -0
  46. app/components/workbench/FileBreadcrumb.tsx +148 -0
  47. app/components/workbench/FileTree.tsx +409 -0
  48. app/components/workbench/PortDropdown.tsx +83 -0
  49. app/components/workbench/Preview.tsx +124 -0
  50. app/components/workbench/Workbench.client.tsx +187 -0
.editorconfig ADDED
@@ -0,0 +1,13 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ root = true
2
+
3
+ [*]
4
+ indent_style = space
5
+ end_of_line = lf
6
+ charset = utf-8
7
+ trim_trailing_whitespace = true
8
+ insert_final_newline = true
9
+ max_line_length = 120
10
+ indent_size = 2
11
+
12
+ [*.md]
13
+ trim_trailing_whitespace = false
.github/ISSUE_TEMPLATE/bug_report.yml ADDED
@@ -0,0 +1,75 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ name: "Bug report"
2
+ description: Create a report to help us improve
3
+ body:
4
+ - type: markdown
5
+ attributes:
6
+ value: |
7
+ Thank you for reporting an issue :pray:.
8
+
9
+ This issue tracker is for bugs and issues found with [Bolt.new](https://bolt.new).
10
+ 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.
11
+
12
+ The more information you fill in, the better we can help you.
13
+ - type: textarea
14
+ id: description
15
+ attributes:
16
+ label: Describe the bug
17
+ description: Provide a clear and concise description of what you're running into.
18
+ validations:
19
+ required: true
20
+ - type: input
21
+ id: link
22
+ attributes:
23
+ label: Link to the Bolt URL that caused the error
24
+ description: Please do not delete it after reporting!
25
+ validations:
26
+ required: true
27
+ - type: checkboxes
28
+ id: checkboxes
29
+ attributes:
30
+ label: Validations
31
+ description: Before submitting the issue, please make sure you do the following
32
+ options:
33
+ - 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."
34
+ required: true
35
+ - type: markdown
36
+ attributes:
37
+ value: |
38
+ ![Making your project public](https://github.com/stackblitz/bolt.new/blob/main/public/project-visibility.jpg?raw=true)
39
+ - type: textarea
40
+ id: steps
41
+ attributes:
42
+ label: Steps to reproduce
43
+ description: Describe the steps we have to take to reproduce the behavior.
44
+ placeholder: |
45
+ 1. Go to '...'
46
+ 2. Click on '....'
47
+ 3. Scroll down to '....'
48
+ 4. See error
49
+ validations:
50
+ required: true
51
+ - type: textarea
52
+ id: expected
53
+ attributes:
54
+ label: Expected behavior
55
+ description: Provide a clear and concise description of what you expected to happen.
56
+ validations:
57
+ required: true
58
+ - type: textarea
59
+ id: screenshots
60
+ attributes:
61
+ label: Screen Recording / Screenshot
62
+ 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>.
63
+ - type: textarea
64
+ id: platform
65
+ attributes:
66
+ label: Platform
67
+ value: |
68
+ - OS: [e.g. macOS, Windows, Linux]
69
+ - Browser: [e.g. Chrome, Safari, Firefox]
70
+ - Version: [e.g. 91.1]
71
+ - type: textarea
72
+ id: additional
73
+ attributes:
74
+ label: Additional context
75
+ description: Add any other context about the problem here.
.github/ISSUE_TEMPLATE/config.yml ADDED
@@ -0,0 +1,11 @@
 
 
 
 
 
 
 
 
 
 
 
 
1
+ blank_issues_enabled: false
2
+ contact_links:
3
+ - name: Bolt.new Help Center
4
+ url: https://support.bolt.new
5
+ about: Official central repository for tips, tricks, tutorials, known issues, and best practices for bolt.new usage.
6
+ - name: Billing Issues
7
+ url: https://support.bolt.new/Billing-13fd971055d680ebb393cb80973710b6
8
+ about: Instructions for billing and subscription related support
9
+ - name: Discord Chat
10
+ url: https://discord.gg/stackblitz
11
+ about: Build, share, and learn with other Bolters in real time.
.github/ISSUE_TEMPLATE/feature_request.md ADDED
@@ -0,0 +1,23 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ name: Feature request
3
+ about: Suggest an idea for this project
4
+ title: ''
5
+ labels: ''
6
+ assignees: ''
7
+ ---
8
+
9
+ **Is your feature request related to a problem? Please describe:**
10
+
11
+ <!-- A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] -->
12
+
13
+ **Describe the solution you'd like:**
14
+
15
+ <!-- A clear and concise description of what you want to happen. -->
16
+
17
+ **Describe alternatives you've considered:**
18
+
19
+ <!-- A clear and concise description of any alternative solutions or features you've considered. -->
20
+
21
+ **Additional context:**
22
+
23
+ <!-- Add any other context or screenshots about the feature request here. -->
.github/actions/setup-and-build/action.yaml ADDED
@@ -0,0 +1,32 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ name: Setup and Build
2
+ description: Generic setup action
3
+ inputs:
4
+ pnpm-version:
5
+ required: false
6
+ type: string
7
+ default: '9.4.0'
8
+ node-version:
9
+ required: false
10
+ type: string
11
+ default: '20.15.1'
12
+
13
+ runs:
14
+ using: composite
15
+
16
+ steps:
17
+ - uses: pnpm/action-setup@v4
18
+ with:
19
+ version: ${{ inputs.pnpm-version }}
20
+ run_install: false
21
+
22
+ - name: Set Node.js version to ${{ inputs.node-version }}
23
+ uses: actions/setup-node@v4
24
+ with:
25
+ node-version: ${{ inputs.node-version }}
26
+ cache: pnpm
27
+
28
+ - name: Install dependencies and build project
29
+ shell: bash
30
+ run: |
31
+ pnpm install
32
+ pnpm run build
.github/workflows/ci.yaml ADDED
@@ -0,0 +1,27 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ name: CI/CD
2
+
3
+ on:
4
+ push:
5
+ branches:
6
+ - master
7
+ pull_request:
8
+
9
+ jobs:
10
+ test:
11
+ name: Test
12
+ runs-on: ubuntu-latest
13
+ steps:
14
+ - name: Checkout
15
+ uses: actions/checkout@v4
16
+
17
+ - name: Setup and Build
18
+ uses: ./.github/actions/setup-and-build
19
+
20
+ - name: Run type check
21
+ run: pnpm run typecheck
22
+
23
+ # - name: Run ESLint
24
+ # run: pnpm run lint
25
+
26
+ - name: Run tests
27
+ run: pnpm run test
.github/workflows/semantic-pr.yaml ADDED
@@ -0,0 +1,32 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ name: Semantic Pull Request
2
+ on:
3
+ pull_request_target:
4
+ types: [opened, reopened, edited, synchronize]
5
+ permissions:
6
+ pull-requests: read
7
+ jobs:
8
+ main:
9
+ name: Validate PR Title
10
+ runs-on: ubuntu-latest
11
+ steps:
12
+ # https://github.com/amannn/action-semantic-pull-request/releases/tag/v5.5.3
13
+ - uses: amannn/action-semantic-pull-request@0723387faaf9b38adef4775cd42cfd5155ed6017
14
+ env:
15
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
16
+ with:
17
+ subjectPattern: ^(?![A-Z]).+$
18
+ subjectPatternError: |
19
+ The subject "{subject}" found in the pull request title "{title}"
20
+ didn't match the configured pattern. Please ensure that the subject
21
+ doesn't start with an uppercase character.
22
+ types: |
23
+ fix
24
+ feat
25
+ chore
26
+ build
27
+ ci
28
+ perf
29
+ docs
30
+ refactor
31
+ revert
32
+ test
.gitignore ADDED
@@ -0,0 +1,30 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ logs
2
+ *.log
3
+ npm-debug.log*
4
+ yarn-debug.log*
5
+ yarn-error.log*
6
+ pnpm-debug.log*
7
+ lerna-debug.log*
8
+
9
+ node_modules
10
+ dist
11
+ dist-ssr
12
+ *.local
13
+
14
+ .vscode/*
15
+ !.vscode/launch.json
16
+ !.vscode/extensions.json
17
+ .idea
18
+ .DS_Store
19
+ *.suo
20
+ *.ntvs*
21
+ *.njsproj
22
+ *.sln
23
+ *.sw?
24
+
25
+ /.cache
26
+ /build
27
+ .env*
28
+ *.vars
29
+ .wrangler
30
+ _worker.bundle
.husky/commit-msg ADDED
@@ -0,0 +1,7 @@
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env sh
2
+
3
+ . "$(dirname "$0")/_/husky.sh"
4
+
5
+ npx commitlint --edit $1
6
+
7
+ exit 0
.prettierignore ADDED
@@ -0,0 +1,2 @@
 
 
 
1
+ pnpm-lock.yaml
2
+ .astro
.prettierrc ADDED
@@ -0,0 +1,8 @@
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "printWidth": 120,
3
+ "singleQuote": true,
4
+ "useTabs": false,
5
+ "tabWidth": 2,
6
+ "semi": true,
7
+ "bracketSpacing": true
8
+ }
.tool-versions ADDED
@@ -0,0 +1,2 @@
 
 
 
1
+ nodejs 20.15.1
2
+ pnpm 9.4.0
CONTRIBUTING.md ADDED
@@ -0,0 +1,110 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ [![Bolt Open Source Codebase](./public/social_preview_index.jpg)](https://bolt.new)
2
+
3
+ > 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**.
4
+
5
+ ### Why Build with Bolt + WebContainer API
6
+
7
+ 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.
8
+
9
+ ### What’s the Difference Between Bolt (This Repo) and [Bolt.new](https://bolt.new)?
10
+
11
+ - **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**.
12
+
13
+ - **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.
14
+
15
+ # Get Started Building with Bolt
16
+
17
+ 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/).
18
+
19
+ ### WebContainer API
20
+
21
+ 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.
22
+
23
+ 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).
24
+
25
+ ### Remix App
26
+
27
+ Bolt is built with [Remix](https://remix.run/) and
28
+ deployed using [CloudFlare Pages](https://pages.cloudflare.com/) and
29
+ [CloudFlare Workers](https://workers.cloudflare.com/).
30
+
31
+ ### AI SDK Integration
32
+
33
+ Bolt uses the [AI SDK](https://github.com/vercel/ai) to integrate with AI
34
+ models. At this time, Bolt supports using Anthropic's Claude Sonnet 3.5.
35
+ You can get an API key from the [Anthropic API Console](https://console.anthropic.com/) to use with Bolt.
36
+ Take a look at how [Bolt uses the AI SDK](https://github.com/stackblitz/bolt.new/tree/main/app/lib/.server/llm)
37
+
38
+ ## Prerequisites
39
+
40
+ Before you begin, ensure you have the following installed:
41
+
42
+ - Node.js (v20.15.1)
43
+ - pnpm (v9.4.0)
44
+
45
+ ## Setup
46
+
47
+ 1. Clone the repository (if you haven't already):
48
+
49
+ ```bash
50
+ git clone https://github.com/stackblitz/bolt.new.git
51
+ ```
52
+
53
+ 2. Install dependencies:
54
+
55
+ ```bash
56
+ pnpm install
57
+ ```
58
+
59
+ 3. Create a `.env.local` file in the root directory and add your Anthropic API key:
60
+
61
+ ```
62
+ ANTHROPIC_API_KEY=XXX
63
+ ```
64
+
65
+ Optionally, you can set the debug level:
66
+
67
+ ```
68
+ VITE_LOG_LEVEL=debug
69
+ ```
70
+
71
+ **Important**: Never commit your `.env.local` file to version control. It's already included in .gitignore.
72
+
73
+ ## Available Scripts
74
+
75
+ - `pnpm run dev`: Starts the development server.
76
+ - `pnpm run build`: Builds the project.
77
+ - `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.
78
+ - `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`.
79
+ - `pnpm test`: Runs the test suite using Vitest.
80
+ - `pnpm run typecheck`: Runs TypeScript type checking.
81
+ - `pnpm run typegen`: Generates TypeScript types using Wrangler.
82
+ - `pnpm run deploy`: Builds the project and deploys it to Cloudflare Pages.
83
+
84
+ ## Development
85
+
86
+ To start the development server:
87
+
88
+ ```bash
89
+ pnpm run dev
90
+ ```
91
+
92
+ This will start the Remix Vite development server.
93
+
94
+ ## Testing
95
+
96
+ Run the test suite with:
97
+
98
+ ```bash
99
+ pnpm test
100
+ ```
101
+
102
+ ## Deployment
103
+
104
+ To deploy the application to Cloudflare Pages:
105
+
106
+ ```bash
107
+ pnpm run deploy
108
+ ```
109
+
110
+ Make sure you have the necessary permissions and Wrangler is correctly configured for your Cloudflare account.
LICENSE ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ MIT License
2
+
3
+ Copyright (c) 2024 StackBlitz, Inc.
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
README.md CHANGED
@@ -1,10 +1,54 @@
1
- ---
2
- title: Newbolt
3
- emoji: 👁
4
- colorFrom: green
5
- colorTo: green
6
- sdk: docker
7
- pinned: false
8
- ---
9
-
10
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ [![Bolt.new: AI-Powered Full-Stack Web Development in the Browser](./public/social_preview_index.jpg)](https://bolt.new)
2
+
3
+ # Bolt.new: AI-Powered Full-Stack Web Development in the Browser
4
+
5
+ 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)
6
+
7
+ ## What Makes Bolt.new Different
8
+
9
+ Claude, v0, etc are incredible- but you can't install packages, run backends or edit code. That’s where Bolt.new stands out:
10
+
11
+ - **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:
12
+ - Install and run npm tools and libraries (like Vite, Next.js, and more)
13
+ - Run Node.js servers
14
+ - Interact with third-party APIs
15
+ - Deploy to production from chat
16
+ - Share your work via a URL
17
+
18
+ - **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.
19
+
20
+ Whether you’re an experienced developer, a PM or designer, Bolt.new allows you to build production-grade full-stack applications with ease.
21
+
22
+ For developers interested in building their own AI-powered development tools with WebContainers, check out the open-source Bolt codebase in this repo!
23
+
24
+ ## Tips and Tricks
25
+
26
+ Here are some tips to get the most out of Bolt.new:
27
+
28
+ - **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.
29
+
30
+ - **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.
31
+
32
+ - **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.
33
+
34
+ - **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.
35
+
36
+ ## FAQs
37
+
38
+ **Where do I sign up for a paid plan?**
39
+ 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.
40
+
41
+ **What happens if I hit the free usage limit?**
42
+ Once your free daily token limit is reached, AI interactions are paused until the next day or until you upgrade your plan.
43
+
44
+ **Is Bolt in beta?**
45
+ Yes, Bolt.new is in beta, and we are actively improving it based on feedback.
46
+
47
+ **How can I report Bolt.new issues?**
48
+ 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.
49
+
50
+ **What frameworks/libraries currently work on Bolt?**
51
+ Bolt.new supports most popular JavaScript frameworks and libraries. If it runs on StackBlitz, it will run on Bolt.new as well.
52
+
53
+ **How can I add make sure my framework/project works well in bolt?**
54
+ We are excited to work with the JavaScript ecosystem to improve functionality in Bolt. Reach out to us via [[email protected]](mailto:[email protected]) to discuss how we can partner!
app/components/chat/Artifact.tsx ADDED
@@ -0,0 +1,213 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useStore } from '@nanostores/react';
2
+ import { AnimatePresence, motion } from 'framer-motion';
3
+ import { computed } from 'nanostores';
4
+ import { memo, useEffect, useRef, useState } from 'react';
5
+ import { createHighlighter, type BundledLanguage, type BundledTheme, type HighlighterGeneric } from 'shiki';
6
+ import type { ActionState } from '~/lib/runtime/action-runner';
7
+ import { workbenchStore } from '~/lib/stores/workbench';
8
+ import { classNames } from '~/utils/classNames';
9
+ import { cubicEasingFn } from '~/utils/easings';
10
+
11
+ const highlighterOptions = {
12
+ langs: ['shell'],
13
+ themes: ['light-plus', 'dark-plus'],
14
+ };
15
+
16
+ const shellHighlighter: HighlighterGeneric<BundledLanguage, BundledTheme> =
17
+ import.meta.hot?.data.shellHighlighter ?? (await createHighlighter(highlighterOptions));
18
+
19
+ if (import.meta.hot) {
20
+ import.meta.hot.data.shellHighlighter = shellHighlighter;
21
+ }
22
+
23
+ interface ArtifactProps {
24
+ messageId: string;
25
+ }
26
+
27
+ export const Artifact = memo(({ messageId }: ArtifactProps) => {
28
+ const userToggledActions = useRef(false);
29
+ const [showActions, setShowActions] = useState(false);
30
+
31
+ const artifacts = useStore(workbenchStore.artifacts);
32
+ const artifact = artifacts[messageId];
33
+
34
+ const actions = useStore(
35
+ computed(artifact.runner.actions, (actions) => {
36
+ return Object.values(actions);
37
+ }),
38
+ );
39
+
40
+ const toggleActions = () => {
41
+ userToggledActions.current = true;
42
+ setShowActions(!showActions);
43
+ };
44
+
45
+ useEffect(() => {
46
+ if (actions.length && !showActions && !userToggledActions.current) {
47
+ setShowActions(true);
48
+ }
49
+ }, [actions]);
50
+
51
+ return (
52
+ <div className="artifact border border-bolt-elements-borderColor flex flex-col overflow-hidden rounded-lg w-full transition-border duration-150">
53
+ <div className="flex">
54
+ <button
55
+ className="flex items-stretch bg-bolt-elements-artifacts-background hover:bg-bolt-elements-artifacts-backgroundHover w-full overflow-hidden"
56
+ onClick={() => {
57
+ const showWorkbench = workbenchStore.showWorkbench.get();
58
+ workbenchStore.showWorkbench.set(!showWorkbench);
59
+ }}
60
+ >
61
+ <div className="px-5 p-3.5 w-full text-left">
62
+ <div className="w-full text-bolt-elements-textPrimary font-medium leading-5 text-sm">{artifact?.title}</div>
63
+ <div className="w-full w-full text-bolt-elements-textSecondary text-xs mt-0.5">Click to open Workbench</div>
64
+ </div>
65
+ </button>
66
+ <div className="bg-bolt-elements-artifacts-borderColor w-[1px]" />
67
+ <AnimatePresence>
68
+ {actions.length && (
69
+ <motion.button
70
+ initial={{ width: 0 }}
71
+ animate={{ width: 'auto' }}
72
+ exit={{ width: 0 }}
73
+ transition={{ duration: 0.15, ease: cubicEasingFn }}
74
+ className="bg-bolt-elements-artifacts-background hover:bg-bolt-elements-artifacts-backgroundHover"
75
+ onClick={toggleActions}
76
+ >
77
+ <div className="p-4">
78
+ <div className={showActions ? 'i-ph:caret-up-bold' : 'i-ph:caret-down-bold'}></div>
79
+ </div>
80
+ </motion.button>
81
+ )}
82
+ </AnimatePresence>
83
+ </div>
84
+ <AnimatePresence>
85
+ {showActions && actions.length > 0 && (
86
+ <motion.div
87
+ className="actions"
88
+ initial={{ height: 0 }}
89
+ animate={{ height: 'auto' }}
90
+ exit={{ height: '0px' }}
91
+ transition={{ duration: 0.15 }}
92
+ >
93
+ <div className="bg-bolt-elements-artifacts-borderColor h-[1px]" />
94
+ <div className="p-5 text-left bg-bolt-elements-actions-background">
95
+ <ActionList actions={actions} />
96
+ </div>
97
+ </motion.div>
98
+ )}
99
+ </AnimatePresence>
100
+ </div>
101
+ );
102
+ });
103
+
104
+ interface ShellCodeBlockProps {
105
+ classsName?: string;
106
+ code: string;
107
+ }
108
+
109
+ function ShellCodeBlock({ classsName, code }: ShellCodeBlockProps) {
110
+ return (
111
+ <div
112
+ className={classNames('text-xs', classsName)}
113
+ dangerouslySetInnerHTML={{
114
+ __html: shellHighlighter.codeToHtml(code, {
115
+ lang: 'shell',
116
+ theme: 'dark-plus',
117
+ }),
118
+ }}
119
+ ></div>
120
+ );
121
+ }
122
+
123
+ interface ActionListProps {
124
+ actions: ActionState[];
125
+ }
126
+
127
+ const actionVariants = {
128
+ hidden: { opacity: 0, y: 20 },
129
+ visible: { opacity: 1, y: 0 },
130
+ };
131
+
132
+ const ActionList = memo(({ actions }: ActionListProps) => {
133
+ return (
134
+ <motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }} transition={{ duration: 0.15 }}>
135
+ <ul className="list-none space-y-2.5">
136
+ {actions.map((action, index) => {
137
+ const { status, type, content } = action;
138
+ const isLast = index === actions.length - 1;
139
+
140
+ return (
141
+ <motion.li
142
+ key={index}
143
+ variants={actionVariants}
144
+ initial="hidden"
145
+ animate="visible"
146
+ transition={{
147
+ duration: 0.2,
148
+ ease: cubicEasingFn,
149
+ }}
150
+ >
151
+ <div className="flex items-center gap-1.5 text-sm">
152
+ <div className={classNames('text-lg', getIconColor(action.status))}>
153
+ {status === 'running' ? (
154
+ <div className="i-svg-spinners:90-ring-with-bg"></div>
155
+ ) : status === 'pending' ? (
156
+ <div className="i-ph:circle-duotone"></div>
157
+ ) : status === 'complete' ? (
158
+ <div className="i-ph:check"></div>
159
+ ) : status === 'failed' || status === 'aborted' ? (
160
+ <div className="i-ph:x"></div>
161
+ ) : null}
162
+ </div>
163
+ {type === 'file' ? (
164
+ <div>
165
+ Create{' '}
166
+ <code className="bg-bolt-elements-artifacts-inlineCode-background text-bolt-elements-artifacts-inlineCode-text px-1.5 py-1 rounded-md">
167
+ {action.filePath}
168
+ </code>
169
+ </div>
170
+ ) : type === 'shell' ? (
171
+ <div className="flex items-center w-full min-h-[28px]">
172
+ <span className="flex-1">Run command</span>
173
+ </div>
174
+ ) : null}
175
+ </div>
176
+ {type === 'shell' && (
177
+ <ShellCodeBlock
178
+ classsName={classNames('mt-1', {
179
+ 'mb-3.5': !isLast,
180
+ })}
181
+ code={content}
182
+ />
183
+ )}
184
+ </motion.li>
185
+ );
186
+ })}
187
+ </ul>
188
+ </motion.div>
189
+ );
190
+ });
191
+
192
+ function getIconColor(status: ActionState['status']) {
193
+ switch (status) {
194
+ case 'pending': {
195
+ return 'text-bolt-elements-textTertiary';
196
+ }
197
+ case 'running': {
198
+ return 'text-bolt-elements-loader-progress';
199
+ }
200
+ case 'complete': {
201
+ return 'text-bolt-elements-icon-success';
202
+ }
203
+ case 'aborted': {
204
+ return 'text-bolt-elements-textSecondary';
205
+ }
206
+ case 'failed': {
207
+ return 'text-bolt-elements-icon-error';
208
+ }
209
+ default: {
210
+ return undefined;
211
+ }
212
+ }
213
+ }
app/components/chat/AssistantMessage.tsx ADDED
@@ -0,0 +1,14 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { memo } from 'react';
2
+ import { Markdown } from './Markdown';
3
+
4
+ interface AssistantMessageProps {
5
+ content: string;
6
+ }
7
+
8
+ export const AssistantMessage = memo(({ content }: AssistantMessageProps) => {
9
+ return (
10
+ <div className="overflow-hidden w-full">
11
+ <Markdown html>{content}</Markdown>
12
+ </div>
13
+ );
14
+ });
app/components/chat/BaseChat.module.scss ADDED
@@ -0,0 +1,19 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ .BaseChat {
2
+ &[data-chat-visible='false'] {
3
+ --workbench-inner-width: 100%;
4
+ --workbench-left: 0;
5
+
6
+ .Chat {
7
+ --at-apply: bolt-ease-cubic-bezier;
8
+ transition-property: transform, opacity;
9
+ transition-duration: 0.3s;
10
+ will-change: transform, opacity;
11
+ transform: translateX(-50%);
12
+ opacity: 0;
13
+ }
14
+ }
15
+ }
16
+
17
+ .Chat {
18
+ opacity: 1;
19
+ }
app/components/chat/BaseChat.tsx ADDED
@@ -0,0 +1,213 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import type { Message } from 'ai';
2
+ import React, { type RefCallback } from 'react';
3
+ import { ClientOnly } from 'remix-utils/client-only';
4
+ import { Menu } from '~/components/sidebar/Menu.client';
5
+ import { IconButton } from '~/components/ui/IconButton';
6
+ import { Workbench } from '~/components/workbench/Workbench.client';
7
+ import { classNames } from '~/utils/classNames';
8
+ import { Messages } from './Messages.client';
9
+ import { SendButton } from './SendButton.client';
10
+
11
+ import styles from './BaseChat.module.scss';
12
+
13
+ interface BaseChatProps {
14
+ textareaRef?: React.RefObject<HTMLTextAreaElement> | undefined;
15
+ messageRef?: RefCallback<HTMLDivElement> | undefined;
16
+ scrollRef?: RefCallback<HTMLDivElement> | undefined;
17
+ showChat?: boolean;
18
+ chatStarted?: boolean;
19
+ isStreaming?: boolean;
20
+ messages?: Message[];
21
+ enhancingPrompt?: boolean;
22
+ promptEnhanced?: boolean;
23
+ input?: string;
24
+ handleStop?: () => void;
25
+ sendMessage?: (event: React.UIEvent, messageInput?: string) => void;
26
+ handleInputChange?: (event: React.ChangeEvent<HTMLTextAreaElement>) => void;
27
+ enhancePrompt?: () => void;
28
+ }
29
+
30
+ const EXAMPLE_PROMPTS = [
31
+ { text: 'Build a todo app in React using Tailwind' },
32
+ { text: 'Build a simple blog using Astro' },
33
+ { text: 'Create a cookie consent form using Material UI' },
34
+ { text: 'Make a space invaders game' },
35
+ { text: 'How do I center a div?' },
36
+ ];
37
+
38
+ const TEXTAREA_MIN_HEIGHT = 76;
39
+
40
+ export const BaseChat = React.forwardRef<HTMLDivElement, BaseChatProps>(
41
+ (
42
+ {
43
+ textareaRef,
44
+ messageRef,
45
+ scrollRef,
46
+ showChat = true,
47
+ chatStarted = false,
48
+ isStreaming = false,
49
+ enhancingPrompt = false,
50
+ promptEnhanced = false,
51
+ messages,
52
+ input = '',
53
+ sendMessage,
54
+ handleInputChange,
55
+ enhancePrompt,
56
+ handleStop,
57
+ },
58
+ ref,
59
+ ) => {
60
+ const TEXTAREA_MAX_HEIGHT = chatStarted ? 400 : 200;
61
+
62
+ return (
63
+ <div
64
+ ref={ref}
65
+ className={classNames(
66
+ styles.BaseChat,
67
+ 'relative flex h-full w-full overflow-hidden bg-bolt-elements-background-depth-1',
68
+ )}
69
+ data-chat-visible={showChat}
70
+ >
71
+ <ClientOnly>{() => <Menu />}</ClientOnly>
72
+ <div ref={scrollRef} className="flex overflow-y-auto w-full h-full">
73
+ <div className={classNames(styles.Chat, 'flex flex-col flex-grow min-w-[var(--chat-min-width)] h-full')}>
74
+ {!chatStarted && (
75
+ <div id="intro" className="mt-[26vh] max-w-chat mx-auto">
76
+ <h1 className="text-5xl text-center font-bold text-bolt-elements-textPrimary mb-2">
77
+ Where ideas begin
78
+ </h1>
79
+ <p className="mb-4 text-center text-bolt-elements-textSecondary">
80
+ Bring ideas to life in seconds or get help on existing projects.
81
+ </p>
82
+ </div>
83
+ )}
84
+ <div
85
+ className={classNames('pt-6 px-6', {
86
+ 'h-full flex flex-col': chatStarted,
87
+ })}
88
+ >
89
+ <ClientOnly>
90
+ {() => {
91
+ return chatStarted ? (
92
+ <Messages
93
+ ref={messageRef}
94
+ className="flex flex-col w-full flex-1 max-w-chat px-4 pb-6 mx-auto z-1"
95
+ messages={messages}
96
+ isStreaming={isStreaming}
97
+ />
98
+ ) : null;
99
+ }}
100
+ </ClientOnly>
101
+ <div
102
+ className={classNames('relative w-full max-w-chat mx-auto z-prompt', {
103
+ 'sticky bottom-0': chatStarted,
104
+ })}
105
+ >
106
+ <div
107
+ className={classNames(
108
+ 'shadow-sm border border-bolt-elements-borderColor bg-bolt-elements-prompt-background backdrop-filter backdrop-blur-[8px] rounded-lg overflow-hidden',
109
+ )}
110
+ >
111
+ <textarea
112
+ ref={textareaRef}
113
+ 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`}
114
+ onKeyDown={(event) => {
115
+ if (event.key === 'Enter') {
116
+ if (event.shiftKey) {
117
+ return;
118
+ }
119
+
120
+ event.preventDefault();
121
+
122
+ sendMessage?.(event);
123
+ }
124
+ }}
125
+ value={input}
126
+ onChange={(event) => {
127
+ handleInputChange?.(event);
128
+ }}
129
+ style={{
130
+ minHeight: TEXTAREA_MIN_HEIGHT,
131
+ maxHeight: TEXTAREA_MAX_HEIGHT,
132
+ }}
133
+ placeholder="How can Bolt help you today?"
134
+ translate="no"
135
+ />
136
+ <ClientOnly>
137
+ {() => (
138
+ <SendButton
139
+ show={input.length > 0 || isStreaming}
140
+ isStreaming={isStreaming}
141
+ onClick={(event) => {
142
+ if (isStreaming) {
143
+ handleStop?.();
144
+ return;
145
+ }
146
+
147
+ sendMessage?.(event);
148
+ }}
149
+ />
150
+ )}
151
+ </ClientOnly>
152
+ <div className="flex justify-between text-sm p-4 pt-2">
153
+ <div className="flex gap-1 items-center">
154
+ <IconButton
155
+ title="Enhance prompt"
156
+ disabled={input.length === 0 || enhancingPrompt}
157
+ className={classNames({
158
+ 'opacity-100!': enhancingPrompt,
159
+ 'text-bolt-elements-item-contentAccent! pr-1.5 enabled:hover:bg-bolt-elements-item-backgroundAccent!':
160
+ promptEnhanced,
161
+ })}
162
+ onClick={() => enhancePrompt?.()}
163
+ >
164
+ {enhancingPrompt ? (
165
+ <>
166
+ <div className="i-svg-spinners:90-ring-with-bg text-bolt-elements-loader-progress text-xl"></div>
167
+ <div className="ml-1.5">Enhancing prompt...</div>
168
+ </>
169
+ ) : (
170
+ <>
171
+ <div className="i-bolt:stars text-xl"></div>
172
+ {promptEnhanced && <div className="ml-1.5">Prompt enhanced</div>}
173
+ </>
174
+ )}
175
+ </IconButton>
176
+ </div>
177
+ {input.length > 3 ? (
178
+ <div className="text-xs text-bolt-elements-textTertiary">
179
+ Use <kbd className="kdb">Shift</kbd> + <kbd className="kdb">Return</kbd> for a new line
180
+ </div>
181
+ ) : null}
182
+ </div>
183
+ </div>
184
+ <div className="bg-bolt-elements-background-depth-1 pb-6">{/* Ghost Element */}</div>
185
+ </div>
186
+ </div>
187
+ {!chatStarted && (
188
+ <div id="examples" className="relative w-full max-w-xl mx-auto mt-8 flex justify-center">
189
+ <div className="flex flex-col space-y-2 [mask-image:linear-gradient(to_bottom,black_0%,transparent_180%)] hover:[mask-image:none]">
190
+ {EXAMPLE_PROMPTS.map((examplePrompt, index) => {
191
+ return (
192
+ <button
193
+ key={index}
194
+ onClick={(event) => {
195
+ sendMessage?.(event, examplePrompt.text);
196
+ }}
197
+ className="group flex items-center w-full gap-2 justify-center bg-transparent text-bolt-elements-textTertiary hover:text-bolt-elements-textPrimary transition-theme"
198
+ >
199
+ {examplePrompt.text}
200
+ <div className="i-ph:arrow-bend-down-left" />
201
+ </button>
202
+ );
203
+ })}
204
+ </div>
205
+ </div>
206
+ )}
207
+ </div>
208
+ <ClientOnly>{() => <Workbench chatStarted={chatStarted} isStreaming={isStreaming} />}</ClientOnly>
209
+ </div>
210
+ </div>
211
+ );
212
+ },
213
+ );
app/components/chat/Chat.client.tsx ADDED
@@ -0,0 +1,234 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useStore } from '@nanostores/react';
2
+ import type { Message } from 'ai';
3
+ import { useChat } from 'ai/react';
4
+ import { useAnimate } from 'framer-motion';
5
+ import { memo, useEffect, useRef, useState } from 'react';
6
+ import { cssTransition, toast, ToastContainer } from 'react-toastify';
7
+ import { useMessageParser, usePromptEnhancer, useShortcuts, useSnapScroll } from '~/lib/hooks';
8
+ import { useChatHistory } from '~/lib/persistence';
9
+ import { chatStore } from '~/lib/stores/chat';
10
+ import { workbenchStore } from '~/lib/stores/workbench';
11
+ import { fileModificationsToHTML } from '~/utils/diff';
12
+ import { cubicEasingFn } from '~/utils/easings';
13
+ import { createScopedLogger, renderLogger } from '~/utils/logger';
14
+ import { BaseChat } from './BaseChat';
15
+
16
+ const toastAnimation = cssTransition({
17
+ enter: 'animated fadeInRight',
18
+ exit: 'animated fadeOutRight',
19
+ });
20
+
21
+ const logger = createScopedLogger('Chat');
22
+
23
+ export function Chat() {
24
+ renderLogger.trace('Chat');
25
+
26
+ const { ready, initialMessages, storeMessageHistory } = useChatHistory();
27
+
28
+ return (
29
+ <>
30
+ {ready && <ChatImpl initialMessages={initialMessages} storeMessageHistory={storeMessageHistory} />}
31
+ <ToastContainer
32
+ closeButton={({ closeToast }) => {
33
+ return (
34
+ <button className="Toastify__close-button" onClick={closeToast}>
35
+ <div className="i-ph:x text-lg" />
36
+ </button>
37
+ );
38
+ }}
39
+ icon={({ type }) => {
40
+ /**
41
+ * @todo Handle more types if we need them. This may require extra color palettes.
42
+ */
43
+ switch (type) {
44
+ case 'success': {
45
+ return <div className="i-ph:check-bold text-bolt-elements-icon-success text-2xl" />;
46
+ }
47
+ case 'error': {
48
+ return <div className="i-ph:warning-circle-bold text-bolt-elements-icon-error text-2xl" />;
49
+ }
50
+ }
51
+
52
+ return undefined;
53
+ }}
54
+ position="bottom-right"
55
+ pauseOnFocusLoss
56
+ transition={toastAnimation}
57
+ />
58
+ </>
59
+ );
60
+ }
61
+
62
+ interface ChatProps {
63
+ initialMessages: Message[];
64
+ storeMessageHistory: (messages: Message[]) => Promise<void>;
65
+ }
66
+
67
+ export const ChatImpl = memo(({ initialMessages, storeMessageHistory }: ChatProps) => {
68
+ useShortcuts();
69
+
70
+ const textareaRef = useRef<HTMLTextAreaElement>(null);
71
+
72
+ const [chatStarted, setChatStarted] = useState(initialMessages.length > 0);
73
+
74
+ const { showChat } = useStore(chatStore);
75
+
76
+ const [animationScope, animate] = useAnimate();
77
+
78
+ const { messages, isLoading, input, handleInputChange, setInput, stop, append } = useChat({
79
+ api: '/api/chat',
80
+ onError: (error) => {
81
+ logger.error('Request failed\n\n', error);
82
+ toast.error('There was an error processing your request');
83
+ },
84
+ onFinish: () => {
85
+ logger.debug('Finished streaming');
86
+ },
87
+ initialMessages,
88
+ });
89
+
90
+ const { enhancingPrompt, promptEnhanced, enhancePrompt, resetEnhancer } = usePromptEnhancer();
91
+ const { parsedMessages, parseMessages } = useMessageParser();
92
+
93
+ const TEXTAREA_MAX_HEIGHT = chatStarted ? 400 : 200;
94
+
95
+ useEffect(() => {
96
+ chatStore.setKey('started', initialMessages.length > 0);
97
+ }, []);
98
+
99
+ useEffect(() => {
100
+ parseMessages(messages, isLoading);
101
+
102
+ if (messages.length > initialMessages.length) {
103
+ storeMessageHistory(messages).catch((error) => toast.error(error.message));
104
+ }
105
+ }, [messages, isLoading, parseMessages]);
106
+
107
+ const scrollTextArea = () => {
108
+ const textarea = textareaRef.current;
109
+
110
+ if (textarea) {
111
+ textarea.scrollTop = textarea.scrollHeight;
112
+ }
113
+ };
114
+
115
+ const abort = () => {
116
+ stop();
117
+ chatStore.setKey('aborted', true);
118
+ workbenchStore.abortAllActions();
119
+ };
120
+
121
+ useEffect(() => {
122
+ const textarea = textareaRef.current;
123
+
124
+ if (textarea) {
125
+ textarea.style.height = 'auto';
126
+
127
+ const scrollHeight = textarea.scrollHeight;
128
+
129
+ textarea.style.height = `${Math.min(scrollHeight, TEXTAREA_MAX_HEIGHT)}px`;
130
+ textarea.style.overflowY = scrollHeight > TEXTAREA_MAX_HEIGHT ? 'auto' : 'hidden';
131
+ }
132
+ }, [input, textareaRef]);
133
+
134
+ const runAnimation = async () => {
135
+ if (chatStarted) {
136
+ return;
137
+ }
138
+
139
+ await Promise.all([
140
+ animate('#examples', { opacity: 0, display: 'none' }, { duration: 0.1 }),
141
+ animate('#intro', { opacity: 0, flex: 1 }, { duration: 0.2, ease: cubicEasingFn }),
142
+ ]);
143
+
144
+ chatStore.setKey('started', true);
145
+
146
+ setChatStarted(true);
147
+ };
148
+
149
+ const sendMessage = async (_event: React.UIEvent, messageInput?: string) => {
150
+ const _input = messageInput || input;
151
+
152
+ if (_input.length === 0 || isLoading) {
153
+ return;
154
+ }
155
+
156
+ /**
157
+ * @note (delm) Usually saving files shouldn't take long but it may take longer if there
158
+ * many unsaved files. In that case we need to block user input and show an indicator
159
+ * of some kind so the user is aware that something is happening. But I consider the
160
+ * happy case to be no unsaved files and I would expect users to save their changes
161
+ * before they send another message.
162
+ */
163
+ await workbenchStore.saveAllFiles();
164
+
165
+ const fileModifications = workbenchStore.getFileModifcations();
166
+
167
+ chatStore.setKey('aborted', false);
168
+
169
+ runAnimation();
170
+
171
+ if (fileModifications !== undefined) {
172
+ const diff = fileModificationsToHTML(fileModifications);
173
+
174
+ /**
175
+ * If we have file modifications we append a new user message manually since we have to prefix
176
+ * the user input with the file modifications and we don't want the new user input to appear
177
+ * in the prompt. Using `append` is almost the same as `handleSubmit` except that we have to
178
+ * manually reset the input and we'd have to manually pass in file attachments. However, those
179
+ * aren't relevant here.
180
+ */
181
+ append({ role: 'user', content: `${diff}\n\n${_input}` });
182
+
183
+ /**
184
+ * After sending a new message we reset all modifications since the model
185
+ * should now be aware of all the changes.
186
+ */
187
+ workbenchStore.resetAllFileModifications();
188
+ } else {
189
+ append({ role: 'user', content: _input });
190
+ }
191
+
192
+ setInput('');
193
+
194
+ resetEnhancer();
195
+
196
+ textareaRef.current?.blur();
197
+ };
198
+
199
+ const [messageRef, scrollRef] = useSnapScroll();
200
+
201
+ return (
202
+ <BaseChat
203
+ ref={animationScope}
204
+ textareaRef={textareaRef}
205
+ input={input}
206
+ showChat={showChat}
207
+ chatStarted={chatStarted}
208
+ isStreaming={isLoading}
209
+ enhancingPrompt={enhancingPrompt}
210
+ promptEnhanced={promptEnhanced}
211
+ sendMessage={sendMessage}
212
+ messageRef={messageRef}
213
+ scrollRef={scrollRef}
214
+ handleInputChange={handleInputChange}
215
+ handleStop={abort}
216
+ messages={messages.map((message, i) => {
217
+ if (message.role === 'user') {
218
+ return message;
219
+ }
220
+
221
+ return {
222
+ ...message,
223
+ content: parsedMessages[i] || '',
224
+ };
225
+ })}
226
+ enhancePrompt={() => {
227
+ enhancePrompt(input, (input) => {
228
+ setInput(input);
229
+ scrollTextArea();
230
+ });
231
+ }}
232
+ />
233
+ );
234
+ });
app/components/chat/CodeBlock.module.scss ADDED
@@ -0,0 +1,10 @@
 
 
 
 
 
 
 
 
 
 
 
1
+ .CopyButtonContainer {
2
+ button:before {
3
+ content: 'Copied';
4
+ font-size: 12px;
5
+ position: absolute;
6
+ left: -53px;
7
+ padding: 2px 6px;
8
+ height: 30px;
9
+ }
10
+ }
app/components/chat/CodeBlock.tsx ADDED
@@ -0,0 +1,82 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { memo, useEffect, useState } from 'react';
2
+ import { bundledLanguages, codeToHtml, isSpecialLang, type BundledLanguage, type SpecialLanguage } from 'shiki';
3
+ import { classNames } from '~/utils/classNames';
4
+ import { createScopedLogger } from '~/utils/logger';
5
+
6
+ import styles from './CodeBlock.module.scss';
7
+
8
+ const logger = createScopedLogger('CodeBlock');
9
+
10
+ interface CodeBlockProps {
11
+ className?: string;
12
+ code: string;
13
+ language?: BundledLanguage | SpecialLanguage;
14
+ theme?: 'light-plus' | 'dark-plus';
15
+ disableCopy?: boolean;
16
+ }
17
+
18
+ export const CodeBlock = memo(
19
+ ({ className, code, language = 'plaintext', theme = 'dark-plus', disableCopy = false }: CodeBlockProps) => {
20
+ const [html, setHTML] = useState<string | undefined>(undefined);
21
+ const [copied, setCopied] = useState(false);
22
+
23
+ const copyToClipboard = () => {
24
+ if (copied) {
25
+ return;
26
+ }
27
+
28
+ navigator.clipboard.writeText(code);
29
+
30
+ setCopied(true);
31
+
32
+ setTimeout(() => {
33
+ setCopied(false);
34
+ }, 2000);
35
+ };
36
+
37
+ useEffect(() => {
38
+ if (language && !isSpecialLang(language) && !(language in bundledLanguages)) {
39
+ logger.warn(`Unsupported language '${language}'`);
40
+ }
41
+
42
+ logger.trace(`Language = ${language}`);
43
+
44
+ const processCode = async () => {
45
+ setHTML(await codeToHtml(code, { lang: language, theme }));
46
+ };
47
+
48
+ processCode();
49
+ }, [code]);
50
+
51
+ return (
52
+ <div className={classNames('relative group text-left', className)}>
53
+ <div
54
+ className={classNames(
55
+ styles.CopyButtonContainer,
56
+ 'bg-white absolute top-[10px] right-[10px] rounded-md z-10 text-lg flex items-center justify-center opacity-0 group-hover:opacity-100',
57
+ {
58
+ 'rounded-l-0 opacity-100': copied,
59
+ },
60
+ )}
61
+ >
62
+ {!disableCopy && (
63
+ <button
64
+ className={classNames(
65
+ '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',
66
+ {
67
+ 'before:opacity-0': !copied,
68
+ 'before:opacity-100': copied,
69
+ },
70
+ )}
71
+ title="Copy Code"
72
+ onClick={() => copyToClipboard()}
73
+ >
74
+ <div className="i-ph:clipboard-text-duotone"></div>
75
+ </button>
76
+ )}
77
+ </div>
78
+ <div dangerouslySetInnerHTML={{ __html: html ?? '' }}></div>
79
+ </div>
80
+ );
81
+ },
82
+ );
app/components/chat/Markdown.module.scss ADDED
@@ -0,0 +1,171 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ $font-mono: ui-monospace, 'Fira Code', Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace;
2
+ $code-font-size: 13px;
3
+
4
+ @mixin not-inside-actions {
5
+ &:not(:has(:global(.actions)), :global(.actions *)) {
6
+ @content;
7
+ }
8
+ }
9
+
10
+ .MarkdownContent {
11
+ line-height: 1.6;
12
+ color: var(--bolt-elements-textPrimary);
13
+
14
+ > *:not(:last-child) {
15
+ margin-block-end: 16px;
16
+ }
17
+
18
+ :global(.artifact) {
19
+ margin: 1.5em 0;
20
+ }
21
+
22
+ :is(h1, h2, h3, h4, h5, h6) {
23
+ @include not-inside-actions {
24
+ margin-block-start: 24px;
25
+ margin-block-end: 16px;
26
+ font-weight: 600;
27
+ line-height: 1.25;
28
+ color: var(--bolt-elements-textPrimary);
29
+ }
30
+ }
31
+
32
+ h1 {
33
+ font-size: 2em;
34
+ border-bottom: 1px solid var(--bolt-elements-borderColor);
35
+ padding-bottom: 0.3em;
36
+ }
37
+
38
+ h2 {
39
+ font-size: 1.5em;
40
+ border-bottom: 1px solid var(--bolt-elements-borderColor);
41
+ padding-bottom: 0.3em;
42
+ }
43
+
44
+ h3 {
45
+ font-size: 1.25em;
46
+ }
47
+
48
+ h4 {
49
+ font-size: 1em;
50
+ }
51
+
52
+ h5 {
53
+ font-size: 0.875em;
54
+ }
55
+
56
+ h6 {
57
+ font-size: 0.85em;
58
+ color: #6a737d;
59
+ }
60
+
61
+ p {
62
+ white-space: pre-wrap;
63
+
64
+ &:not(:last-of-type) {
65
+ margin-block-start: 0;
66
+ margin-block-end: 16px;
67
+ }
68
+ }
69
+
70
+ a {
71
+ color: var(--bolt-elements-messages-linkColor);
72
+ text-decoration: none;
73
+ cursor: pointer;
74
+
75
+ &:hover {
76
+ text-decoration: underline;
77
+ }
78
+ }
79
+
80
+ :not(pre) > code {
81
+ font-family: $font-mono;
82
+ font-size: $code-font-size;
83
+
84
+ @include not-inside-actions {
85
+ border-radius: 6px;
86
+ padding: 0.2em 0.4em;
87
+ background-color: var(--bolt-elements-messages-inlineCode-background);
88
+ color: var(--bolt-elements-messages-inlineCode-text);
89
+ }
90
+ }
91
+
92
+ pre {
93
+ padding: 20px 16px;
94
+ border-radius: 6px;
95
+ }
96
+
97
+ pre:has(> code) {
98
+ font-family: $font-mono;
99
+ font-size: $code-font-size;
100
+ background: transparent;
101
+ overflow-x: auto;
102
+ min-width: 0;
103
+ }
104
+
105
+ blockquote {
106
+ margin: 0;
107
+ padding: 0 1em;
108
+ color: var(--bolt-elements-textTertiary);
109
+ border-left: 0.25em solid var(--bolt-elements-borderColor);
110
+ }
111
+
112
+ :is(ul, ol) {
113
+ @include not-inside-actions {
114
+ padding-left: 2em;
115
+ margin-block-start: 0;
116
+ margin-block-end: 16px;
117
+ }
118
+ }
119
+
120
+ ul {
121
+ @include not-inside-actions {
122
+ list-style-type: disc;
123
+ }
124
+ }
125
+
126
+ ol {
127
+ @include not-inside-actions {
128
+ list-style-type: decimal;
129
+ }
130
+ }
131
+
132
+ li {
133
+ @include not-inside-actions {
134
+ & + li {
135
+ margin-block-start: 8px;
136
+ }
137
+
138
+ > *:not(:last-child) {
139
+ margin-block-end: 16px;
140
+ }
141
+ }
142
+ }
143
+
144
+ img {
145
+ max-width: 100%;
146
+ box-sizing: border-box;
147
+ }
148
+
149
+ hr {
150
+ height: 0.25em;
151
+ padding: 0;
152
+ margin: 24px 0;
153
+ background-color: var(--bolt-elements-borderColor);
154
+ border: 0;
155
+ }
156
+
157
+ table {
158
+ border-collapse: collapse;
159
+ width: 100%;
160
+ margin-block-end: 16px;
161
+
162
+ :is(th, td) {
163
+ padding: 6px 13px;
164
+ border: 1px solid #dfe2e5;
165
+ }
166
+
167
+ tr:nth-child(2n) {
168
+ background-color: #f6f8fa;
169
+ }
170
+ }
171
+ }
app/components/chat/Markdown.tsx ADDED
@@ -0,0 +1,74 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { memo, useMemo } from 'react';
2
+ import ReactMarkdown, { type Components } from 'react-markdown';
3
+ import type { BundledLanguage } from 'shiki';
4
+ import { createScopedLogger } from '~/utils/logger';
5
+ import { rehypePlugins, remarkPlugins, allowedHTMLElements } from '~/utils/markdown';
6
+ import { Artifact } from './Artifact';
7
+ import { CodeBlock } from './CodeBlock';
8
+
9
+ import styles from './Markdown.module.scss';
10
+
11
+ const logger = createScopedLogger('MarkdownComponent');
12
+
13
+ interface MarkdownProps {
14
+ children: string;
15
+ html?: boolean;
16
+ limitedMarkdown?: boolean;
17
+ }
18
+
19
+ export const Markdown = memo(({ children, html = false, limitedMarkdown = false }: MarkdownProps) => {
20
+ logger.trace('Render');
21
+
22
+ const components = useMemo(() => {
23
+ return {
24
+ div: ({ className, children, node, ...props }) => {
25
+ if (className?.includes('__boltArtifact__')) {
26
+ const messageId = node?.properties.dataMessageId as string;
27
+
28
+ if (!messageId) {
29
+ logger.error(`Invalid message id ${messageId}`);
30
+ }
31
+
32
+ return <Artifact messageId={messageId} />;
33
+ }
34
+
35
+ return (
36
+ <div className={className} {...props}>
37
+ {children}
38
+ </div>
39
+ );
40
+ },
41
+ pre: (props) => {
42
+ const { children, node, ...rest } = props;
43
+
44
+ const [firstChild] = node?.children ?? [];
45
+
46
+ if (
47
+ firstChild &&
48
+ firstChild.type === 'element' &&
49
+ firstChild.tagName === 'code' &&
50
+ firstChild.children[0].type === 'text'
51
+ ) {
52
+ const { className, ...rest } = firstChild.properties;
53
+ const [, language = 'plaintext'] = /language-(\w+)/.exec(String(className) || '') ?? [];
54
+
55
+ return <CodeBlock code={firstChild.children[0].value} language={language as BundledLanguage} {...rest} />;
56
+ }
57
+
58
+ return <pre {...rest}>{children}</pre>;
59
+ },
60
+ } satisfies Components;
61
+ }, []);
62
+
63
+ return (
64
+ <ReactMarkdown
65
+ allowedElements={allowedHTMLElements}
66
+ className={styles.MarkdownContent}
67
+ components={components}
68
+ remarkPlugins={remarkPlugins(limitedMarkdown)}
69
+ rehypePlugins={rehypePlugins(html)}
70
+ >
71
+ {children}
72
+ </ReactMarkdown>
73
+ );
74
+ });
app/components/chat/Messages.client.tsx ADDED
@@ -0,0 +1,53 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import type { Message } from 'ai';
2
+ import React from 'react';
3
+ import { classNames } from '~/utils/classNames';
4
+ import { AssistantMessage } from './AssistantMessage';
5
+ import { UserMessage } from './UserMessage';
6
+
7
+ interface MessagesProps {
8
+ id?: string;
9
+ className?: string;
10
+ isStreaming?: boolean;
11
+ messages?: Message[];
12
+ }
13
+
14
+ export const Messages = React.forwardRef<HTMLDivElement, MessagesProps>((props: MessagesProps, ref) => {
15
+ const { id, isStreaming = false, messages = [] } = props;
16
+
17
+ return (
18
+ <div id={id} ref={ref} className={props.className}>
19
+ {messages.length > 0
20
+ ? messages.map((message, index) => {
21
+ const { role, content } = message;
22
+ const isUserMessage = role === 'user';
23
+ const isFirst = index === 0;
24
+ const isLast = index === messages.length - 1;
25
+
26
+ return (
27
+ <div
28
+ key={index}
29
+ className={classNames('flex gap-4 p-6 w-full rounded-[calc(0.75rem-1px)]', {
30
+ 'bg-bolt-elements-messages-background': isUserMessage || !isStreaming || (isStreaming && !isLast),
31
+ 'bg-gradient-to-b from-bolt-elements-messages-background from-30% to-transparent':
32
+ isStreaming && isLast,
33
+ 'mt-4': !isFirst,
34
+ })}
35
+ >
36
+ {isUserMessage && (
37
+ <div className="flex items-center justify-center w-[34px] h-[34px] overflow-hidden bg-white text-gray-600 rounded-full shrink-0 self-start">
38
+ <div className="i-ph:user-fill text-xl"></div>
39
+ </div>
40
+ )}
41
+ <div className="grid grid-col-1 w-full">
42
+ {isUserMessage ? <UserMessage content={content} /> : <AssistantMessage content={content} />}
43
+ </div>
44
+ </div>
45
+ );
46
+ })
47
+ : null}
48
+ {isStreaming && (
49
+ <div className="text-center w-full text-bolt-elements-textSecondary i-svg-spinners:3-dots-fade text-4xl mt-4"></div>
50
+ )}
51
+ </div>
52
+ );
53
+ });
app/components/chat/SendButton.client.tsx ADDED
@@ -0,0 +1,33 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { AnimatePresence, cubicBezier, motion } from 'framer-motion';
2
+
3
+ interface SendButtonProps {
4
+ show: boolean;
5
+ isStreaming?: boolean;
6
+ onClick?: (event: React.MouseEvent<HTMLButtonElement, MouseEvent>) => void;
7
+ }
8
+
9
+ const customEasingFn = cubicBezier(0.4, 0, 0.2, 1);
10
+
11
+ export function SendButton({ show, isStreaming, onClick }: SendButtonProps) {
12
+ return (
13
+ <AnimatePresence>
14
+ {show ? (
15
+ <motion.button
16
+ 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"
17
+ transition={{ ease: customEasingFn, duration: 0.17 }}
18
+ initial={{ opacity: 0, y: 10 }}
19
+ animate={{ opacity: 1, y: 0 }}
20
+ exit={{ opacity: 0, y: 10 }}
21
+ onClick={(event) => {
22
+ event.preventDefault();
23
+ onClick?.(event);
24
+ }}
25
+ >
26
+ <div className="text-lg">
27
+ {!isStreaming ? <div className="i-ph:arrow-right"></div> : <div className="i-ph:stop-circle-bold"></div>}
28
+ </div>
29
+ </motion.button>
30
+ ) : null}
31
+ </AnimatePresence>
32
+ );
33
+ }
app/components/chat/UserMessage.tsx ADDED
@@ -0,0 +1,18 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { modificationsRegex } from '~/utils/diff';
2
+ import { Markdown } from './Markdown';
3
+
4
+ interface UserMessageProps {
5
+ content: string;
6
+ }
7
+
8
+ export function UserMessage({ content }: UserMessageProps) {
9
+ return (
10
+ <div className="overflow-hidden pt-[4px]">
11
+ <Markdown limitedMarkdown>{sanitizeUserMessage(content)}</Markdown>
12
+ </div>
13
+ );
14
+ }
15
+
16
+ function sanitizeUserMessage(content: string) {
17
+ return content.replace(modificationsRegex, '').trim();
18
+ }
app/components/editor/codemirror/BinaryContent.tsx ADDED
@@ -0,0 +1,7 @@
 
 
 
 
 
 
 
 
1
+ export function BinaryContent() {
2
+ return (
3
+ <div className="flex items-center justify-center absolute inset-0 z-10 text-sm bg-tk-elements-app-backgroundColor text-tk-elements-app-textColor">
4
+ File format cannot be displayed.
5
+ </div>
6
+ );
7
+ }
app/components/editor/codemirror/CodeMirrorEditor.tsx ADDED
@@ -0,0 +1,461 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { acceptCompletion, autocompletion, closeBrackets } from '@codemirror/autocomplete';
2
+ import { defaultKeymap, history, historyKeymap } from '@codemirror/commands';
3
+ import { bracketMatching, foldGutter, indentOnInput, indentUnit } from '@codemirror/language';
4
+ import { searchKeymap } from '@codemirror/search';
5
+ import { Compartment, EditorSelection, EditorState, StateEffect, StateField, type Extension } from '@codemirror/state';
6
+ import {
7
+ drawSelection,
8
+ dropCursor,
9
+ EditorView,
10
+ highlightActiveLine,
11
+ highlightActiveLineGutter,
12
+ keymap,
13
+ lineNumbers,
14
+ scrollPastEnd,
15
+ showTooltip,
16
+ tooltips,
17
+ type Tooltip,
18
+ } from '@codemirror/view';
19
+ import { memo, useEffect, useRef, useState, type MutableRefObject } from 'react';
20
+ import type { Theme } from '~/types/theme';
21
+ import { classNames } from '~/utils/classNames';
22
+ import { debounce } from '~/utils/debounce';
23
+ import { createScopedLogger, renderLogger } from '~/utils/logger';
24
+ import { BinaryContent } from './BinaryContent';
25
+ import { getTheme, reconfigureTheme } from './cm-theme';
26
+ import { indentKeyBinding } from './indent';
27
+ import { getLanguage } from './languages';
28
+
29
+ const logger = createScopedLogger('CodeMirrorEditor');
30
+
31
+ export interface EditorDocument {
32
+ value: string;
33
+ isBinary: boolean;
34
+ filePath: string;
35
+ scroll?: ScrollPosition;
36
+ }
37
+
38
+ export interface EditorSettings {
39
+ fontSize?: string;
40
+ gutterFontSize?: string;
41
+ tabSize?: number;
42
+ }
43
+
44
+ type TextEditorDocument = EditorDocument & {
45
+ value: string;
46
+ };
47
+
48
+ export interface ScrollPosition {
49
+ top: number;
50
+ left: number;
51
+ }
52
+
53
+ export interface EditorUpdate {
54
+ selection: EditorSelection;
55
+ content: string;
56
+ }
57
+
58
+ export type OnChangeCallback = (update: EditorUpdate) => void;
59
+ export type OnScrollCallback = (position: ScrollPosition) => void;
60
+ export type OnSaveCallback = () => void;
61
+
62
+ interface Props {
63
+ theme: Theme;
64
+ id?: unknown;
65
+ doc?: EditorDocument;
66
+ editable?: boolean;
67
+ debounceChange?: number;
68
+ debounceScroll?: number;
69
+ autoFocusOnDocumentChange?: boolean;
70
+ onChange?: OnChangeCallback;
71
+ onScroll?: OnScrollCallback;
72
+ onSave?: OnSaveCallback;
73
+ className?: string;
74
+ settings?: EditorSettings;
75
+ }
76
+
77
+ type EditorStates = Map<string, EditorState>;
78
+
79
+ const readOnlyTooltipStateEffect = StateEffect.define<boolean>();
80
+
81
+ const editableTooltipField = StateField.define<readonly Tooltip[]>({
82
+ create: () => [],
83
+ update(_tooltips, transaction) {
84
+ if (!transaction.state.readOnly) {
85
+ return [];
86
+ }
87
+
88
+ for (const effect of transaction.effects) {
89
+ if (effect.is(readOnlyTooltipStateEffect) && effect.value) {
90
+ return getReadOnlyTooltip(transaction.state);
91
+ }
92
+ }
93
+
94
+ return [];
95
+ },
96
+ provide: (field) => {
97
+ return showTooltip.computeN([field], (state) => state.field(field));
98
+ },
99
+ });
100
+
101
+ const editableStateEffect = StateEffect.define<boolean>();
102
+
103
+ const editableStateField = StateField.define<boolean>({
104
+ create() {
105
+ return true;
106
+ },
107
+ update(value, transaction) {
108
+ for (const effect of transaction.effects) {
109
+ if (effect.is(editableStateEffect)) {
110
+ return effect.value;
111
+ }
112
+ }
113
+
114
+ return value;
115
+ },
116
+ });
117
+
118
+ export const CodeMirrorEditor = memo(
119
+ ({
120
+ id,
121
+ doc,
122
+ debounceScroll = 100,
123
+ debounceChange = 150,
124
+ autoFocusOnDocumentChange = false,
125
+ editable = true,
126
+ onScroll,
127
+ onChange,
128
+ onSave,
129
+ theme,
130
+ settings,
131
+ className = '',
132
+ }: Props) => {
133
+ renderLogger.trace('CodeMirrorEditor');
134
+
135
+ const [languageCompartment] = useState(new Compartment());
136
+
137
+ const containerRef = useRef<HTMLDivElement | null>(null);
138
+ const viewRef = useRef<EditorView>();
139
+ const themeRef = useRef<Theme>();
140
+ const docRef = useRef<EditorDocument>();
141
+ const editorStatesRef = useRef<EditorStates>();
142
+ const onScrollRef = useRef(onScroll);
143
+ const onChangeRef = useRef(onChange);
144
+ const onSaveRef = useRef(onSave);
145
+
146
+ /**
147
+ * This effect is used to avoid side effects directly in the render function
148
+ * and instead the refs are updated after each render.
149
+ */
150
+ useEffect(() => {
151
+ onScrollRef.current = onScroll;
152
+ onChangeRef.current = onChange;
153
+ onSaveRef.current = onSave;
154
+ docRef.current = doc;
155
+ themeRef.current = theme;
156
+ });
157
+
158
+ useEffect(() => {
159
+ const onUpdate = debounce((update: EditorUpdate) => {
160
+ onChangeRef.current?.(update);
161
+ }, debounceChange);
162
+
163
+ const view = new EditorView({
164
+ parent: containerRef.current!,
165
+ dispatchTransactions(transactions) {
166
+ const previousSelection = view.state.selection;
167
+
168
+ view.update(transactions);
169
+
170
+ const newSelection = view.state.selection;
171
+
172
+ const selectionChanged =
173
+ newSelection !== previousSelection &&
174
+ (newSelection === undefined || previousSelection === undefined || !newSelection.eq(previousSelection));
175
+
176
+ if (docRef.current && (transactions.some((transaction) => transaction.docChanged) || selectionChanged)) {
177
+ onUpdate({
178
+ selection: view.state.selection,
179
+ content: view.state.doc.toString(),
180
+ });
181
+
182
+ editorStatesRef.current!.set(docRef.current.filePath, view.state);
183
+ }
184
+ },
185
+ });
186
+
187
+ viewRef.current = view;
188
+
189
+ return () => {
190
+ viewRef.current?.destroy();
191
+ viewRef.current = undefined;
192
+ };
193
+ }, []);
194
+
195
+ useEffect(() => {
196
+ if (!viewRef.current) {
197
+ return;
198
+ }
199
+
200
+ viewRef.current.dispatch({
201
+ effects: [reconfigureTheme(theme)],
202
+ });
203
+ }, [theme]);
204
+
205
+ useEffect(() => {
206
+ editorStatesRef.current = new Map<string, EditorState>();
207
+ }, [id]);
208
+
209
+ useEffect(() => {
210
+ const editorStates = editorStatesRef.current!;
211
+ const view = viewRef.current!;
212
+ const theme = themeRef.current!;
213
+
214
+ if (!doc) {
215
+ const state = newEditorState('', theme, settings, onScrollRef, debounceScroll, onSaveRef, [
216
+ languageCompartment.of([]),
217
+ ]);
218
+
219
+ view.setState(state);
220
+
221
+ setNoDocument(view);
222
+
223
+ return;
224
+ }
225
+
226
+ if (doc.isBinary) {
227
+ return;
228
+ }
229
+
230
+ if (doc.filePath === '') {
231
+ logger.warn('File path should not be empty');
232
+ }
233
+
234
+ let state = editorStates.get(doc.filePath);
235
+
236
+ if (!state) {
237
+ state = newEditorState(doc.value, theme, settings, onScrollRef, debounceScroll, onSaveRef, [
238
+ languageCompartment.of([]),
239
+ ]);
240
+
241
+ editorStates.set(doc.filePath, state);
242
+ }
243
+
244
+ view.setState(state);
245
+
246
+ setEditorDocument(
247
+ view,
248
+ theme,
249
+ editable,
250
+ languageCompartment,
251
+ autoFocusOnDocumentChange,
252
+ doc as TextEditorDocument,
253
+ );
254
+ }, [doc?.value, editable, doc?.filePath, autoFocusOnDocumentChange]);
255
+
256
+ return (
257
+ <div className={classNames('relative h-full', className)}>
258
+ {doc?.isBinary && <BinaryContent />}
259
+ <div className="h-full overflow-hidden" ref={containerRef} />
260
+ </div>
261
+ );
262
+ },
263
+ );
264
+
265
+ export default CodeMirrorEditor;
266
+
267
+ CodeMirrorEditor.displayName = 'CodeMirrorEditor';
268
+
269
+ function newEditorState(
270
+ content: string,
271
+ theme: Theme,
272
+ settings: EditorSettings | undefined,
273
+ onScrollRef: MutableRefObject<OnScrollCallback | undefined>,
274
+ debounceScroll: number,
275
+ onFileSaveRef: MutableRefObject<OnSaveCallback | undefined>,
276
+ extensions: Extension[],
277
+ ) {
278
+ return EditorState.create({
279
+ doc: content,
280
+ extensions: [
281
+ EditorView.domEventHandlers({
282
+ scroll: debounce((event, view) => {
283
+ if (event.target !== view.scrollDOM) {
284
+ return;
285
+ }
286
+
287
+ onScrollRef.current?.({ left: view.scrollDOM.scrollLeft, top: view.scrollDOM.scrollTop });
288
+ }, debounceScroll),
289
+ keydown: (event, view) => {
290
+ if (view.state.readOnly) {
291
+ view.dispatch({
292
+ effects: [readOnlyTooltipStateEffect.of(event.key !== 'Escape')],
293
+ });
294
+
295
+ return true;
296
+ }
297
+
298
+ return false;
299
+ },
300
+ }),
301
+ getTheme(theme, settings),
302
+ history(),
303
+ keymap.of([
304
+ ...defaultKeymap,
305
+ ...historyKeymap,
306
+ ...searchKeymap,
307
+ { key: 'Tab', run: acceptCompletion },
308
+ {
309
+ key: 'Mod-s',
310
+ preventDefault: true,
311
+ run: () => {
312
+ onFileSaveRef.current?.();
313
+ return true;
314
+ },
315
+ },
316
+ indentKeyBinding,
317
+ ]),
318
+ indentUnit.of('\t'),
319
+ autocompletion({
320
+ closeOnBlur: false,
321
+ }),
322
+ tooltips({
323
+ position: 'absolute',
324
+ parent: document.body,
325
+ tooltipSpace: (view) => {
326
+ const rect = view.dom.getBoundingClientRect();
327
+
328
+ return {
329
+ top: rect.top - 50,
330
+ left: rect.left,
331
+ bottom: rect.bottom,
332
+ right: rect.right + 10,
333
+ };
334
+ },
335
+ }),
336
+ closeBrackets(),
337
+ lineNumbers(),
338
+ scrollPastEnd(),
339
+ dropCursor(),
340
+ drawSelection(),
341
+ bracketMatching(),
342
+ EditorState.tabSize.of(settings?.tabSize ?? 2),
343
+ indentOnInput(),
344
+ editableTooltipField,
345
+ editableStateField,
346
+ EditorState.readOnly.from(editableStateField, (editable) => !editable),
347
+ highlightActiveLineGutter(),
348
+ highlightActiveLine(),
349
+ foldGutter({
350
+ markerDOM: (open) => {
351
+ const icon = document.createElement('div');
352
+
353
+ icon.className = `fold-icon ${open ? 'i-ph-caret-down-bold' : 'i-ph-caret-right-bold'}`;
354
+
355
+ return icon;
356
+ },
357
+ }),
358
+ ...extensions,
359
+ ],
360
+ });
361
+ }
362
+
363
+ function setNoDocument(view: EditorView) {
364
+ view.dispatch({
365
+ selection: { anchor: 0 },
366
+ changes: {
367
+ from: 0,
368
+ to: view.state.doc.length,
369
+ insert: '',
370
+ },
371
+ });
372
+
373
+ view.scrollDOM.scrollTo(0, 0);
374
+ }
375
+
376
+ function setEditorDocument(
377
+ view: EditorView,
378
+ theme: Theme,
379
+ editable: boolean,
380
+ languageCompartment: Compartment,
381
+ autoFocus: boolean,
382
+ doc: TextEditorDocument,
383
+ ) {
384
+ if (doc.value !== view.state.doc.toString()) {
385
+ view.dispatch({
386
+ selection: { anchor: 0 },
387
+ changes: {
388
+ from: 0,
389
+ to: view.state.doc.length,
390
+ insert: doc.value,
391
+ },
392
+ });
393
+ }
394
+
395
+ view.dispatch({
396
+ effects: [editableStateEffect.of(editable && !doc.isBinary)],
397
+ });
398
+
399
+ getLanguage(doc.filePath).then((languageSupport) => {
400
+ if (!languageSupport) {
401
+ return;
402
+ }
403
+
404
+ view.dispatch({
405
+ effects: [languageCompartment.reconfigure([languageSupport]), reconfigureTheme(theme)],
406
+ });
407
+
408
+ requestAnimationFrame(() => {
409
+ const currentLeft = view.scrollDOM.scrollLeft;
410
+ const currentTop = view.scrollDOM.scrollTop;
411
+ const newLeft = doc.scroll?.left ?? 0;
412
+ const newTop = doc.scroll?.top ?? 0;
413
+
414
+ const needsScrolling = currentLeft !== newLeft || currentTop !== newTop;
415
+
416
+ if (autoFocus && editable) {
417
+ if (needsScrolling) {
418
+ // we have to wait until the scroll position was changed before we can set the focus
419
+ view.scrollDOM.addEventListener(
420
+ 'scroll',
421
+ () => {
422
+ view.focus();
423
+ },
424
+ { once: true },
425
+ );
426
+ } else {
427
+ // if the scroll position is still the same we can focus immediately
428
+ view.focus();
429
+ }
430
+ }
431
+
432
+ view.scrollDOM.scrollTo(newLeft, newTop);
433
+ });
434
+ });
435
+ }
436
+
437
+ function getReadOnlyTooltip(state: EditorState) {
438
+ if (!state.readOnly) {
439
+ return [];
440
+ }
441
+
442
+ return state.selection.ranges
443
+ .filter((range) => {
444
+ return range.empty;
445
+ })
446
+ .map((range) => {
447
+ return {
448
+ pos: range.head,
449
+ above: true,
450
+ strictSide: true,
451
+ arrow: true,
452
+ create: () => {
453
+ const divElement = document.createElement('div');
454
+ divElement.className = 'cm-readonly-tooltip';
455
+ divElement.textContent = 'Cannot edit file while AI response is being generated';
456
+
457
+ return { dom: divElement };
458
+ },
459
+ };
460
+ });
461
+ }
app/components/editor/codemirror/cm-theme.ts ADDED
@@ -0,0 +1,192 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { Compartment, type Extension } from '@codemirror/state';
2
+ import { EditorView } from '@codemirror/view';
3
+ import { vscodeDark, vscodeLight } from '@uiw/codemirror-theme-vscode';
4
+ import type { Theme } from '~/types/theme.js';
5
+ import type { EditorSettings } from './CodeMirrorEditor.js';
6
+
7
+ export const darkTheme = EditorView.theme({}, { dark: true });
8
+ export const themeSelection = new Compartment();
9
+
10
+ export function getTheme(theme: Theme, settings: EditorSettings = {}): Extension {
11
+ return [
12
+ getEditorTheme(settings),
13
+ theme === 'dark' ? themeSelection.of([getDarkTheme()]) : themeSelection.of([getLightTheme()]),
14
+ ];
15
+ }
16
+
17
+ export function reconfigureTheme(theme: Theme) {
18
+ return themeSelection.reconfigure(theme === 'dark' ? getDarkTheme() : getLightTheme());
19
+ }
20
+
21
+ function getEditorTheme(settings: EditorSettings) {
22
+ return EditorView.theme({
23
+ '&': {
24
+ fontSize: settings.fontSize ?? '12px',
25
+ },
26
+ '&.cm-editor': {
27
+ height: '100%',
28
+ background: 'var(--cm-backgroundColor)',
29
+ color: 'var(--cm-textColor)',
30
+ },
31
+ '.cm-cursor': {
32
+ borderLeft: 'var(--cm-cursor-width) solid var(--cm-cursor-backgroundColor)',
33
+ },
34
+ '.cm-scroller': {
35
+ lineHeight: '1.5',
36
+ '&:focus-visible': {
37
+ outline: 'none',
38
+ },
39
+ },
40
+ '.cm-line': {
41
+ padding: '0 0 0 4px',
42
+ },
43
+ '&.cm-focused > .cm-scroller > .cm-selectionLayer .cm-selectionBackground': {
44
+ backgroundColor: 'var(--cm-selection-backgroundColorFocused) !important',
45
+ opacity: 'var(--cm-selection-backgroundOpacityFocused, 0.3)',
46
+ },
47
+ '&:not(.cm-focused) > .cm-scroller > .cm-selectionLayer .cm-selectionBackground': {
48
+ backgroundColor: 'var(--cm-selection-backgroundColorBlured)',
49
+ opacity: 'var(--cm-selection-backgroundOpacityBlured, 0.3)',
50
+ },
51
+ '&.cm-focused > .cm-scroller .cm-matchingBracket': {
52
+ backgroundColor: 'var(--cm-matching-bracket)',
53
+ },
54
+ '.cm-activeLine': {
55
+ background: 'var(--cm-activeLineBackgroundColor)',
56
+ },
57
+ '.cm-gutters': {
58
+ background: 'var(--cm-gutter-backgroundColor)',
59
+ borderRight: 0,
60
+ color: 'var(--cm-gutter-textColor)',
61
+ },
62
+ '.cm-gutter': {
63
+ '&.cm-lineNumbers': {
64
+ fontFamily: 'Roboto Mono, monospace',
65
+ fontSize: settings.gutterFontSize ?? settings.fontSize ?? '12px',
66
+ minWidth: '40px',
67
+ },
68
+ '& .cm-activeLineGutter': {
69
+ background: 'transparent',
70
+ color: 'var(--cm-gutter-activeLineTextColor)',
71
+ },
72
+ '&.cm-foldGutter .cm-gutterElement > .fold-icon': {
73
+ cursor: 'pointer',
74
+ color: 'var(--cm-foldGutter-textColor)',
75
+ transform: 'translateY(2px)',
76
+ '&:hover': {
77
+ color: 'var(--cm-foldGutter-textColorHover)',
78
+ },
79
+ },
80
+ },
81
+ '.cm-foldGutter .cm-gutterElement': {
82
+ padding: '0 4px',
83
+ },
84
+ '.cm-tooltip-autocomplete > ul > li': {
85
+ minHeight: '18px',
86
+ },
87
+ '.cm-panel.cm-search label': {
88
+ marginLeft: '2px',
89
+ fontSize: '12px',
90
+ },
91
+ '.cm-panel.cm-search .cm-button': {
92
+ fontSize: '12px',
93
+ },
94
+ '.cm-panel.cm-search .cm-textfield': {
95
+ fontSize: '12px',
96
+ },
97
+ '.cm-panel.cm-search input[type=checkbox]': {
98
+ position: 'relative',
99
+ transform: 'translateY(2px)',
100
+ marginRight: '4px',
101
+ },
102
+ '.cm-panels': {
103
+ borderColor: 'var(--cm-panels-borderColor)',
104
+ },
105
+ '.cm-panels-bottom': {
106
+ borderTop: '1px solid var(--cm-panels-borderColor)',
107
+ backgroundColor: 'transparent',
108
+ },
109
+ '.cm-panel.cm-search': {
110
+ background: 'var(--cm-search-backgroundColor)',
111
+ color: 'var(--cm-search-textColor)',
112
+ padding: '8px',
113
+ },
114
+ '.cm-search .cm-button': {
115
+ background: 'var(--cm-search-button-backgroundColor)',
116
+ borderColor: 'var(--cm-search-button-borderColor)',
117
+ color: 'var(--cm-search-button-textColor)',
118
+ borderRadius: '4px',
119
+ '&:hover': {
120
+ color: 'var(--cm-search-button-textColorHover)',
121
+ },
122
+ '&:focus-visible': {
123
+ outline: 'none',
124
+ borderColor: 'var(--cm-search-button-borderColorFocused)',
125
+ },
126
+ '&:hover:not(:focus-visible)': {
127
+ background: 'var(--cm-search-button-backgroundColorHover)',
128
+ borderColor: 'var(--cm-search-button-borderColorHover)',
129
+ },
130
+ '&:hover:focus-visible': {
131
+ background: 'var(--cm-search-button-backgroundColorHover)',
132
+ borderColor: 'var(--cm-search-button-borderColorFocused)',
133
+ },
134
+ },
135
+ '.cm-panel.cm-search [name=close]': {
136
+ top: '6px',
137
+ right: '6px',
138
+ padding: '0 6px',
139
+ fontSize: '1rem',
140
+ backgroundColor: 'var(--cm-search-closeButton-backgroundColor)',
141
+ color: 'var(--cm-search-closeButton-textColor)',
142
+ '&:hover': {
143
+ 'border-radius': '6px',
144
+ color: 'var(--cm-search-closeButton-textColorHover)',
145
+ backgroundColor: 'var(--cm-search-closeButton-backgroundColorHover)',
146
+ },
147
+ },
148
+ '.cm-search input': {
149
+ background: 'var(--cm-search-input-backgroundColor)',
150
+ borderColor: 'var(--cm-search-input-borderColor)',
151
+ color: 'var(--cm-search-input-textColor)',
152
+ outline: 'none',
153
+ borderRadius: '4px',
154
+ '&:focus-visible': {
155
+ borderColor: 'var(--cm-search-input-borderColorFocused)',
156
+ },
157
+ },
158
+ '.cm-tooltip': {
159
+ background: 'var(--cm-tooltip-backgroundColor)',
160
+ border: '1px solid transparent',
161
+ borderColor: 'var(--cm-tooltip-borderColor)',
162
+ color: 'var(--cm-tooltip-textColor)',
163
+ },
164
+ '.cm-tooltip.cm-tooltip-autocomplete ul li[aria-selected]': {
165
+ background: 'var(--cm-tooltip-backgroundColorSelected)',
166
+ color: 'var(--cm-tooltip-textColorSelected)',
167
+ },
168
+ '.cm-searchMatch': {
169
+ backgroundColor: 'var(--cm-searchMatch-backgroundColor)',
170
+ },
171
+ '.cm-tooltip.cm-readonly-tooltip': {
172
+ padding: '4px',
173
+ whiteSpace: 'nowrap',
174
+ backgroundColor: 'var(--bolt-elements-bg-depth-2)',
175
+ borderColor: 'var(--bolt-elements-borderColorActive)',
176
+ '& .cm-tooltip-arrow:before': {
177
+ borderTopColor: 'var(--bolt-elements-borderColorActive)',
178
+ },
179
+ '& .cm-tooltip-arrow:after': {
180
+ borderTopColor: 'transparent',
181
+ },
182
+ },
183
+ });
184
+ }
185
+
186
+ function getLightTheme() {
187
+ return vscodeLight;
188
+ }
189
+
190
+ function getDarkTheme() {
191
+ return vscodeDark;
192
+ }
app/components/editor/codemirror/indent.ts ADDED
@@ -0,0 +1,68 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { indentLess } from '@codemirror/commands';
2
+ import { indentUnit } from '@codemirror/language';
3
+ import { EditorSelection, EditorState, Line, type ChangeSpec } from '@codemirror/state';
4
+ import { EditorView, type KeyBinding } from '@codemirror/view';
5
+
6
+ export const indentKeyBinding: KeyBinding = {
7
+ key: 'Tab',
8
+ run: indentMore,
9
+ shift: indentLess,
10
+ };
11
+
12
+ function indentMore({ state, dispatch }: EditorView) {
13
+ if (state.readOnly) {
14
+ return false;
15
+ }
16
+
17
+ dispatch(
18
+ state.update(
19
+ changeBySelectedLine(state, (from, to, changes) => {
20
+ changes.push({ from, to, insert: state.facet(indentUnit) });
21
+ }),
22
+ { userEvent: 'input.indent' },
23
+ ),
24
+ );
25
+
26
+ return true;
27
+ }
28
+
29
+ function changeBySelectedLine(
30
+ state: EditorState,
31
+ cb: (from: number, to: number | undefined, changes: ChangeSpec[], line: Line) => void,
32
+ ) {
33
+ return state.changeByRange((range) => {
34
+ const changes: ChangeSpec[] = [];
35
+
36
+ const line = state.doc.lineAt(range.from);
37
+
38
+ // just insert single indent unit at the current cursor position
39
+ if (range.from === range.to) {
40
+ cb(range.from, undefined, changes, line);
41
+ }
42
+ // handle the case when multiple characters are selected in a single line
43
+ else if (range.from < range.to && range.to <= line.to) {
44
+ cb(range.from, range.to, changes, line);
45
+ } else {
46
+ let atLine = -1;
47
+
48
+ // handle the case when selection spans multiple lines
49
+ for (let pos = range.from; pos <= range.to; ) {
50
+ const line = state.doc.lineAt(pos);
51
+
52
+ if (line.number > atLine && (range.empty || range.to > line.from)) {
53
+ cb(line.from, undefined, changes, line);
54
+ atLine = line.number;
55
+ }
56
+
57
+ pos = line.to + 1;
58
+ }
59
+ }
60
+
61
+ const changeSet = state.changes(changes);
62
+
63
+ return {
64
+ changes,
65
+ range: EditorSelection.range(changeSet.mapPos(range.anchor, 1), changeSet.mapPos(range.head, 1)),
66
+ };
67
+ });
68
+ }
app/components/editor/codemirror/languages.ts ADDED
@@ -0,0 +1,105 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { LanguageDescription } from '@codemirror/language';
2
+
3
+ export const supportedLanguages = [
4
+ LanguageDescription.of({
5
+ name: 'TS',
6
+ extensions: ['ts'],
7
+ async load() {
8
+ return import('@codemirror/lang-javascript').then((module) => module.javascript({ typescript: true }));
9
+ },
10
+ }),
11
+ LanguageDescription.of({
12
+ name: 'JS',
13
+ extensions: ['js', 'mjs', 'cjs'],
14
+ async load() {
15
+ return import('@codemirror/lang-javascript').then((module) => module.javascript());
16
+ },
17
+ }),
18
+ LanguageDescription.of({
19
+ name: 'TSX',
20
+ extensions: ['tsx'],
21
+ async load() {
22
+ return import('@codemirror/lang-javascript').then((module) => module.javascript({ jsx: true, typescript: true }));
23
+ },
24
+ }),
25
+ LanguageDescription.of({
26
+ name: 'JSX',
27
+ extensions: ['jsx'],
28
+ async load() {
29
+ return import('@codemirror/lang-javascript').then((module) => module.javascript({ jsx: true }));
30
+ },
31
+ }),
32
+ LanguageDescription.of({
33
+ name: 'HTML',
34
+ extensions: ['html'],
35
+ async load() {
36
+ return import('@codemirror/lang-html').then((module) => module.html());
37
+ },
38
+ }),
39
+ LanguageDescription.of({
40
+ name: 'CSS',
41
+ extensions: ['css'],
42
+ async load() {
43
+ return import('@codemirror/lang-css').then((module) => module.css());
44
+ },
45
+ }),
46
+ LanguageDescription.of({
47
+ name: 'SASS',
48
+ extensions: ['sass'],
49
+ async load() {
50
+ return import('@codemirror/lang-sass').then((module) => module.sass({ indented: true }));
51
+ },
52
+ }),
53
+ LanguageDescription.of({
54
+ name: 'SCSS',
55
+ extensions: ['scss'],
56
+ async load() {
57
+ return import('@codemirror/lang-sass').then((module) => module.sass({ indented: false }));
58
+ },
59
+ }),
60
+ LanguageDescription.of({
61
+ name: 'JSON',
62
+ extensions: ['json'],
63
+ async load() {
64
+ return import('@codemirror/lang-json').then((module) => module.json());
65
+ },
66
+ }),
67
+ LanguageDescription.of({
68
+ name: 'Markdown',
69
+ extensions: ['md'],
70
+ async load() {
71
+ return import('@codemirror/lang-markdown').then((module) => module.markdown());
72
+ },
73
+ }),
74
+ LanguageDescription.of({
75
+ name: 'Wasm',
76
+ extensions: ['wat'],
77
+ async load() {
78
+ return import('@codemirror/lang-wast').then((module) => module.wast());
79
+ },
80
+ }),
81
+ LanguageDescription.of({
82
+ name: 'Python',
83
+ extensions: ['py'],
84
+ async load() {
85
+ return import('@codemirror/lang-python').then((module) => module.python());
86
+ },
87
+ }),
88
+ LanguageDescription.of({
89
+ name: 'C++',
90
+ extensions: ['cpp'],
91
+ async load() {
92
+ return import('@codemirror/lang-cpp').then((module) => module.cpp());
93
+ },
94
+ }),
95
+ ];
96
+
97
+ export async function getLanguage(fileName: string) {
98
+ const languageDescription = LanguageDescription.matchFilename(supportedLanguages, fileName);
99
+
100
+ if (languageDescription) {
101
+ return await languageDescription.load();
102
+ }
103
+
104
+ return undefined;
105
+ }
app/components/header/Header.tsx ADDED
@@ -0,0 +1,41 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useStore } from '@nanostores/react';
2
+ import { ClientOnly } from 'remix-utils/client-only';
3
+ import { chatStore } from '~/lib/stores/chat';
4
+ import { classNames } from '~/utils/classNames';
5
+ import { HeaderActionButtons } from './HeaderActionButtons.client';
6
+ import { ChatDescription } from '~/lib/persistence/ChatDescription.client';
7
+
8
+ export function Header() {
9
+ const chat = useStore(chatStore);
10
+
11
+ return (
12
+ <header
13
+ className={classNames(
14
+ 'flex items-center bg-bolt-elements-background-depth-1 p-5 border-b h-[var(--header-height)]',
15
+ {
16
+ 'border-transparent': !chat.started,
17
+ 'border-bolt-elements-borderColor': chat.started,
18
+ },
19
+ )}
20
+ >
21
+ <div className="flex items-center gap-2 z-logo text-bolt-elements-textPrimary cursor-pointer">
22
+ <div className="i-ph:sidebar-simple-duotone text-xl" />
23
+ <a href="/" className="text-2xl font-semibold text-accent flex items-center">
24
+ <span className="i-bolt:logo-text?mask w-[46px] inline-block" />
25
+ </a>
26
+ </div>
27
+ <span className="flex-1 px-4 truncate text-center text-bolt-elements-textPrimary">
28
+ <ClientOnly>{() => <ChatDescription />}</ClientOnly>
29
+ </span>
30
+ {chat.started && (
31
+ <ClientOnly>
32
+ {() => (
33
+ <div className="mr-1">
34
+ <HeaderActionButtons />
35
+ </div>
36
+ )}
37
+ </ClientOnly>
38
+ )}
39
+ </header>
40
+ );
41
+ }
app/components/header/HeaderActionButtons.client.tsx ADDED
@@ -0,0 +1,68 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useStore } from '@nanostores/react';
2
+ import { chatStore } from '~/lib/stores/chat';
3
+ import { workbenchStore } from '~/lib/stores/workbench';
4
+ import { classNames } from '~/utils/classNames';
5
+
6
+ interface HeaderActionButtonsProps {}
7
+
8
+ export function HeaderActionButtons({}: HeaderActionButtonsProps) {
9
+ const showWorkbench = useStore(workbenchStore.showWorkbench);
10
+ const { showChat } = useStore(chatStore);
11
+
12
+ const canHideChat = showWorkbench || !showChat;
13
+
14
+ return (
15
+ <div className="flex">
16
+ <div className="flex border border-bolt-elements-borderColor rounded-md overflow-hidden">
17
+ <Button
18
+ active={showChat}
19
+ disabled={!canHideChat}
20
+ onClick={() => {
21
+ if (canHideChat) {
22
+ chatStore.setKey('showChat', !showChat);
23
+ }
24
+ }}
25
+ >
26
+ <div className="i-bolt:chat text-sm" />
27
+ </Button>
28
+ <div className="w-[1px] bg-bolt-elements-borderColor" />
29
+ <Button
30
+ active={showWorkbench}
31
+ onClick={() => {
32
+ if (showWorkbench && !showChat) {
33
+ chatStore.setKey('showChat', true);
34
+ }
35
+
36
+ workbenchStore.showWorkbench.set(!showWorkbench);
37
+ }}
38
+ >
39
+ <div className="i-ph:code-bold" />
40
+ </Button>
41
+ </div>
42
+ </div>
43
+ );
44
+ }
45
+
46
+ interface ButtonProps {
47
+ active?: boolean;
48
+ disabled?: boolean;
49
+ children?: any;
50
+ onClick?: VoidFunction;
51
+ }
52
+
53
+ function Button({ active = false, disabled = false, children, onClick }: ButtonProps) {
54
+ return (
55
+ <button
56
+ className={classNames('flex items-center p-1.5', {
57
+ 'bg-bolt-elements-item-backgroundDefault hover:bg-bolt-elements-item-backgroundActive text-bolt-elements-textTertiary hover:text-bolt-elements-textPrimary':
58
+ !active,
59
+ 'bg-bolt-elements-item-backgroundAccent text-bolt-elements-item-contentAccent': active && !disabled,
60
+ 'bg-bolt-elements-item-backgroundDefault text-alpha-gray-20 dark:text-alpha-white-20 cursor-not-allowed':
61
+ disabled,
62
+ })}
63
+ onClick={onClick}
64
+ >
65
+ {children}
66
+ </button>
67
+ );
68
+ }
app/components/sidebar/HistoryItem.tsx ADDED
@@ -0,0 +1,64 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import * as Dialog from '@radix-ui/react-dialog';
2
+ import { useEffect, useRef, useState } from 'react';
3
+ import { type ChatHistoryItem } from '~/lib/persistence';
4
+
5
+ interface HistoryItemProps {
6
+ item: ChatHistoryItem;
7
+ onDelete?: (event: React.UIEvent) => void;
8
+ }
9
+
10
+ export function HistoryItem({ item, onDelete }: HistoryItemProps) {
11
+ const [hovering, setHovering] = useState(false);
12
+ const hoverRef = useRef<HTMLDivElement>(null);
13
+
14
+ useEffect(() => {
15
+ let timeout: NodeJS.Timeout | undefined;
16
+
17
+ function mouseEnter() {
18
+ setHovering(true);
19
+
20
+ if (timeout) {
21
+ clearTimeout(timeout);
22
+ }
23
+ }
24
+
25
+ function mouseLeave() {
26
+ setHovering(false);
27
+ }
28
+
29
+ hoverRef.current?.addEventListener('mouseenter', mouseEnter);
30
+ hoverRef.current?.addEventListener('mouseleave', mouseLeave);
31
+
32
+ return () => {
33
+ hoverRef.current?.removeEventListener('mouseenter', mouseEnter);
34
+ hoverRef.current?.removeEventListener('mouseleave', mouseLeave);
35
+ };
36
+ }, []);
37
+
38
+ return (
39
+ <div
40
+ ref={hoverRef}
41
+ 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"
42
+ >
43
+ <a href={`/chat/${item.urlId}`} className="flex w-full relative truncate block">
44
+ {item.description}
45
+ <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%">
46
+ {hovering && (
47
+ <div className="flex items-center p-1 text-bolt-elements-textSecondary hover:text-bolt-elements-item-contentDanger">
48
+ <Dialog.Trigger asChild>
49
+ <button
50
+ className="i-ph:trash scale-110"
51
+ onClick={(event) => {
52
+ // we prevent the default so we don't trigger the anchor above
53
+ event.preventDefault();
54
+ onDelete?.(event);
55
+ }}
56
+ />
57
+ </Dialog.Trigger>
58
+ </div>
59
+ )}
60
+ </div>
61
+ </a>
62
+ </div>
63
+ );
64
+ }
app/components/sidebar/Menu.client.tsx ADDED
@@ -0,0 +1,172 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { motion, type Variants } from 'framer-motion';
2
+ import { useCallback, useEffect, useRef, useState } from 'react';
3
+ import { toast } from 'react-toastify';
4
+ import { Dialog, DialogButton, DialogDescription, DialogRoot, DialogTitle } from '~/components/ui/Dialog';
5
+ import { IconButton } from '~/components/ui/IconButton';
6
+ import { ThemeSwitch } from '~/components/ui/ThemeSwitch';
7
+ import { db, deleteById, getAll, chatId, type ChatHistoryItem } from '~/lib/persistence';
8
+ import { cubicEasingFn } from '~/utils/easings';
9
+ import { logger } from '~/utils/logger';
10
+ import { HistoryItem } from './HistoryItem';
11
+ import { binDates } from './date-binning';
12
+
13
+ const menuVariants = {
14
+ closed: {
15
+ opacity: 0,
16
+ visibility: 'hidden',
17
+ left: '-150px',
18
+ transition: {
19
+ duration: 0.2,
20
+ ease: cubicEasingFn,
21
+ },
22
+ },
23
+ open: {
24
+ opacity: 1,
25
+ visibility: 'initial',
26
+ left: 0,
27
+ transition: {
28
+ duration: 0.2,
29
+ ease: cubicEasingFn,
30
+ },
31
+ },
32
+ } satisfies Variants;
33
+
34
+ type DialogContent = { type: 'delete'; item: ChatHistoryItem } | null;
35
+
36
+ export function Menu() {
37
+ const menuRef = useRef<HTMLDivElement>(null);
38
+ const [list, setList] = useState<ChatHistoryItem[]>([]);
39
+ const [open, setOpen] = useState(false);
40
+ const [dialogContent, setDialogContent] = useState<DialogContent>(null);
41
+
42
+ const loadEntries = useCallback(() => {
43
+ if (db) {
44
+ getAll(db)
45
+ .then((list) => list.filter((item) => item.urlId && item.description))
46
+ .then(setList)
47
+ .catch((error) => toast.error(error.message));
48
+ }
49
+ }, []);
50
+
51
+ const deleteItem = useCallback((event: React.UIEvent, item: ChatHistoryItem) => {
52
+ event.preventDefault();
53
+
54
+ if (db) {
55
+ deleteById(db, item.id)
56
+ .then(() => {
57
+ loadEntries();
58
+
59
+ if (chatId.get() === item.id) {
60
+ // hard page navigation to clear the stores
61
+ window.location.pathname = '/';
62
+ }
63
+ })
64
+ .catch((error) => {
65
+ toast.error('Failed to delete conversation');
66
+ logger.error(error);
67
+ });
68
+ }
69
+ }, []);
70
+
71
+ const closeDialog = () => {
72
+ setDialogContent(null);
73
+ };
74
+
75
+ useEffect(() => {
76
+ if (open) {
77
+ loadEntries();
78
+ }
79
+ }, [open]);
80
+
81
+ useEffect(() => {
82
+ const enterThreshold = 40;
83
+ const exitThreshold = 40;
84
+
85
+ function onMouseMove(event: MouseEvent) {
86
+ if (event.pageX < enterThreshold) {
87
+ setOpen(true);
88
+ }
89
+
90
+ if (menuRef.current && event.clientX > menuRef.current.getBoundingClientRect().right + exitThreshold) {
91
+ setOpen(false);
92
+ }
93
+ }
94
+
95
+ window.addEventListener('mousemove', onMouseMove);
96
+
97
+ return () => {
98
+ window.removeEventListener('mousemove', onMouseMove);
99
+ };
100
+ }, []);
101
+
102
+ return (
103
+ <motion.div
104
+ ref={menuRef}
105
+ initial="closed"
106
+ animate={open ? 'open' : 'closed'}
107
+ variants={menuVariants}
108
+ 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"
109
+ >
110
+ <div className="flex items-center h-[var(--header-height)]">{/* Placeholder */}</div>
111
+ <div className="flex-1 flex flex-col h-full w-full overflow-hidden">
112
+ <div className="p-4">
113
+ <a
114
+ href="/"
115
+ 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"
116
+ >
117
+ <span className="inline-block i-bolt:chat scale-110" />
118
+ Start new chat
119
+ </a>
120
+ </div>
121
+ <div className="text-bolt-elements-textPrimary font-medium pl-6 pr-5 my-2">Your Chats</div>
122
+ <div className="flex-1 overflow-scroll pl-4 pr-5 pb-5">
123
+ {list.length === 0 && <div className="pl-2 text-bolt-elements-textTertiary">No previous conversations</div>}
124
+ <DialogRoot open={dialogContent !== null}>
125
+ {binDates(list).map(({ category, items }) => (
126
+ <div key={category} className="mt-4 first:mt-0 space-y-1">
127
+ <div className="text-bolt-elements-textTertiary sticky top-0 z-1 bg-bolt-elements-background-depth-2 pl-2 pt-2 pb-1">
128
+ {category}
129
+ </div>
130
+ {items.map((item) => (
131
+ <HistoryItem key={item.id} item={item} onDelete={() => setDialogContent({ type: 'delete', item })} />
132
+ ))}
133
+ </div>
134
+ ))}
135
+ <Dialog onBackdrop={closeDialog} onClose={closeDialog}>
136
+ {dialogContent?.type === 'delete' && (
137
+ <>
138
+ <DialogTitle>Delete Chat?</DialogTitle>
139
+ <DialogDescription asChild>
140
+ <div>
141
+ <p>
142
+ You are about to delete <strong>{dialogContent.item.description}</strong>.
143
+ </p>
144
+ <p className="mt-1">Are you sure you want to delete this chat?</p>
145
+ </div>
146
+ </DialogDescription>
147
+ <div className="px-5 pb-4 bg-bolt-elements-background-depth-2 flex gap-2 justify-end">
148
+ <DialogButton type="secondary" onClick={closeDialog}>
149
+ Cancel
150
+ </DialogButton>
151
+ <DialogButton
152
+ type="danger"
153
+ onClick={(event) => {
154
+ deleteItem(event, dialogContent.item);
155
+ closeDialog();
156
+ }}
157
+ >
158
+ Delete
159
+ </DialogButton>
160
+ </div>
161
+ </>
162
+ )}
163
+ </Dialog>
164
+ </DialogRoot>
165
+ </div>
166
+ <div className="flex items-center border-t border-bolt-elements-borderColor p-4">
167
+ <ThemeSwitch className="ml-auto" />
168
+ </div>
169
+ </div>
170
+ </motion.div>
171
+ );
172
+ }
app/components/sidebar/date-binning.ts ADDED
@@ -0,0 +1,59 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { format, isAfter, isThisWeek, isThisYear, isToday, isYesterday, subDays } from 'date-fns';
2
+ import type { ChatHistoryItem } from '~/lib/persistence';
3
+
4
+ type Bin = { category: string; items: ChatHistoryItem[] };
5
+
6
+ export function binDates(_list: ChatHistoryItem[]) {
7
+ const list = _list.toSorted((a, b) => Date.parse(b.timestamp) - Date.parse(a.timestamp));
8
+
9
+ const binLookup: Record<string, Bin> = {};
10
+ const bins: Array<Bin> = [];
11
+
12
+ list.forEach((item) => {
13
+ const category = dateCategory(new Date(item.timestamp));
14
+
15
+ if (!(category in binLookup)) {
16
+ const bin = {
17
+ category,
18
+ items: [item],
19
+ };
20
+
21
+ binLookup[category] = bin;
22
+
23
+ bins.push(bin);
24
+ } else {
25
+ binLookup[category].items.push(item);
26
+ }
27
+ });
28
+
29
+ return bins;
30
+ }
31
+
32
+ function dateCategory(date: Date) {
33
+ if (isToday(date)) {
34
+ return 'Today';
35
+ }
36
+
37
+ if (isYesterday(date)) {
38
+ return 'Yesterday';
39
+ }
40
+
41
+ if (isThisWeek(date)) {
42
+ // e.g., "Monday"
43
+ return format(date, 'eeee');
44
+ }
45
+
46
+ const thirtyDaysAgo = subDays(new Date(), 30);
47
+
48
+ if (isAfter(date, thirtyDaysAgo)) {
49
+ return 'Last 30 Days';
50
+ }
51
+
52
+ if (isThisYear(date)) {
53
+ // e.g., "July"
54
+ return format(date, 'MMMM');
55
+ }
56
+
57
+ // e.g., "July 2023"
58
+ return format(date, 'MMMM yyyy');
59
+ }
app/components/ui/Dialog.tsx ADDED
@@ -0,0 +1,133 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import * as RadixDialog from '@radix-ui/react-dialog';
2
+ import { motion, type Variants } from 'framer-motion';
3
+ import React, { memo, type ReactNode } from 'react';
4
+ import { classNames } from '~/utils/classNames';
5
+ import { cubicEasingFn } from '~/utils/easings';
6
+ import { IconButton } from './IconButton';
7
+
8
+ export { Close as DialogClose, Root as DialogRoot } from '@radix-ui/react-dialog';
9
+
10
+ const transition = {
11
+ duration: 0.15,
12
+ ease: cubicEasingFn,
13
+ };
14
+
15
+ export const dialogBackdropVariants = {
16
+ closed: {
17
+ opacity: 0,
18
+ transition,
19
+ },
20
+ open: {
21
+ opacity: 1,
22
+ transition,
23
+ },
24
+ } satisfies Variants;
25
+
26
+ export const dialogVariants = {
27
+ closed: {
28
+ x: '-50%',
29
+ y: '-40%',
30
+ scale: 0.96,
31
+ opacity: 0,
32
+ transition,
33
+ },
34
+ open: {
35
+ x: '-50%',
36
+ y: '-50%',
37
+ scale: 1,
38
+ opacity: 1,
39
+ transition,
40
+ },
41
+ } satisfies Variants;
42
+
43
+ interface DialogButtonProps {
44
+ type: 'primary' | 'secondary' | 'danger';
45
+ children: ReactNode;
46
+ onClick?: (event: React.UIEvent) => void;
47
+ }
48
+
49
+ export const DialogButton = memo(({ type, children, onClick }: DialogButtonProps) => {
50
+ return (
51
+ <button
52
+ className={classNames(
53
+ 'inline-flex h-[35px] items-center justify-center rounded-lg px-4 text-sm leading-none focus:outline-none',
54
+ {
55
+ 'bg-bolt-elements-button-primary-background text-bolt-elements-button-primary-text hover:bg-bolt-elements-button-primary-backgroundHover':
56
+ type === 'primary',
57
+ 'bg-bolt-elements-button-secondary-background text-bolt-elements-button-secondary-text hover:bg-bolt-elements-button-secondary-backgroundHover':
58
+ type === 'secondary',
59
+ 'bg-bolt-elements-button-danger-background text-bolt-elements-button-danger-text hover:bg-bolt-elements-button-danger-backgroundHover':
60
+ type === 'danger',
61
+ },
62
+ )}
63
+ onClick={onClick}
64
+ >
65
+ {children}
66
+ </button>
67
+ );
68
+ });
69
+
70
+ export const DialogTitle = memo(({ className, children, ...props }: RadixDialog.DialogTitleProps) => {
71
+ return (
72
+ <RadixDialog.Title
73
+ className={classNames(
74
+ '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',
75
+ className,
76
+ )}
77
+ {...props}
78
+ >
79
+ {children}
80
+ </RadixDialog.Title>
81
+ );
82
+ });
83
+
84
+ export const DialogDescription = memo(({ className, children, ...props }: RadixDialog.DialogDescriptionProps) => {
85
+ return (
86
+ <RadixDialog.Description
87
+ className={classNames('px-5 py-4 text-bolt-elements-textPrimary text-md', className)}
88
+ {...props}
89
+ >
90
+ {children}
91
+ </RadixDialog.Description>
92
+ );
93
+ });
94
+
95
+ interface DialogProps {
96
+ children: ReactNode | ReactNode[];
97
+ className?: string;
98
+ onBackdrop?: (event: React.UIEvent) => void;
99
+ onClose?: (event: React.UIEvent) => void;
100
+ }
101
+
102
+ export const Dialog = memo(({ className, children, onBackdrop, onClose }: DialogProps) => {
103
+ return (
104
+ <RadixDialog.Portal>
105
+ <RadixDialog.Overlay onClick={onBackdrop} asChild>
106
+ <motion.div
107
+ className="bg-black/50 fixed inset-0 z-max"
108
+ initial="closed"
109
+ animate="open"
110
+ exit="closed"
111
+ variants={dialogBackdropVariants}
112
+ />
113
+ </RadixDialog.Overlay>
114
+ <RadixDialog.Content asChild>
115
+ <motion.div
116
+ className={classNames(
117
+ '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',
118
+ className,
119
+ )}
120
+ initial="closed"
121
+ animate="open"
122
+ exit="closed"
123
+ variants={dialogVariants}
124
+ >
125
+ {children}
126
+ <RadixDialog.Close asChild onClick={onClose}>
127
+ <IconButton icon="i-ph:x" className="absolute top-[10px] right-[10px]" />
128
+ </RadixDialog.Close>
129
+ </motion.div>
130
+ </RadixDialog.Content>
131
+ </RadixDialog.Portal>
132
+ );
133
+ });
app/components/ui/IconButton.tsx ADDED
@@ -0,0 +1,77 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { memo } from 'react';
2
+ import { classNames } from '~/utils/classNames';
3
+
4
+ type IconSize = 'sm' | 'md' | 'lg' | 'xl' | 'xxl';
5
+
6
+ interface BaseIconButtonProps {
7
+ size?: IconSize;
8
+ className?: string;
9
+ iconClassName?: string;
10
+ disabledClassName?: string;
11
+ title?: string;
12
+ disabled?: boolean;
13
+ onClick?: (event: React.MouseEvent<HTMLButtonElement, MouseEvent>) => void;
14
+ }
15
+
16
+ type IconButtonWithoutChildrenProps = {
17
+ icon: string;
18
+ children?: undefined;
19
+ } & BaseIconButtonProps;
20
+
21
+ type IconButtonWithChildrenProps = {
22
+ icon?: undefined;
23
+ children: string | JSX.Element | JSX.Element[];
24
+ } & BaseIconButtonProps;
25
+
26
+ type IconButtonProps = IconButtonWithoutChildrenProps | IconButtonWithChildrenProps;
27
+
28
+ export const IconButton = memo(
29
+ ({
30
+ icon,
31
+ size = 'xl',
32
+ className,
33
+ iconClassName,
34
+ disabledClassName,
35
+ disabled = false,
36
+ title,
37
+ onClick,
38
+ children,
39
+ }: IconButtonProps) => {
40
+ return (
41
+ <button
42
+ className={classNames(
43
+ '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',
44
+ {
45
+ [classNames('opacity-30', disabledClassName)]: disabled,
46
+ },
47
+ className,
48
+ )}
49
+ title={title}
50
+ disabled={disabled}
51
+ onClick={(event) => {
52
+ if (disabled) {
53
+ return;
54
+ }
55
+
56
+ onClick?.(event);
57
+ }}
58
+ >
59
+ {children ? children : <div className={classNames(icon, getIconSize(size), iconClassName)}></div>}
60
+ </button>
61
+ );
62
+ },
63
+ );
64
+
65
+ function getIconSize(size: IconSize) {
66
+ if (size === 'sm') {
67
+ return 'text-sm';
68
+ } else if (size === 'md') {
69
+ return 'text-md';
70
+ } else if (size === 'lg') {
71
+ return 'text-lg';
72
+ } else if (size === 'xl') {
73
+ return 'text-xl';
74
+ } else {
75
+ return 'text-2xl';
76
+ }
77
+ }
app/components/ui/LoadingDots.tsx ADDED
@@ -0,0 +1,27 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { memo, useEffect, useState } from 'react';
2
+
3
+ interface LoadingDotsProps {
4
+ text: string;
5
+ }
6
+
7
+ export const LoadingDots = memo(({ text }: LoadingDotsProps) => {
8
+ const [dotCount, setDotCount] = useState(0);
9
+
10
+ useEffect(() => {
11
+ const interval = setInterval(() => {
12
+ setDotCount((prevDotCount) => (prevDotCount + 1) % 4);
13
+ }, 500);
14
+
15
+ return () => clearInterval(interval);
16
+ }, []);
17
+
18
+ return (
19
+ <div className="flex justify-center items-center h-full">
20
+ <div className="relative">
21
+ <span>{text}</span>
22
+ <span className="absolute left-[calc(100%-12px)]">{'.'.repeat(dotCount)}</span>
23
+ <span className="invisible">...</span>
24
+ </div>
25
+ </div>
26
+ );
27
+ });
app/components/ui/PanelHeader.tsx ADDED
@@ -0,0 +1,20 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { memo } from 'react';
2
+ import { classNames } from '~/utils/classNames';
3
+
4
+ interface PanelHeaderProps {
5
+ className?: string;
6
+ children: React.ReactNode;
7
+ }
8
+
9
+ export const PanelHeader = memo(({ className, children }: PanelHeaderProps) => {
10
+ return (
11
+ <div
12
+ className={classNames(
13
+ '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',
14
+ className,
15
+ )}
16
+ >
17
+ {children}
18
+ </div>
19
+ );
20
+ });
app/components/ui/PanelHeaderButton.tsx ADDED
@@ -0,0 +1,36 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { memo } from 'react';
2
+ import { classNames } from '~/utils/classNames';
3
+
4
+ interface PanelHeaderButtonProps {
5
+ className?: string;
6
+ disabledClassName?: string;
7
+ disabled?: boolean;
8
+ children: string | JSX.Element | Array<JSX.Element | string>;
9
+ onClick?: (event: React.MouseEvent<HTMLButtonElement, MouseEvent>) => void;
10
+ }
11
+
12
+ export const PanelHeaderButton = memo(
13
+ ({ className, disabledClassName, disabled = false, children, onClick }: PanelHeaderButtonProps) => {
14
+ return (
15
+ <button
16
+ className={classNames(
17
+ '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',
18
+ {
19
+ [classNames('opacity-30', disabledClassName)]: disabled,
20
+ },
21
+ className,
22
+ )}
23
+ disabled={disabled}
24
+ onClick={(event) => {
25
+ if (disabled) {
26
+ return;
27
+ }
28
+
29
+ onClick?.(event);
30
+ }}
31
+ >
32
+ {children}
33
+ </button>
34
+ );
35
+ },
36
+ );
app/components/ui/Slider.tsx ADDED
@@ -0,0 +1,65 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { motion } from 'framer-motion';
2
+ import { memo } from 'react';
3
+ import { classNames } from '~/utils/classNames';
4
+ import { cubicEasingFn } from '~/utils/easings';
5
+ import { genericMemo } from '~/utils/react';
6
+
7
+ interface SliderOption<T> {
8
+ value: T;
9
+ text: string;
10
+ }
11
+
12
+ export interface SliderOptions<T> {
13
+ left: SliderOption<T>;
14
+ right: SliderOption<T>;
15
+ }
16
+
17
+ interface SliderProps<T> {
18
+ selected: T;
19
+ options: SliderOptions<T>;
20
+ setSelected?: (selected: T) => void;
21
+ }
22
+
23
+ export const Slider = genericMemo(<T,>({ selected, options, setSelected }: SliderProps<T>) => {
24
+ const isLeftSelected = selected === options.left.value;
25
+
26
+ return (
27
+ <div className="flex items-center flex-wrap shrink-0 gap-1 bg-bolt-elements-background-depth-1 overflow-hidden rounded-full p-1">
28
+ <SliderButton selected={isLeftSelected} setSelected={() => setSelected?.(options.left.value)}>
29
+ {options.left.text}
30
+ </SliderButton>
31
+ <SliderButton selected={!isLeftSelected} setSelected={() => setSelected?.(options.right.value)}>
32
+ {options.right.text}
33
+ </SliderButton>
34
+ </div>
35
+ );
36
+ });
37
+
38
+ interface SliderButtonProps {
39
+ selected: boolean;
40
+ children: string | JSX.Element | Array<JSX.Element | string>;
41
+ setSelected: () => void;
42
+ }
43
+
44
+ const SliderButton = memo(({ selected, children, setSelected }: SliderButtonProps) => {
45
+ return (
46
+ <button
47
+ onClick={setSelected}
48
+ className={classNames(
49
+ 'bg-transparent text-sm px-2.5 py-0.5 rounded-full relative',
50
+ selected
51
+ ? 'text-bolt-elements-item-contentAccent'
52
+ : 'text-bolt-elements-item-contentDefault hover:text-bolt-elements-item-contentActive',
53
+ )}
54
+ >
55
+ <span className="relative z-10">{children}</span>
56
+ {selected && (
57
+ <motion.span
58
+ layoutId="pill-tab"
59
+ transition={{ duration: 0.2, ease: cubicEasingFn }}
60
+ className="absolute inset-0 z-0 bg-bolt-elements-item-backgroundAccent rounded-full"
61
+ ></motion.span>
62
+ )}
63
+ </button>
64
+ );
65
+ });
app/components/ui/ThemeSwitch.tsx ADDED
@@ -0,0 +1,29 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useStore } from '@nanostores/react';
2
+ import { memo, useEffect, useState } from 'react';
3
+ import { themeStore, toggleTheme } from '~/lib/stores/theme';
4
+ import { IconButton } from './IconButton';
5
+
6
+ interface ThemeSwitchProps {
7
+ className?: string;
8
+ }
9
+
10
+ export const ThemeSwitch = memo(({ className }: ThemeSwitchProps) => {
11
+ const theme = useStore(themeStore);
12
+ const [domLoaded, setDomLoaded] = useState(false);
13
+
14
+ useEffect(() => {
15
+ setDomLoaded(true);
16
+ }, []);
17
+
18
+ return (
19
+ domLoaded && (
20
+ <IconButton
21
+ className={className}
22
+ icon={theme === 'dark' ? 'i-ph-sun-dim-duotone' : 'i-ph-moon-stars-duotone'}
23
+ size="xl"
24
+ title="Toggle Theme"
25
+ onClick={toggleTheme}
26
+ />
27
+ )
28
+ );
29
+ });
app/components/workbench/EditorPanel.tsx ADDED
@@ -0,0 +1,256 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useStore } from '@nanostores/react';
2
+ import { memo, useEffect, useMemo, useRef, useState } from 'react';
3
+ import { Panel, PanelGroup, PanelResizeHandle, type ImperativePanelHandle } from 'react-resizable-panels';
4
+ import {
5
+ CodeMirrorEditor,
6
+ type EditorDocument,
7
+ type EditorSettings,
8
+ type OnChangeCallback as OnEditorChange,
9
+ type OnSaveCallback as OnEditorSave,
10
+ type OnScrollCallback as OnEditorScroll,
11
+ } from '~/components/editor/codemirror/CodeMirrorEditor';
12
+ import { IconButton } from '~/components/ui/IconButton';
13
+ import { PanelHeader } from '~/components/ui/PanelHeader';
14
+ import { PanelHeaderButton } from '~/components/ui/PanelHeaderButton';
15
+ import { shortcutEventEmitter } from '~/lib/hooks';
16
+ import type { FileMap } from '~/lib/stores/files';
17
+ import { themeStore } from '~/lib/stores/theme';
18
+ import { workbenchStore } from '~/lib/stores/workbench';
19
+ import { classNames } from '~/utils/classNames';
20
+ import { WORK_DIR } from '~/utils/constants';
21
+ import { renderLogger } from '~/utils/logger';
22
+ import { isMobile } from '~/utils/mobile';
23
+ import { FileBreadcrumb } from './FileBreadcrumb';
24
+ import { FileTree } from './FileTree';
25
+ import { Terminal, type TerminalRef } from './terminal/Terminal';
26
+
27
+ interface EditorPanelProps {
28
+ files?: FileMap;
29
+ unsavedFiles?: Set<string>;
30
+ editorDocument?: EditorDocument;
31
+ selectedFile?: string | undefined;
32
+ isStreaming?: boolean;
33
+ onEditorChange?: OnEditorChange;
34
+ onEditorScroll?: OnEditorScroll;
35
+ onFileSelect?: (value?: string) => void;
36
+ onFileSave?: OnEditorSave;
37
+ onFileReset?: () => void;
38
+ }
39
+
40
+ const MAX_TERMINALS = 3;
41
+ const DEFAULT_TERMINAL_SIZE = 25;
42
+ const DEFAULT_EDITOR_SIZE = 100 - DEFAULT_TERMINAL_SIZE;
43
+
44
+ const editorSettings: EditorSettings = { tabSize: 2 };
45
+
46
+ export const EditorPanel = memo(
47
+ ({
48
+ files,
49
+ unsavedFiles,
50
+ editorDocument,
51
+ selectedFile,
52
+ isStreaming,
53
+ onFileSelect,
54
+ onEditorChange,
55
+ onEditorScroll,
56
+ onFileSave,
57
+ onFileReset,
58
+ }: EditorPanelProps) => {
59
+ renderLogger.trace('EditorPanel');
60
+
61
+ const theme = useStore(themeStore);
62
+ const showTerminal = useStore(workbenchStore.showTerminal);
63
+
64
+ const terminalRefs = useRef<Array<TerminalRef | null>>([]);
65
+ const terminalPanelRef = useRef<ImperativePanelHandle>(null);
66
+ const terminalToggledByShortcut = useRef(false);
67
+
68
+ const [activeTerminal, setActiveTerminal] = useState(0);
69
+ const [terminalCount, setTerminalCount] = useState(1);
70
+
71
+ const activeFileSegments = useMemo(() => {
72
+ if (!editorDocument) {
73
+ return undefined;
74
+ }
75
+
76
+ return editorDocument.filePath.split('/');
77
+ }, [editorDocument]);
78
+
79
+ const activeFileUnsaved = useMemo(() => {
80
+ return editorDocument !== undefined && unsavedFiles?.has(editorDocument.filePath);
81
+ }, [editorDocument, unsavedFiles]);
82
+
83
+ useEffect(() => {
84
+ const unsubscribeFromEventEmitter = shortcutEventEmitter.on('toggleTerminal', () => {
85
+ terminalToggledByShortcut.current = true;
86
+ });
87
+
88
+ const unsubscribeFromThemeStore = themeStore.subscribe(() => {
89
+ for (const ref of Object.values(terminalRefs.current)) {
90
+ ref?.reloadStyles();
91
+ }
92
+ });
93
+
94
+ return () => {
95
+ unsubscribeFromEventEmitter();
96
+ unsubscribeFromThemeStore();
97
+ };
98
+ }, []);
99
+
100
+ useEffect(() => {
101
+ const { current: terminal } = terminalPanelRef;
102
+
103
+ if (!terminal) {
104
+ return;
105
+ }
106
+
107
+ const isCollapsed = terminal.isCollapsed();
108
+
109
+ if (!showTerminal && !isCollapsed) {
110
+ terminal.collapse();
111
+ } else if (showTerminal && isCollapsed) {
112
+ terminal.resize(DEFAULT_TERMINAL_SIZE);
113
+ }
114
+
115
+ terminalToggledByShortcut.current = false;
116
+ }, [showTerminal]);
117
+
118
+ const addTerminal = () => {
119
+ if (terminalCount < MAX_TERMINALS) {
120
+ setTerminalCount(terminalCount + 1);
121
+ setActiveTerminal(terminalCount);
122
+ }
123
+ };
124
+
125
+ return (
126
+ <PanelGroup direction="vertical">
127
+ <Panel defaultSize={showTerminal ? DEFAULT_EDITOR_SIZE : 100} minSize={20}>
128
+ <PanelGroup direction="horizontal">
129
+ <Panel defaultSize={20} minSize={10} collapsible>
130
+ <div className="flex flex-col border-r border-bolt-elements-borderColor h-full">
131
+ <PanelHeader>
132
+ <div className="i-ph:tree-structure-duotone shrink-0" />
133
+ Files
134
+ </PanelHeader>
135
+ <FileTree
136
+ className="h-full"
137
+ files={files}
138
+ hideRoot
139
+ unsavedFiles={unsavedFiles}
140
+ rootFolder={WORK_DIR}
141
+ selectedFile={selectedFile}
142
+ onFileSelect={onFileSelect}
143
+ />
144
+ </div>
145
+ </Panel>
146
+ <PanelResizeHandle />
147
+ <Panel className="flex flex-col" defaultSize={80} minSize={20}>
148
+ <PanelHeader className="overflow-x-auto">
149
+ {activeFileSegments?.length && (
150
+ <div className="flex items-center flex-1 text-sm">
151
+ <FileBreadcrumb pathSegments={activeFileSegments} files={files} onFileSelect={onFileSelect} />
152
+ {activeFileUnsaved && (
153
+ <div className="flex gap-1 ml-auto -mr-1.5">
154
+ <PanelHeaderButton onClick={onFileSave}>
155
+ <div className="i-ph:floppy-disk-duotone" />
156
+ Save
157
+ </PanelHeaderButton>
158
+ <PanelHeaderButton onClick={onFileReset}>
159
+ <div className="i-ph:clock-counter-clockwise-duotone" />
160
+ Reset
161
+ </PanelHeaderButton>
162
+ </div>
163
+ )}
164
+ </div>
165
+ )}
166
+ </PanelHeader>
167
+ <div className="h-full flex-1 overflow-hidden">
168
+ <CodeMirrorEditor
169
+ theme={theme}
170
+ editable={!isStreaming && editorDocument !== undefined}
171
+ settings={editorSettings}
172
+ doc={editorDocument}
173
+ autoFocusOnDocumentChange={!isMobile()}
174
+ onScroll={onEditorScroll}
175
+ onChange={onEditorChange}
176
+ onSave={onFileSave}
177
+ />
178
+ </div>
179
+ </Panel>
180
+ </PanelGroup>
181
+ </Panel>
182
+ <PanelResizeHandle />
183
+ <Panel
184
+ ref={terminalPanelRef}
185
+ defaultSize={showTerminal ? DEFAULT_TERMINAL_SIZE : 0}
186
+ minSize={10}
187
+ collapsible
188
+ onExpand={() => {
189
+ if (!terminalToggledByShortcut.current) {
190
+ workbenchStore.toggleTerminal(true);
191
+ }
192
+ }}
193
+ onCollapse={() => {
194
+ if (!terminalToggledByShortcut.current) {
195
+ workbenchStore.toggleTerminal(false);
196
+ }
197
+ }}
198
+ >
199
+ <div className="h-full">
200
+ <div className="bg-bolt-elements-terminals-background h-full flex flex-col">
201
+ <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">
202
+ {Array.from({ length: terminalCount }, (_, index) => {
203
+ const isActive = activeTerminal === index;
204
+
205
+ return (
206
+ <button
207
+ key={index}
208
+ className={classNames(
209
+ 'flex items-center text-sm cursor-pointer gap-1.5 px-3 py-2 h-full whitespace-nowrap rounded-full',
210
+ {
211
+ 'bg-bolt-elements-terminals-buttonBackground text-bolt-elements-textPrimary': isActive,
212
+ 'bg-bolt-elements-background-depth-2 text-bolt-elements-textSecondary hover:bg-bolt-elements-terminals-buttonBackground':
213
+ !isActive,
214
+ },
215
+ )}
216
+ onClick={() => setActiveTerminal(index)}
217
+ >
218
+ <div className="i-ph:terminal-window-duotone text-lg" />
219
+ Terminal {terminalCount > 1 && index + 1}
220
+ </button>
221
+ );
222
+ })}
223
+ {terminalCount < MAX_TERMINALS && <IconButton icon="i-ph:plus" size="md" onClick={addTerminal} />}
224
+ <IconButton
225
+ className="ml-auto"
226
+ icon="i-ph:caret-down"
227
+ title="Close"
228
+ size="md"
229
+ onClick={() => workbenchStore.toggleTerminal(false)}
230
+ />
231
+ </div>
232
+ {Array.from({ length: terminalCount }, (_, index) => {
233
+ const isActive = activeTerminal === index;
234
+
235
+ return (
236
+ <Terminal
237
+ key={index}
238
+ className={classNames('h-full overflow-hidden', {
239
+ hidden: !isActive,
240
+ })}
241
+ ref={(ref) => {
242
+ terminalRefs.current.push(ref);
243
+ }}
244
+ onTerminalReady={(terminal) => workbenchStore.attachTerminal(terminal)}
245
+ onTerminalResize={(cols, rows) => workbenchStore.onTerminalResize(cols, rows)}
246
+ theme={theme}
247
+ />
248
+ );
249
+ })}
250
+ </div>
251
+ </div>
252
+ </Panel>
253
+ </PanelGroup>
254
+ );
255
+ },
256
+ );
app/components/workbench/FileBreadcrumb.tsx ADDED
@@ -0,0 +1,148 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import * as DropdownMenu from '@radix-ui/react-dropdown-menu';
2
+ import { AnimatePresence, motion, type Variants } from 'framer-motion';
3
+ import { memo, useEffect, useRef, useState } from 'react';
4
+ import type { FileMap } from '~/lib/stores/files';
5
+ import { classNames } from '~/utils/classNames';
6
+ import { WORK_DIR } from '~/utils/constants';
7
+ import { cubicEasingFn } from '~/utils/easings';
8
+ import { renderLogger } from '~/utils/logger';
9
+ import FileTree from './FileTree';
10
+
11
+ const WORK_DIR_REGEX = new RegExp(`^${WORK_DIR.split('/').slice(0, -1).join('/').replaceAll('/', '\\/')}/`);
12
+
13
+ interface FileBreadcrumbProps {
14
+ files?: FileMap;
15
+ pathSegments?: string[];
16
+ onFileSelect?: (filePath: string) => void;
17
+ }
18
+
19
+ const contextMenuVariants = {
20
+ open: {
21
+ y: 0,
22
+ opacity: 1,
23
+ transition: {
24
+ duration: 0.15,
25
+ ease: cubicEasingFn,
26
+ },
27
+ },
28
+ close: {
29
+ y: 6,
30
+ opacity: 0,
31
+ transition: {
32
+ duration: 0.15,
33
+ ease: cubicEasingFn,
34
+ },
35
+ },
36
+ } satisfies Variants;
37
+
38
+ export const FileBreadcrumb = memo<FileBreadcrumbProps>(({ files, pathSegments = [], onFileSelect }) => {
39
+ renderLogger.trace('FileBreadcrumb');
40
+
41
+ const [activeIndex, setActiveIndex] = useState<number | null>(null);
42
+
43
+ const contextMenuRef = useRef<HTMLDivElement | null>(null);
44
+ const segmentRefs = useRef<(HTMLSpanElement | null)[]>([]);
45
+
46
+ const handleSegmentClick = (index: number) => {
47
+ setActiveIndex((prevIndex) => (prevIndex === index ? null : index));
48
+ };
49
+
50
+ useEffect(() => {
51
+ const handleOutsideClick = (event: MouseEvent) => {
52
+ if (
53
+ activeIndex !== null &&
54
+ !contextMenuRef.current?.contains(event.target as Node) &&
55
+ !segmentRefs.current.some((ref) => ref?.contains(event.target as Node))
56
+ ) {
57
+ setActiveIndex(null);
58
+ }
59
+ };
60
+
61
+ document.addEventListener('mousedown', handleOutsideClick);
62
+
63
+ return () => {
64
+ document.removeEventListener('mousedown', handleOutsideClick);
65
+ };
66
+ }, [activeIndex]);
67
+
68
+ if (files === undefined || pathSegments.length === 0) {
69
+ return null;
70
+ }
71
+
72
+ return (
73
+ <div className="flex">
74
+ {pathSegments.map((segment, index) => {
75
+ const isLast = index === pathSegments.length - 1;
76
+
77
+ const path = pathSegments.slice(0, index).join('/');
78
+
79
+ if (!WORK_DIR_REGEX.test(path)) {
80
+ return null;
81
+ }
82
+
83
+ const isActive = activeIndex === index;
84
+
85
+ return (
86
+ <div key={index} className="relative flex items-center">
87
+ <DropdownMenu.Root open={isActive} modal={false}>
88
+ <DropdownMenu.Trigger asChild>
89
+ <span
90
+ ref={(ref) => (segmentRefs.current[index] = ref)}
91
+ className={classNames('flex items-center gap-1.5 cursor-pointer shrink-0', {
92
+ 'text-bolt-elements-textTertiary hover:text-bolt-elements-textPrimary': !isActive,
93
+ 'text-bolt-elements-textPrimary underline': isActive,
94
+ 'pr-4': isLast,
95
+ })}
96
+ onClick={() => handleSegmentClick(index)}
97
+ >
98
+ {isLast && <div className="i-ph:file-duotone" />}
99
+ {segment}
100
+ </span>
101
+ </DropdownMenu.Trigger>
102
+ {index > 0 && !isLast && <span className="i-ph:caret-right inline-block mx-1" />}
103
+ <AnimatePresence>
104
+ {isActive && (
105
+ <DropdownMenu.Portal>
106
+ <DropdownMenu.Content
107
+ className="z-file-tree-breadcrumb"
108
+ asChild
109
+ align="start"
110
+ side="bottom"
111
+ avoidCollisions={false}
112
+ >
113
+ <motion.div
114
+ ref={contextMenuRef}
115
+ initial="close"
116
+ animate="open"
117
+ exit="close"
118
+ variants={contextMenuVariants}
119
+ >
120
+ <div className="rounded-lg overflow-hidden">
121
+ <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">
122
+ <FileTree
123
+ files={files}
124
+ hideRoot
125
+ rootFolder={path}
126
+ collapsed
127
+ allowFolderSelection
128
+ selectedFile={`${path}/${segment}`}
129
+ onFileSelect={(filePath) => {
130
+ setActiveIndex(null);
131
+ onFileSelect?.(filePath);
132
+ }}
133
+ />
134
+ </div>
135
+ </div>
136
+ <DropdownMenu.Arrow className="fill-bolt-elements-borderColor" />
137
+ </motion.div>
138
+ </DropdownMenu.Content>
139
+ </DropdownMenu.Portal>
140
+ )}
141
+ </AnimatePresence>
142
+ </DropdownMenu.Root>
143
+ </div>
144
+ );
145
+ })}
146
+ </div>
147
+ );
148
+ });
app/components/workbench/FileTree.tsx ADDED
@@ -0,0 +1,409 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { memo, useEffect, useMemo, useState, type ReactNode } from 'react';
2
+ import type { FileMap } from '~/lib/stores/files';
3
+ import { classNames } from '~/utils/classNames';
4
+ import { createScopedLogger, renderLogger } from '~/utils/logger';
5
+
6
+ const logger = createScopedLogger('FileTree');
7
+
8
+ const NODE_PADDING_LEFT = 8;
9
+ const DEFAULT_HIDDEN_FILES = [/\/node_modules\//, /\/\.next/, /\/\.astro/];
10
+
11
+ interface Props {
12
+ files?: FileMap;
13
+ selectedFile?: string;
14
+ onFileSelect?: (filePath: string) => void;
15
+ rootFolder?: string;
16
+ hideRoot?: boolean;
17
+ collapsed?: boolean;
18
+ allowFolderSelection?: boolean;
19
+ hiddenFiles?: Array<string | RegExp>;
20
+ unsavedFiles?: Set<string>;
21
+ className?: string;
22
+ }
23
+
24
+ export const FileTree = memo(
25
+ ({
26
+ files = {},
27
+ onFileSelect,
28
+ selectedFile,
29
+ rootFolder,
30
+ hideRoot = false,
31
+ collapsed = false,
32
+ allowFolderSelection = false,
33
+ hiddenFiles,
34
+ className,
35
+ unsavedFiles,
36
+ }: Props) => {
37
+ renderLogger.trace('FileTree');
38
+
39
+ const computedHiddenFiles = useMemo(() => [...DEFAULT_HIDDEN_FILES, ...(hiddenFiles ?? [])], [hiddenFiles]);
40
+
41
+ const fileList = useMemo(() => {
42
+ return buildFileList(files, rootFolder, hideRoot, computedHiddenFiles);
43
+ }, [files, rootFolder, hideRoot, computedHiddenFiles]);
44
+
45
+ const [collapsedFolders, setCollapsedFolders] = useState(() => {
46
+ return collapsed
47
+ ? new Set(fileList.filter((item) => item.kind === 'folder').map((item) => item.fullPath))
48
+ : new Set<string>();
49
+ });
50
+
51
+ useEffect(() => {
52
+ if (collapsed) {
53
+ setCollapsedFolders(new Set(fileList.filter((item) => item.kind === 'folder').map((item) => item.fullPath)));
54
+ return;
55
+ }
56
+
57
+ setCollapsedFolders((prevCollapsed) => {
58
+ const newCollapsed = new Set<string>();
59
+
60
+ for (const folder of fileList) {
61
+ if (folder.kind === 'folder' && prevCollapsed.has(folder.fullPath)) {
62
+ newCollapsed.add(folder.fullPath);
63
+ }
64
+ }
65
+
66
+ return newCollapsed;
67
+ });
68
+ }, [fileList, collapsed]);
69
+
70
+ const filteredFileList = useMemo(() => {
71
+ const list = [];
72
+
73
+ let lastDepth = Number.MAX_SAFE_INTEGER;
74
+
75
+ for (const fileOrFolder of fileList) {
76
+ const depth = fileOrFolder.depth;
77
+
78
+ // if the depth is equal we reached the end of the collaped group
79
+ if (lastDepth === depth) {
80
+ lastDepth = Number.MAX_SAFE_INTEGER;
81
+ }
82
+
83
+ // ignore collapsed folders
84
+ if (collapsedFolders.has(fileOrFolder.fullPath)) {
85
+ lastDepth = Math.min(lastDepth, depth);
86
+ }
87
+
88
+ // ignore files and folders below the last collapsed folder
89
+ if (lastDepth < depth) {
90
+ continue;
91
+ }
92
+
93
+ list.push(fileOrFolder);
94
+ }
95
+
96
+ return list;
97
+ }, [fileList, collapsedFolders]);
98
+
99
+ const toggleCollapseState = (fullPath: string) => {
100
+ setCollapsedFolders((prevSet) => {
101
+ const newSet = new Set(prevSet);
102
+
103
+ if (newSet.has(fullPath)) {
104
+ newSet.delete(fullPath);
105
+ } else {
106
+ newSet.add(fullPath);
107
+ }
108
+
109
+ return newSet;
110
+ });
111
+ };
112
+
113
+ return (
114
+ <div className={classNames('text-sm', className)}>
115
+ {filteredFileList.map((fileOrFolder) => {
116
+ switch (fileOrFolder.kind) {
117
+ case 'file': {
118
+ return (
119
+ <File
120
+ key={fileOrFolder.id}
121
+ selected={selectedFile === fileOrFolder.fullPath}
122
+ file={fileOrFolder}
123
+ unsavedChanges={unsavedFiles?.has(fileOrFolder.fullPath)}
124
+ onClick={() => {
125
+ onFileSelect?.(fileOrFolder.fullPath);
126
+ }}
127
+ />
128
+ );
129
+ }
130
+ case 'folder': {
131
+ return (
132
+ <Folder
133
+ key={fileOrFolder.id}
134
+ folder={fileOrFolder}
135
+ selected={allowFolderSelection && selectedFile === fileOrFolder.fullPath}
136
+ collapsed={collapsedFolders.has(fileOrFolder.fullPath)}
137
+ onClick={() => {
138
+ toggleCollapseState(fileOrFolder.fullPath);
139
+ }}
140
+ />
141
+ );
142
+ }
143
+ default: {
144
+ return undefined;
145
+ }
146
+ }
147
+ })}
148
+ </div>
149
+ );
150
+ },
151
+ );
152
+
153
+ export default FileTree;
154
+
155
+ interface FolderProps {
156
+ folder: FolderNode;
157
+ collapsed: boolean;
158
+ selected?: boolean;
159
+ onClick: () => void;
160
+ }
161
+
162
+ function Folder({ folder: { depth, name }, collapsed, selected = false, onClick }: FolderProps) {
163
+ return (
164
+ <NodeButton
165
+ className={classNames('group', {
166
+ 'bg-transparent text-bolt-elements-item-contentDefault hover:text-bolt-elements-item-contentActive hover:bg-bolt-elements-item-backgroundActive':
167
+ !selected,
168
+ 'bg-bolt-elements-item-backgroundAccent text-bolt-elements-item-contentAccent': selected,
169
+ })}
170
+ depth={depth}
171
+ iconClasses={classNames({
172
+ 'i-ph:caret-right scale-98': collapsed,
173
+ 'i-ph:caret-down scale-98': !collapsed,
174
+ })}
175
+ onClick={onClick}
176
+ >
177
+ {name}
178
+ </NodeButton>
179
+ );
180
+ }
181
+
182
+ interface FileProps {
183
+ file: FileNode;
184
+ selected: boolean;
185
+ unsavedChanges?: boolean;
186
+ onClick: () => void;
187
+ }
188
+
189
+ function File({ file: { depth, name }, onClick, selected, unsavedChanges = false }: FileProps) {
190
+ return (
191
+ <NodeButton
192
+ className={classNames('group', {
193
+ 'bg-transparent hover:bg-bolt-elements-item-backgroundActive text-bolt-elements-item-contentDefault': !selected,
194
+ 'bg-bolt-elements-item-backgroundAccent text-bolt-elements-item-contentAccent': selected,
195
+ })}
196
+ depth={depth}
197
+ iconClasses={classNames('i-ph:file-duotone scale-98', {
198
+ 'group-hover:text-bolt-elements-item-contentActive': !selected,
199
+ })}
200
+ onClick={onClick}
201
+ >
202
+ <div
203
+ className={classNames('flex items-center', {
204
+ 'group-hover:text-bolt-elements-item-contentActive': !selected,
205
+ })}
206
+ >
207
+ <div className="flex-1 truncate pr-2">{name}</div>
208
+ {unsavedChanges && <span className="i-ph:circle-fill scale-68 shrink-0 text-orange-500" />}
209
+ </div>
210
+ </NodeButton>
211
+ );
212
+ }
213
+
214
+ interface ButtonProps {
215
+ depth: number;
216
+ iconClasses: string;
217
+ children: ReactNode;
218
+ className?: string;
219
+ onClick?: () => void;
220
+ }
221
+
222
+ function NodeButton({ depth, iconClasses, onClick, className, children }: ButtonProps) {
223
+ return (
224
+ <button
225
+ className={classNames(
226
+ 'flex items-center gap-1.5 w-full pr-2 border-2 border-transparent text-faded py-0.5',
227
+ className,
228
+ )}
229
+ style={{ paddingLeft: `${6 + depth * NODE_PADDING_LEFT}px` }}
230
+ onClick={() => onClick?.()}
231
+ >
232
+ <div className={classNames('scale-120 shrink-0', iconClasses)}></div>
233
+ <div className="truncate w-full text-left">{children}</div>
234
+ </button>
235
+ );
236
+ }
237
+
238
+ type Node = FileNode | FolderNode;
239
+
240
+ interface BaseNode {
241
+ id: number;
242
+ depth: number;
243
+ name: string;
244
+ fullPath: string;
245
+ }
246
+
247
+ interface FileNode extends BaseNode {
248
+ kind: 'file';
249
+ }
250
+
251
+ interface FolderNode extends BaseNode {
252
+ kind: 'folder';
253
+ }
254
+
255
+ function buildFileList(
256
+ files: FileMap,
257
+ rootFolder = '/',
258
+ hideRoot: boolean,
259
+ hiddenFiles: Array<string | RegExp>,
260
+ ): Node[] {
261
+ const folderPaths = new Set<string>();
262
+ const fileList: Node[] = [];
263
+
264
+ let defaultDepth = 0;
265
+
266
+ if (rootFolder === '/' && !hideRoot) {
267
+ defaultDepth = 1;
268
+ fileList.push({ kind: 'folder', name: '/', depth: 0, id: 0, fullPath: '/' });
269
+ }
270
+
271
+ for (const [filePath, dirent] of Object.entries(files)) {
272
+ const segments = filePath.split('/').filter((segment) => segment);
273
+ const fileName = segments.at(-1);
274
+
275
+ if (!fileName || isHiddenFile(filePath, fileName, hiddenFiles)) {
276
+ continue;
277
+ }
278
+
279
+ let currentPath = '';
280
+
281
+ let i = 0;
282
+ let depth = 0;
283
+
284
+ while (i < segments.length) {
285
+ const name = segments[i];
286
+ const fullPath = (currentPath += `/${name}`);
287
+
288
+ if (!fullPath.startsWith(rootFolder) || (hideRoot && fullPath === rootFolder)) {
289
+ i++;
290
+ continue;
291
+ }
292
+
293
+ if (i === segments.length - 1 && dirent?.type === 'file') {
294
+ fileList.push({
295
+ kind: 'file',
296
+ id: fileList.length,
297
+ name,
298
+ fullPath,
299
+ depth: depth + defaultDepth,
300
+ });
301
+ } else if (!folderPaths.has(fullPath)) {
302
+ folderPaths.add(fullPath);
303
+
304
+ fileList.push({
305
+ kind: 'folder',
306
+ id: fileList.length,
307
+ name,
308
+ fullPath,
309
+ depth: depth + defaultDepth,
310
+ });
311
+ }
312
+
313
+ i++;
314
+ depth++;
315
+ }
316
+ }
317
+
318
+ return sortFileList(rootFolder, fileList, hideRoot);
319
+ }
320
+
321
+ function isHiddenFile(filePath: string, fileName: string, hiddenFiles: Array<string | RegExp>) {
322
+ return hiddenFiles.some((pathOrRegex) => {
323
+ if (typeof pathOrRegex === 'string') {
324
+ return fileName === pathOrRegex;
325
+ }
326
+
327
+ return pathOrRegex.test(filePath);
328
+ });
329
+ }
330
+
331
+ /**
332
+ * Sorts the given list of nodes into a tree structure (still a flat list).
333
+ *
334
+ * This function organizes the nodes into a hierarchical structure based on their paths,
335
+ * with folders appearing before files and all items sorted alphabetically within their level.
336
+ *
337
+ * @note This function mutates the given `nodeList` array for performance reasons.
338
+ *
339
+ * @param rootFolder - The path of the root folder to start the sorting from.
340
+ * @param nodeList - The list of nodes to be sorted.
341
+ *
342
+ * @returns A new array of nodes sorted in depth-first order.
343
+ */
344
+ function sortFileList(rootFolder: string, nodeList: Node[], hideRoot: boolean): Node[] {
345
+ logger.trace('sortFileList');
346
+
347
+ const nodeMap = new Map<string, Node>();
348
+ const childrenMap = new Map<string, Node[]>();
349
+
350
+ // pre-sort nodes by name and type
351
+ nodeList.sort((a, b) => compareNodes(a, b));
352
+
353
+ for (const node of nodeList) {
354
+ nodeMap.set(node.fullPath, node);
355
+
356
+ const parentPath = node.fullPath.slice(0, node.fullPath.lastIndexOf('/'));
357
+
358
+ if (parentPath !== rootFolder.slice(0, rootFolder.lastIndexOf('/'))) {
359
+ if (!childrenMap.has(parentPath)) {
360
+ childrenMap.set(parentPath, []);
361
+ }
362
+
363
+ childrenMap.get(parentPath)?.push(node);
364
+ }
365
+ }
366
+
367
+ const sortedList: Node[] = [];
368
+
369
+ const depthFirstTraversal = (path: string): void => {
370
+ const node = nodeMap.get(path);
371
+
372
+ if (node) {
373
+ sortedList.push(node);
374
+ }
375
+
376
+ const children = childrenMap.get(path);
377
+
378
+ if (children) {
379
+ for (const child of children) {
380
+ if (child.kind === 'folder') {
381
+ depthFirstTraversal(child.fullPath);
382
+ } else {
383
+ sortedList.push(child);
384
+ }
385
+ }
386
+ }
387
+ };
388
+
389
+ if (hideRoot) {
390
+ // if root is hidden, start traversal from its immediate children
391
+ const rootChildren = childrenMap.get(rootFolder) || [];
392
+
393
+ for (const child of rootChildren) {
394
+ depthFirstTraversal(child.fullPath);
395
+ }
396
+ } else {
397
+ depthFirstTraversal(rootFolder);
398
+ }
399
+
400
+ return sortedList;
401
+ }
402
+
403
+ function compareNodes(a: Node, b: Node): number {
404
+ if (a.kind !== b.kind) {
405
+ return a.kind === 'folder' ? -1 : 1;
406
+ }
407
+
408
+ return a.name.localeCompare(b.name, undefined, { numeric: true, sensitivity: 'base' });
409
+ }
app/components/workbench/PortDropdown.tsx ADDED
@@ -0,0 +1,83 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { memo, useEffect, useRef } from 'react';
2
+ import { IconButton } from '~/components/ui/IconButton';
3
+ import type { PreviewInfo } from '~/lib/stores/previews';
4
+
5
+ interface PortDropdownProps {
6
+ activePreviewIndex: number;
7
+ setActivePreviewIndex: (index: number) => void;
8
+ isDropdownOpen: boolean;
9
+ setIsDropdownOpen: (value: boolean) => void;
10
+ setHasSelectedPreview: (value: boolean) => void;
11
+ previews: PreviewInfo[];
12
+ }
13
+
14
+ export const PortDropdown = memo(
15
+ ({
16
+ activePreviewIndex,
17
+ setActivePreviewIndex,
18
+ isDropdownOpen,
19
+ setIsDropdownOpen,
20
+ setHasSelectedPreview,
21
+ previews,
22
+ }: PortDropdownProps) => {
23
+ const dropdownRef = useRef<HTMLDivElement>(null);
24
+
25
+ // sort previews, preserving original index
26
+ const sortedPreviews = previews
27
+ .map((previewInfo, index) => ({ ...previewInfo, index }))
28
+ .sort((a, b) => a.port - b.port);
29
+
30
+ // close dropdown if user clicks outside
31
+ useEffect(() => {
32
+ const handleClickOutside = (event: MouseEvent) => {
33
+ if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
34
+ setIsDropdownOpen(false);
35
+ }
36
+ };
37
+
38
+ if (isDropdownOpen) {
39
+ window.addEventListener('mousedown', handleClickOutside);
40
+ } else {
41
+ window.removeEventListener('mousedown', handleClickOutside);
42
+ }
43
+
44
+ return () => {
45
+ window.removeEventListener('mousedown', handleClickOutside);
46
+ };
47
+ }, [isDropdownOpen]);
48
+
49
+ return (
50
+ <div className="relative z-port-dropdown" ref={dropdownRef}>
51
+ <IconButton icon="i-ph:plug" onClick={() => setIsDropdownOpen(!isDropdownOpen)} />
52
+ {isDropdownOpen && (
53
+ <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">
54
+ <div className="px-4 py-2 border-b border-bolt-elements-borderColor text-sm font-semibold text-bolt-elements-textPrimary">
55
+ Ports
56
+ </div>
57
+ {sortedPreviews.map((preview) => (
58
+ <div
59
+ key={preview.port}
60
+ className="flex items-center px-4 py-2 cursor-pointer hover:bg-bolt-elements-item-backgroundActive"
61
+ onClick={() => {
62
+ setActivePreviewIndex(preview.index);
63
+ setIsDropdownOpen(false);
64
+ setHasSelectedPreview(true);
65
+ }}
66
+ >
67
+ <span
68
+ className={
69
+ activePreviewIndex === preview.index
70
+ ? 'text-bolt-elements-item-contentAccent'
71
+ : 'text-bolt-elements-item-contentDefault group-hover:text-bolt-elements-item-contentActive'
72
+ }
73
+ >
74
+ {preview.port}
75
+ </span>
76
+ </div>
77
+ ))}
78
+ </div>
79
+ )}
80
+ </div>
81
+ );
82
+ },
83
+ );
app/components/workbench/Preview.tsx ADDED
@@ -0,0 +1,124 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useStore } from '@nanostores/react';
2
+ import { memo, useCallback, useEffect, useRef, useState } from 'react';
3
+ import { IconButton } from '~/components/ui/IconButton';
4
+ import { workbenchStore } from '~/lib/stores/workbench';
5
+ import { PortDropdown } from './PortDropdown';
6
+
7
+ export const Preview = memo(() => {
8
+ const iframeRef = useRef<HTMLIFrameElement>(null);
9
+ const inputRef = useRef<HTMLInputElement>(null);
10
+ const [activePreviewIndex, setActivePreviewIndex] = useState(0);
11
+ const [isPortDropdownOpen, setIsPortDropdownOpen] = useState(false);
12
+ const hasSelectedPreview = useRef(false);
13
+ const previews = useStore(workbenchStore.previews);
14
+ const activePreview = previews[activePreviewIndex];
15
+
16
+ const [url, setUrl] = useState('');
17
+ const [iframeUrl, setIframeUrl] = useState<string | undefined>();
18
+
19
+ useEffect(() => {
20
+ if (!activePreview) {
21
+ setUrl('');
22
+ setIframeUrl(undefined);
23
+
24
+ return;
25
+ }
26
+
27
+ const { baseUrl } = activePreview;
28
+
29
+ setUrl(baseUrl);
30
+ setIframeUrl(baseUrl);
31
+ }, [activePreview, iframeUrl]);
32
+
33
+ const validateUrl = useCallback(
34
+ (value: string) => {
35
+ if (!activePreview) {
36
+ return false;
37
+ }
38
+
39
+ const { baseUrl } = activePreview;
40
+
41
+ if (value === baseUrl) {
42
+ return true;
43
+ } else if (value.startsWith(baseUrl)) {
44
+ return ['/', '?', '#'].includes(value.charAt(baseUrl.length));
45
+ }
46
+
47
+ return false;
48
+ },
49
+ [activePreview],
50
+ );
51
+
52
+ const findMinPortIndex = useCallback(
53
+ (minIndex: number, preview: { port: number }, index: number, array: { port: number }[]) => {
54
+ return preview.port < array[minIndex].port ? index : minIndex;
55
+ },
56
+ [],
57
+ );
58
+
59
+ // when previews change, display the lowest port if user hasn't selected a preview
60
+ useEffect(() => {
61
+ if (previews.length > 1 && !hasSelectedPreview.current) {
62
+ const minPortIndex = previews.reduce(findMinPortIndex, 0);
63
+
64
+ setActivePreviewIndex(minPortIndex);
65
+ }
66
+ }, [previews]);
67
+
68
+ const reloadPreview = () => {
69
+ if (iframeRef.current) {
70
+ iframeRef.current.src = iframeRef.current.src;
71
+ }
72
+ };
73
+
74
+ return (
75
+ <div className="w-full h-full flex flex-col">
76
+ {isPortDropdownOpen && (
77
+ <div className="z-iframe-overlay w-full h-full absolute" onClick={() => setIsPortDropdownOpen(false)} />
78
+ )}
79
+ <div className="bg-bolt-elements-background-depth-2 p-2 flex items-center gap-1.5">
80
+ <IconButton icon="i-ph:arrow-clockwise" onClick={reloadPreview} />
81
+ <div
82
+ 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
83
+ focus-within-border-bolt-elements-borderColorActive focus-within:text-bolt-elements-preview-addressBar-textActive"
84
+ >
85
+ <input
86
+ ref={inputRef}
87
+ className="w-full bg-transparent outline-none"
88
+ type="text"
89
+ value={url}
90
+ onChange={(event) => {
91
+ setUrl(event.target.value);
92
+ }}
93
+ onKeyDown={(event) => {
94
+ if (event.key === 'Enter' && validateUrl(url)) {
95
+ setIframeUrl(url);
96
+
97
+ if (inputRef.current) {
98
+ inputRef.current.blur();
99
+ }
100
+ }
101
+ }}
102
+ />
103
+ </div>
104
+ {previews.length > 1 && (
105
+ <PortDropdown
106
+ activePreviewIndex={activePreviewIndex}
107
+ setActivePreviewIndex={setActivePreviewIndex}
108
+ isDropdownOpen={isPortDropdownOpen}
109
+ setHasSelectedPreview={(value) => (hasSelectedPreview.current = value)}
110
+ setIsDropdownOpen={setIsPortDropdownOpen}
111
+ previews={previews}
112
+ />
113
+ )}
114
+ </div>
115
+ <div className="flex-1 border-t border-bolt-elements-borderColor">
116
+ {activePreview ? (
117
+ <iframe ref={iframeRef} className="border-none w-full h-full bg-white" src={iframeUrl} />
118
+ ) : (
119
+ <div className="flex w-full h-full justify-center items-center bg-white">No preview available</div>
120
+ )}
121
+ </div>
122
+ </div>
123
+ );
124
+ });
app/components/workbench/Workbench.client.tsx ADDED
@@ -0,0 +1,187 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useStore } from '@nanostores/react';
2
+ import { motion, type HTMLMotionProps, type Variants } from 'framer-motion';
3
+ import { computed } from 'nanostores';
4
+ import { memo, useCallback, useEffect } from 'react';
5
+ import { toast } from 'react-toastify';
6
+ import {
7
+ type OnChangeCallback as OnEditorChange,
8
+ type OnScrollCallback as OnEditorScroll,
9
+ } from '~/components/editor/codemirror/CodeMirrorEditor';
10
+ import { IconButton } from '~/components/ui/IconButton';
11
+ import { PanelHeaderButton } from '~/components/ui/PanelHeaderButton';
12
+ import { Slider, type SliderOptions } from '~/components/ui/Slider';
13
+ import { workbenchStore, type WorkbenchViewType } from '~/lib/stores/workbench';
14
+ import { classNames } from '~/utils/classNames';
15
+ import { cubicEasingFn } from '~/utils/easings';
16
+ import { renderLogger } from '~/utils/logger';
17
+ import { EditorPanel } from './EditorPanel';
18
+ import { Preview } from './Preview';
19
+
20
+ interface WorkspaceProps {
21
+ chatStarted?: boolean;
22
+ isStreaming?: boolean;
23
+ }
24
+
25
+ const viewTransition = { ease: cubicEasingFn };
26
+
27
+ const sliderOptions: SliderOptions<WorkbenchViewType> = {
28
+ left: {
29
+ value: 'code',
30
+ text: 'Code',
31
+ },
32
+ right: {
33
+ value: 'preview',
34
+ text: 'Preview',
35
+ },
36
+ };
37
+
38
+ const workbenchVariants = {
39
+ closed: {
40
+ width: 0,
41
+ transition: {
42
+ duration: 0.2,
43
+ ease: cubicEasingFn,
44
+ },
45
+ },
46
+ open: {
47
+ width: 'var(--workbench-width)',
48
+ transition: {
49
+ duration: 0.2,
50
+ ease: cubicEasingFn,
51
+ },
52
+ },
53
+ } satisfies Variants;
54
+
55
+ export const Workbench = memo(({ chatStarted, isStreaming }: WorkspaceProps) => {
56
+ renderLogger.trace('Workbench');
57
+
58
+ const hasPreview = useStore(computed(workbenchStore.previews, (previews) => previews.length > 0));
59
+ const showWorkbench = useStore(workbenchStore.showWorkbench);
60
+ const selectedFile = useStore(workbenchStore.selectedFile);
61
+ const currentDocument = useStore(workbenchStore.currentDocument);
62
+ const unsavedFiles = useStore(workbenchStore.unsavedFiles);
63
+ const files = useStore(workbenchStore.files);
64
+ const selectedView = useStore(workbenchStore.currentView);
65
+
66
+ const setSelectedView = (view: WorkbenchViewType) => {
67
+ workbenchStore.currentView.set(view);
68
+ };
69
+
70
+ useEffect(() => {
71
+ if (hasPreview) {
72
+ setSelectedView('preview');
73
+ }
74
+ }, [hasPreview]);
75
+
76
+ useEffect(() => {
77
+ workbenchStore.setDocuments(files);
78
+ }, [files]);
79
+
80
+ const onEditorChange = useCallback<OnEditorChange>((update) => {
81
+ workbenchStore.setCurrentDocumentContent(update.content);
82
+ }, []);
83
+
84
+ const onEditorScroll = useCallback<OnEditorScroll>((position) => {
85
+ workbenchStore.setCurrentDocumentScrollPosition(position);
86
+ }, []);
87
+
88
+ const onFileSelect = useCallback((filePath: string | undefined) => {
89
+ workbenchStore.setSelectedFile(filePath);
90
+ }, []);
91
+
92
+ const onFileSave = useCallback(() => {
93
+ workbenchStore.saveCurrentDocument().catch(() => {
94
+ toast.error('Failed to update file content');
95
+ });
96
+ }, []);
97
+
98
+ const onFileReset = useCallback(() => {
99
+ workbenchStore.resetCurrentDocument();
100
+ }, []);
101
+
102
+ return (
103
+ chatStarted && (
104
+ <motion.div
105
+ initial="closed"
106
+ animate={showWorkbench ? 'open' : 'closed'}
107
+ variants={workbenchVariants}
108
+ className="z-workbench"
109
+ >
110
+ <div
111
+ className={classNames(
112
+ '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',
113
+ {
114
+ 'left-[var(--workbench-left)]': showWorkbench,
115
+ 'left-[100%]': !showWorkbench,
116
+ },
117
+ )}
118
+ >
119
+ <div className="absolute inset-0 px-6">
120
+ <div className="h-full flex flex-col bg-bolt-elements-background-depth-2 border border-bolt-elements-borderColor shadow-sm rounded-lg overflow-hidden">
121
+ <div className="flex items-center px-3 py-2 border-b border-bolt-elements-borderColor">
122
+ <Slider selected={selectedView} options={sliderOptions} setSelected={setSelectedView} />
123
+ <div className="ml-auto" />
124
+ {selectedView === 'code' && (
125
+ <PanelHeaderButton
126
+ className="mr-1 text-sm"
127
+ onClick={() => {
128
+ workbenchStore.toggleTerminal(!workbenchStore.showTerminal.get());
129
+ }}
130
+ >
131
+ <div className="i-ph:terminal" />
132
+ Toggle Terminal
133
+ </PanelHeaderButton>
134
+ )}
135
+ <IconButton
136
+ icon="i-ph:x-circle"
137
+ className="-mr-1"
138
+ size="xl"
139
+ onClick={() => {
140
+ workbenchStore.showWorkbench.set(false);
141
+ }}
142
+ />
143
+ </div>
144
+ <div className="relative flex-1 overflow-hidden">
145
+ <View
146
+ initial={{ x: selectedView === 'code' ? 0 : '-100%' }}
147
+ animate={{ x: selectedView === 'code' ? 0 : '-100%' }}
148
+ >
149
+ <EditorPanel
150
+ editorDocument={currentDocument}
151
+ isStreaming={isStreaming}
152
+ selectedFile={selectedFile}
153
+ files={files}
154
+ unsavedFiles={unsavedFiles}
155
+ onFileSelect={onFileSelect}
156
+ onEditorScroll={onEditorScroll}
157
+ onEditorChange={onEditorChange}
158
+ onFileSave={onFileSave}
159
+ onFileReset={onFileReset}
160
+ />
161
+ </View>
162
+ <View
163
+ initial={{ x: selectedView === 'preview' ? 0 : '100%' }}
164
+ animate={{ x: selectedView === 'preview' ? 0 : '100%' }}
165
+ >
166
+ <Preview />
167
+ </View>
168
+ </div>
169
+ </div>
170
+ </div>
171
+ </div>
172
+ </motion.div>
173
+ )
174
+ );
175
+ });
176
+
177
+ interface ViewProps extends HTMLMotionProps<'div'> {
178
+ children: JSX.Element;
179
+ }
180
+
181
+ const View = memo(({ children, ...props }: ViewProps) => {
182
+ return (
183
+ <motion.div className="absolute inset-0" transition={viewTransition} {...props}>
184
+ {children}
185
+ </motion.div>
186
+ );
187
+ });