Compare commits

...

9 Commits

Author SHA1 Message Date
viper151
c3599cd2c4 chore(release): v1.29.2 2026-04-14 18:16:20 +00:00
simosmik
9b11c034d9 fix(sandbox): use backgrounded sbx run to keep sandbox alive 2026-04-14 18:14:58 +00:00
viper151
b6d19201b6 chore(release): v1.29.1 2026-04-14 17:38:53 +00:00
simosmik
4a569725da fix: add latest tag to docker npx command and change the detach mode to work without spawn 2026-04-14 17:37:20 +00:00
viper151
6ce3306947 chore(release): v1.29.0 2026-04-14 15:20:18 +00:00
Haile
d0dd007d0f Feature/restart server on update (#652)
* feat: support restart server on update for platform

* feat: add update platform script to package.json

* feat: optimize platform update command by omitting dev dependencies

* feat: simplify update commands for platform

---------

Co-authored-by: Haileyesus <something@gmail.com>
Co-authored-by: Simos Mikelatos <simosmik@gmail.com>
2026-04-14 17:18:47 +02:00
simosmik
13e97e2c71 feat: adding docker sandbox environments 2026-04-14 15:18:02 +00:00
Haile
c7a5baf147 fix(thinking-mode): fix dropdown positioning (#646) 2026-04-13 11:44:31 +02:00
simosmik
e2459cb0f8 chore: update release flow node version 2026-04-10 14:56:33 +00:00
20 changed files with 763 additions and 140 deletions

View File

@@ -20,14 +20,14 @@ jobs:
contents: write contents: write
id-token: write id-token: write
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v6
with: with:
fetch-depth: 0 fetch-depth: 0
token: ${{ secrets.RELEASE_PAT }} token: ${{ secrets.RELEASE_PAT }}
- uses: actions/setup-node@v4 - uses: actions/setup-node@v6
with: with:
node-version: 20 node-version: 22
registry-url: https://registry.npmjs.org registry-url: https://registry.npmjs.org
- name: git config - name: git config

View File

@@ -3,6 +3,32 @@
All notable changes to CloudCLI UI will be documented in this file. All notable changes to CloudCLI UI will be documented in this file.
## [1.29.2](https://github.com/siteboon/claudecodeui/compare/v1.29.1...v1.29.2) (2026-04-14)
### Bug Fixes
* **sandbox:** use backgrounded sbx run to keep sandbox alive ([9b11c03](https://github.com/siteboon/claudecodeui/commit/9b11c034d9a19710a23b56c62dcf07c21a17bd97))
## [1.29.1](https://github.com/siteboon/claudecodeui/compare/v1.29.0...v1.29.1) (2026-04-14)
### Bug Fixes
* add latest tag to docker npx command and change the detach mode to work without spawn ([4a56972](https://github.com/siteboon/claudecodeui/commit/4a569725dae320a505753359d8edfd8ca79f0fd7))
## [1.29.0](https://github.com/siteboon/claudecodeui/compare/v1.28.1...v1.29.0) (2026-04-14)
### New Features
* adding docker sandbox environments ([13e97e2](https://github.com/siteboon/claudecodeui/commit/13e97e2c71254de7a60afb5495b21064c4bc4241))
### Bug Fixes
* **thinking-mode:** fix dropdown positioning ([#646](https://github.com/siteboon/claudecodeui/issues/646)) ([c7a5baf](https://github.com/siteboon/claudecodeui/commit/c7a5baf1479404bd40e23aa58bd9f677df9a04c6))
### Maintenance
* update release flow node version ([e2459cb](https://github.com/siteboon/claudecodeui/commit/e2459cb0f8b35f54827778a7b444e6c3ca326506))
## [1.28.1](https://github.com/siteboon/claudecodeui/compare/v1.28.0...v1.28.1) (2026-04-10) ## [1.28.1](https://github.com/siteboon/claudecodeui/compare/v1.28.0...v1.28.1) (2026-04-10)
### New Features ### New Features

View File

@@ -76,6 +76,8 @@ Der schnellste Einstieg keine lokale Einrichtung erforderlich. Erhalte eine
### Self-Hosted (Open Source) ### Self-Hosted (Open Source)
#### npm
CloudCLI UI sofort mit **npx** ausprobieren (erfordert **Node.js** v22+): CloudCLI UI sofort mit **npx** ausprobieren (erfordert **Node.js** v22+):
```bash ```bash
@@ -93,6 +95,15 @@ cloudcli
Die **[Dokumentation →](https://cloudcli.ai/docs)** enthält weitere Konfigurationsoptionen, PM2, Remote-Server-Einrichtung und mehr. Die **[Dokumentation →](https://cloudcli.ai/docs)** enthält weitere Konfigurationsoptionen, PM2, Remote-Server-Einrichtung und mehr.
#### Docker Sandboxes (Experimentell)
Agents in isolierten Sandboxes mit Hypervisor-Isolation ausführen. Standardmäßig wird Claude Code gestartet. Erfordert die [`sbx` CLI](https://docs.docker.com/ai/sandboxes/get-started/).
```
npx @cloudcli-ai/cloudcli@latest sandbox ~/my-project
```
Unterstützt Claude Code, Codex und Gemini CLI. Weitere Details in der [Sandbox-Dokumentation](docker/).
--- ---

View File

@@ -72,6 +72,8 @@
### セルフホスト(オープンソース) ### セルフホスト(オープンソース)
#### npm
**npx** で今すぐ CloudCLI UI を試せます(**Node.js** v22+ が必要): **npx** で今すぐ CloudCLI UI を試せます(**Node.js** v22+ が必要):
```bash ```bash
@@ -89,6 +91,15 @@ cloudcli
より詳細な設定オプション、PM2、リモートサーバー設定などについては **[ドキュメントはこちら →](https://cloudcli.ai/docs)** を参照してください。 より詳細な設定オプション、PM2、リモートサーバー設定などについては **[ドキュメントはこちら →](https://cloudcli.ai/docs)** を参照してください。
#### Docker Sandboxes実験的
ハイパーバイザーレベルの分離でエージェントをサンドボックスで実行します。デフォルトでは Claude Code が起動します。[`sbx` CLI](https://docs.docker.com/ai/sandboxes/get-started/) が必要です。
```
npx @cloudcli-ai/cloudcli@latest sandbox ~/my-project
```
Claude Code、Codex、Gemini CLI に対応。詳細は[サンドボックスのドキュメント](docker/)をご覧ください。
--- ---

View File

@@ -72,6 +72,8 @@
### 셀프 호스트 (오픈 소스) ### 셀프 호스트 (오픈 소스)
#### npm
**npx**로 즉시 CloudCLI UI를 실행하세요 (Node.js v22+ 필요): **npx**로 즉시 CloudCLI UI를 실행하세요 (Node.js v22+ 필요):
```bash ```bash
@@ -87,7 +89,17 @@ cloudcli
`http://localhost:3001`을 열면 기존 세션이 자동으로 발견됩니다. `http://localhost:3001`을 열면 기존 세션이 자동으로 발견됩니다.
자세한 구성 옵션, PM2, 원격 서버 설정 등은 **[문서 →](https://cloudcli.ai/docs)**를 참고하세요 자세한 구성 옵션, PM2, 원격 서버 설정 등은 **[문서 →](https://cloudcli.ai/docs)**를 참고하세요.
#### Docker Sandboxes (실험적)
하이퍼바이저 수준 격리로 에이전트를 샌드박스에서 실행합니다. 기본 에이전트는 Claude Code입니다. [`sbx` CLI](https://docs.docker.com/ai/sandboxes/get-started/)가 필요합니다.
```
npx @cloudcli-ai/cloudcli@latest sandbox ~/my-project
```
Claude Code, Codex, Gemini CLI를 지원합니다. 자세한 내용은 [샌드박스 문서](docker/)를 참고하세요.
--- ---

View File

@@ -76,6 +76,8 @@ The fastest way to get started — no local setup required. Get a fully managed,
### Self-Hosted (Open source) ### Self-Hosted (Open source)
#### npm
Try CloudCLI UI instantly with **npx** (requires **Node.js** v22+): Try CloudCLI UI instantly with **npx** (requires **Node.js** v22+):
``` ```
@@ -91,33 +93,41 @@ cloudcli
Open `http://localhost:3001` — all your existing sessions are discovered automatically. Open `http://localhost:3001` — all your existing sessions are discovered automatically.
Visit the **[documentation →](https://cloudcli.ai/docs)** for more full configuration options, PM2, remote server setup and more Visit the **[documentation →](https://cloudcli.ai/docs)** for full configuration options, PM2, remote server setup and more.
#### Docker Sandboxes (Experimental)
Run agents in isolated sandboxes with hypervisor-level isolation. Starts Claude Code by default. Requires the [`sbx` CLI](https://docs.docker.com/ai/sandboxes/get-started/).
```
npx @cloudcli-ai/cloudcli@latest sandbox ~/my-project
```
Supports Claude Code, Codex, and Gemini CLI. See the [sandbox docs](docker/) for setup and advanced options.
--- ---
## Which option is right for you? ## Which option is right for you?
CloudCLI UI is the open source UI layer that powers CloudCLI Cloud. You can self-host it on your own machine, or use CloudCLI Cloud which builds on top of it with a full managed cloud environment, team features, and deeper integrations. CloudCLI UI is the open source UI layer that powers CloudCLI Cloud. You can self-host it on your own machine, run it in a Docker sandbox for isolation, or use CloudCLI Cloud for a fully managed environment.
| | CloudCLI UI (Self-hosted) | CloudCLI Cloud | | | Self-Hosted (npm) | Self-Hosted (Docker Sandbox) *(Experimental)* | CloudCLI Cloud |
|---|---|---| |---|---|---|---|
| **Best for** | Developers who want a full UI for local agent sessions on their own machine | Teams and developers who want agents running in the cloud, accessible from anywhere | | **Best for** | Local agent sessions on your own machine | Isolated agents with web/mobile IDE | Teams who want agents in the cloud |
| **How you access it** | Browser via `[yourip]:port` | Browser, any IDE, REST API, n8n | | **How you access it** | Browser via `[yourip]:port` | Browser via `localhost:port` | Browser, any IDE, REST API, n8n |
| **Setup** | `npx @cloudcli-ai/cloudcli` | No setup required | | **Setup** | `npx @cloudcli-ai/cloudcli` | `npx @cloudcli-ai/cloudcli@latest sandbox ~/project` | No setup required |
| **Machine needs to stay on** | Yes | No | | **Isolation** | Runs on your host | Hypervisor-level sandbox (microVM) | Full cloud isolation |
| **Mobile access** | Any browser on your network | Any device, native app coming | | **Machine needs to stay on** | Yes | Yes | No |
| **Sessions available** | All sessions auto-discovered from `~/.claude` | All sessions within your cloud environment | | **Mobile access** | Any browser on your network | Any browser on your network | Any device, native app coming |
| **Agents supported** | Claude Code, Cursor CLI, Codex, Gemini CLI | Claude Code, Cursor CLI, Codex, Gemini CLI | | **Agents supported** | Claude Code, Cursor CLI, Codex, Gemini CLI | Claude Code, Codex, Gemini CLI | Claude Code, Cursor CLI, Codex, Gemini CLI |
| **File explorer and Git** | Yes, built into the UI | Yes, built into the UI | | **File explorer and Git** | Yes | Yes | Yes |
| **MCP configuration** | Managed via UI, synced with your local `~/.claude` config | Managed via UI | | **MCP configuration** | Synced with `~/.claude` | Managed via UI | Managed via UI |
| **IDE access** | Your local IDE | Any IDE connected to your cloud environment | | **REST API** | Yes | Yes | Yes |
| **REST API** | Yes | Yes | | **Team sharing** | No | No | Yes |
| **n8n node** | No | Yes | | **Platform cost** | Free, open source | Free, open source | Starts at $7/month |
| **Team sharing** | No | Yes |
| **Platform cost** | Free, open source | Starts at $7/month |
> Both options use your own AI subscriptions (Claude, Cursor, etc.) — CloudCLI provides the environment, not the AI. > All options use your own AI subscriptions (Claude, Cursor, etc.) — CloudCLI provides the environment, not the AI.
--- ---

View File

@@ -76,6 +76,8 @@
### Self-Hosted (Open source) ### Self-Hosted (Open source)
#### npm
Попробовать CloudCLI UI можно сразу через **npx** (требуется **Node.js** v22+): Попробовать CloudCLI UI можно сразу через **npx** (требуется **Node.js** v22+):
```bash ```bash
@@ -91,8 +93,17 @@ cloudcli
Откройте `http://localhost:3001` — все ваши существующие сессии будут обнаружены автоматически. Откройте `http://localhost:3001` — все ваши существующие сессии будут обнаружены автоматически.
Посетите **[документацию →](https://cloudcli.ai/docs)**, чтобы узнать про дополнительные варианты конфигурации, PM2, настройку удалённого сервера и многое другое Посетите **[документацию →](https://cloudcli.ai/docs)**, чтобы узнать про дополнительные варианты конфигурации, PM2, настройку удалённого сервера и многое другое.
#### Docker Sandboxes (Экспериментально)
Запускайте агентов в изолированных песочницах с гипервизорной изоляцией. По умолчанию запускается Claude Code. Требуется [`sbx` CLI](https://docs.docker.com/ai/sandboxes/get-started/).
```
npx @cloudcli-ai/cloudcli@latest sandbox ~/my-project
```
Поддерживаются Claude Code, Codex и Gemini CLI. Подробнее в [документации sandbox](docker/).
--- ---

View File

@@ -72,6 +72,8 @@
### 自托管(开源) ### 自托管(开源)
#### npm
启动 CloudCLI UI只需一行 `npx`(需要 Node.js v22+ 启动 CloudCLI UI只需一行 `npx`(需要 Node.js v22+
```bash ```bash
@@ -87,7 +89,17 @@ cloudcli
打开 `http://localhost:3001`,系统会自动发现所有现有会话。 打开 `http://localhost:3001`,系统会自动发现所有现有会话。
更多配置选项、PM2、远程服务器设置等请参阅 **[文档 →](https://cloudcli.ai/docs)** 更多配置选项、PM2、远程服务器设置等请参阅 **[文档 →](https://cloudcli.ai/docs)**
#### Docker Sandboxes实验性
在隔离的沙箱中运行代理,具有虚拟机管理程序级别的隔离。默认启动 Claude Code。需要 [`sbx` CLI](https://docs.docker.com/ai/sandboxes/get-started/)。
```
npx @cloudcli-ai/cloudcli@latest sandbox ~/my-project
```
支持 Claude Code、Codex 和 Gemini CLI。详情请参阅 [沙箱文档](docker/)。
--- ---

View File

@@ -1,89 +1,160 @@
# CloudCLI — Docker Sandbox Templates <!-- Docker Hub short description (100 chars max): -->
<!-- Sandbox templates for running AI coding agents with a web & mobile IDE (Claude Code, Codex, Gemini) -->
Run AI coding agents with a full web IDE inside [Docker Sandboxes](https://docs.docker.com/ai/sandboxes/). # Sandboxed coding agents with a web & mobile IDE (CloudCLI)
Instead of a terminal-only experience, get a browser-based interface with chat, file explorer, git panel, shell, and MCP configuration — all running safely inside an isolated sandbox. [Docker Sandbox](https://docs.docker.com/ai/sandboxes/) templates that add [CloudCLI](https://cloudcli.ai) on top of Claude Code, Codex, and Gemini CLI. You get a full web and mobile IDE accessible from any browser on any device.
## Available Templates ## Get started
| Template | Base Image | Agent | ### 1. Install the sbx CLI
|----------|-----------|-------|
| `cloudcli-ai/sandbox:claude-code` | `docker/sandbox-templates:claude-code` | Claude Code |
| `cloudcli-ai/sandbox:codex` | `docker/sandbox-templates:codex` | OpenAI Codex |
| `cloudcli-ai/sandbox:gemini` | `docker/sandbox-templates:gemini` | Gemini CLI |
## Quick Start Docker Sandboxes run agents in isolated microVMs. Install the `sbx` CLI:
### 1. Start a sandbox with the template - **macOS**: `brew install docker/tap/sbx`
- **Windows**: `winget install -h Docker.sbx`
- **Linux**: `sudo apt-get install docker-sbx`
Full instructions: [docs.docker.com/ai/sandboxes/get-started](https://docs.docker.com/ai/sandboxes/get-started/)
### 2. Store your API key
`sbx` manages credentials securely — your API key never enters the sandbox. Store it once:
```bash ```bash
sbx run --template docker.io/cloudcli-ai/sandbox:claude-code claude ~/my-project sbx login
sbx secret set -g anthropic
``` ```
### 2. Forward the UI port ### 3. Launch Claude Code
```bash ```bash
sbx ports <sandbox-name> --publish 3001:3001 npx @cloudcli-ai/cloudcli@latest sandbox ~/my-project
``` ```
### 3. Open the browser Open **http://localhost:3001**. Set a password on first visit. Start building.
``` ### Using a different agent
http://localhost:3001
Store the matching API key and pass `--agent`:
```bash
# OpenAI Codex
sbx secret set -g openai
npx @cloudcli-ai/cloudcli@latest sandbox ~/my-project --agent codex
# Gemini CLI
sbx secret set -g google
npx @cloudcli-ai/cloudcli@latest sandbox ~/my-project --agent gemini
``` ```
On first visit you'll set a password — this protects the UI if the port is ever exposed beyond localhost. ### Available templates
## What You Get | Agent | Template |
|-------|----------|
| **Claude Code** (default) | `docker.io/cloudcliai/sandbox:claude-code` |
| OpenAI Codex | `docker.io/cloudcliai/sandbox:codex` |
| Gemini CLI | `docker.io/cloudcliai/sandbox:gemini` |
- **Chat** — Rich conversation UI with markdown rendering, code blocks, and message history These are used with `--template` when running `sbx` directly (see [Advanced usage](#advanced-usage)).
- **Files** — Visual file tree with syntax-highlighted editor
- **Git** — Diff viewer, staging, branch switching, and commit — all visual ## Managing sandboxes
```bash
sbx ls # List all sandboxes
sbx stop my-project # Stop (preserves state)
sbx start my-project # Restart a stopped sandbox
sbx rm my-project # Remove everything
sbx exec my-project bash # Open a shell inside the sandbox
```
If you install CloudCLI globally (`npm install -g @cloudcli-ai/cloudcli`), you can also use:
```bash
cloudcli sandbox ls
cloudcli sandbox start my-project # Restart and re-launch web UI
cloudcli sandbox logs my-project # View server logs
```
## What you get
- **Chat** — Markdown rendering, code blocks, message history
- **Files** — File tree with syntax-highlighted editor
- **Git** — Diff viewer, staging, branch switching, commits
- **Shell** — Built-in terminal emulator - **Shell** — Built-in terminal emulator
- **MCP** — Configure Model Context Protocol servers through the UI - **MCP** — Configure Model Context Protocol servers visually
- **Mobile** — Works on tablet and phone browsers - **Mobile** — Works on tablet and phone browsers
## Building Locally Your project directory is mounted bidirectionally — edits propagate in real time, both ways.
All Dockerfiles share scripts from `shared/`. Build with the `docker/` directory as context:
```bash
# Claude Code variant
docker build -f docker/claude-code/Dockerfile -t cloudcli-sandbox:claude-code docker/
# Codex variant
docker build -f docker/codex/Dockerfile -t cloudcli-sandbox:codex docker/
# Gemini variant
docker build -f docker/gemini/Dockerfile -t cloudcli-sandbox:gemini docker/
```
## How It Works
Each template extends Docker's official sandbox base image and adds:
1. **Node.js 22** — Runtime for CloudCLI
2. **CloudCLI** — Installed globally via `npm install -g @cloudcli-ai/cloudcli`
3. **Auto-start** — The UI server starts in the background when the sandbox shell opens (port 3001)
The agent (Claude Code, Codex, or Gemini) comes from the base image. CloudCLI connects to it and provides the web interface on top.
## Configuration ## Configuration
| Environment Variable | Default | Description | Set variables at creation time with `--env`:
|---------------------|---------|-------------|
| `SERVER_PORT` | `3001` | Port for the web UI |
| `HOST` | `0.0.0.0` | Bind address |
| `DATABASE_PATH` | `~/.cloudcli/auth.db` | SQLite database location |
## Network Policies
If your sandbox uses restricted network policies, allow the UI port:
```bash ```bash
sbx policy allow network "localhost:3001" npx @cloudcli-ai/cloudcli@latest sandbox ~/my-project --env SERVER_PORT=8080
``` ```
Or inside a running sandbox:
```bash
sbx exec my-project bash -c 'echo "export SERVER_PORT=8080" >> /etc/sandbox-persistent.sh'
```
Restart CloudCLI for changes to take effect:
```bash
sbx exec my-project bash -c 'pkill -f "server/index.js"'
sbx exec -d my-project cloudcli start --port 3001
```
| Variable | Default | Description |
|----------|---------|-------------|
| `SERVER_PORT` | `3001` | Web UI port |
| `HOST` | `0.0.0.0` | Bind address (must be `0.0.0.0` for `sbx ports`) |
| `DATABASE_PATH` | `~/.cloudcli/auth.db` | SQLite database location |
## Advanced usage
For branch mode, multiple workspaces, memory limits, or the terminal agent experience, use `sbx` with the template:
```bash
# Terminal agent + web UI
sbx run --template docker.io/cloudcliai/sandbox:claude-code claude ~/my-project --name my-project
sbx ports my-project --publish 3001:3001
# Branch mode (Git worktree isolation)
sbx run --template docker.io/cloudcliai/sandbox:claude-code claude ~/my-project --branch my-feature
# Multiple workspaces
sbx run --template docker.io/cloudcliai/sandbox:claude-code claude ~/project ~/shared-libs:ro
# Pass a prompt directly
sbx run --template docker.io/cloudcliai/sandbox:claude-code claude ~/my-project -- "Fix the auth bug"
```
CloudCLI auto-starts via `.bashrc` when using `sbx run`.
Full options in the [Docker Sandboxes usage guide](https://docs.docker.com/ai/sandboxes/usage/).
## Network policies
Sandboxes restrict outbound access by default. To reach host services from inside the sandbox:
```bash
sbx policy allow network localhost:11434
# Inside the sandbox: curl http://host.docker.internal:11434
```
The web UI itself doesn't need a policy — access it via `sbx ports`.
## Links
- [CloudCLI Cloud](https://cloudcli.ai) — fully managed, no setup required
- [Documentation](https://cloudcli.ai/docs) — full configuration guide
- [Discord](https://discord.gg/buxwujPNRE) — community support
- [GitHub](https://github.com/siteboon/claudecodeui) — source code and issues
## License ## License
These templates are free and open-source under the same license as CloudCLI (AGPL-3.0-or-later). AGPL-3.0-or-later

View File

@@ -5,7 +5,7 @@ COPY shared/install-cloudcli.sh /tmp/install-cloudcli.sh
RUN chmod +x /tmp/install-cloudcli.sh && /tmp/install-cloudcli.sh RUN chmod +x /tmp/install-cloudcli.sh && /tmp/install-cloudcli.sh
USER agent USER agent
RUN npm install -g @cloudcli-ai/cloudcli RUN npm install -g @cloudcli-ai/cloudcli && cloudcli --version
COPY shared/start-cloudcli.sh /home/agent/.cloudcli-start.sh COPY --chown=agent:agent shared/start-cloudcli.sh /home/agent/.cloudcli-start.sh
RUN echo '. ~/.cloudcli-start.sh' >> /home/agent/.bashrc RUN echo '. ~/.cloudcli-start.sh' >> /home/agent/.bashrc

View File

@@ -5,7 +5,7 @@ COPY shared/install-cloudcli.sh /tmp/install-cloudcli.sh
RUN chmod +x /tmp/install-cloudcli.sh && /tmp/install-cloudcli.sh RUN chmod +x /tmp/install-cloudcli.sh && /tmp/install-cloudcli.sh
USER agent USER agent
RUN npm install -g @cloudcli-ai/cloudcli RUN npm install -g @cloudcli-ai/cloudcli && cloudcli --version
COPY shared/start-cloudcli.sh /home/agent/.cloudcli-start.sh COPY --chown=agent:agent shared/start-cloudcli.sh /home/agent/.cloudcli-start.sh
RUN echo '. ~/.cloudcli-start.sh' >> /home/agent/.bashrc RUN echo '. ~/.cloudcli-start.sh' >> /home/agent/.bashrc

View File

@@ -5,7 +5,7 @@ COPY shared/install-cloudcli.sh /tmp/install-cloudcli.sh
RUN chmod +x /tmp/install-cloudcli.sh && /tmp/install-cloudcli.sh RUN chmod +x /tmp/install-cloudcli.sh && /tmp/install-cloudcli.sh
USER agent USER agent
RUN npm install -g @cloudcli-ai/cloudcli RUN npm install -g @cloudcli-ai/cloudcli && cloudcli --version
COPY shared/start-cloudcli.sh /home/agent/.cloudcli-start.sh COPY --chown=agent:agent shared/start-cloudcli.sh /home/agent/.cloudcli-start.sh
RUN echo '. ~/.cloudcli-start.sh' >> /home/agent/.bashrc RUN echo '. ~/.cloudcli-start.sh' >> /home/agent/.bashrc

View File

@@ -1,13 +1,10 @@
#!/bin/bash #!/bin/bash
set -e set -e
# Install Node.js 22 LTS # Install build tools needed for native modules (node-pty, better-sqlite3, bcrypt)
curl -fsSL https://deb.nodesource.com/setup_22.x | bash - # Node.js is already provided by the sandbox base image
apt-get update && apt-get install -y --no-install-recommends \
# Install Node.js + build tools needed for native modules (node-pty, better-sqlite3, bcrypt) build-essential python3 python3-setuptools \
# Node.js + build tools for native modules + common dev tools
apt-get install -y --no-install-recommends \
nodejs build-essential python3 python3-setuptools \
jq ripgrep sqlite3 zip unzip tree vim-tiny jq ripgrep sqlite3 zip unzip tree vim-tiny
# Clean up apt cache to reduce image size # Clean up apt cache to reduce image size

View File

@@ -4,19 +4,14 @@
# This script is sourced from ~/.bashrc on sandbox shell open. # This script is sourced from ~/.bashrc on sandbox shell open.
if ! pgrep -f "server/index.js" > /dev/null 2>&1; then if ! pgrep -f "server/index.js" > /dev/null 2>&1; then
# Start the pre-installed version immediately
nohup cloudcli start --port 3001 > /tmp/cloudcli-ui.log 2>&1 & nohup cloudcli start --port 3001 > /tmp/cloudcli-ui.log 2>&1 &
disown disown
# Check for updates in the background (non-blocking)
nohup npm update -g @cloudcli-ai/cloudcli > /tmp/cloudcli-update.log 2>&1 &
disown
echo "" echo ""
echo " CloudCLI is starting on port 3001..." echo " CloudCLI is starting on port 3001..."
echo "" echo ""
echo " To access the web UI, forward the port:" echo " Forward the port from another terminal:"
echo " sbx ports \$(hostname) --publish 3001:3001" echo " sbx ports <sandbox-name> --publish 3001:3001"
echo "" echo ""
echo " Then open: http://localhost:3001" echo " Then open: http://localhost:3001"
echo "" echo ""

4
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{ {
"name": "@cloudcli-ai/cloudcli", "name": "@cloudcli-ai/cloudcli",
"version": "1.28.1", "version": "1.29.2",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "@cloudcli-ai/cloudcli", "name": "@cloudcli-ai/cloudcli",
"version": "1.28.1", "version": "1.29.2",
"hasInstallScript": true, "hasInstallScript": true,
"license": "AGPL-3.0-or-later", "license": "AGPL-3.0-or-later",
"dependencies": { "dependencies": {

View File

@@ -1,6 +1,6 @@
{ {
"name": "@cloudcli-ai/cloudcli", "name": "@cloudcli-ai/cloudcli",
"version": "1.28.1", "version": "1.29.2",
"description": "A web-based UI for Claude Code CLI", "description": "A web-based UI for Claude Code CLI",
"type": "module", "type": "module",
"main": "server/index.js", "main": "server/index.js",
@@ -35,7 +35,8 @@
"release": "./release.sh", "release": "./release.sh",
"prepublishOnly": "npm run build", "prepublishOnly": "npm run build",
"postinstall": "node scripts/fix-node-pty.js", "postinstall": "node scripts/fix-node-pty.js",
"prepare": "husky" "prepare": "husky",
"update:platform": "./update-platform.sh"
}, },
"keywords": [ "keywords": [
"claude code", "claude code",

View File

@@ -7,6 +7,7 @@
* Commands: * Commands:
* (no args) - Start the server (default) * (no args) - Start the server (default)
* start - Start the server * start - Start the server
* sandbox - Manage Docker sandbox environments
* status - Show configuration and data locations * status - Show configuration and data locations
* help - Show help information * help - Show help information
* version - Show version information * version - Show version information
@@ -150,6 +151,7 @@ Usage:
Commands: Commands:
start Start the CloudCLI server (default) start Start the CloudCLI server (default)
sandbox Manage Docker sandbox environments
status Show configuration and data locations status Show configuration and data locations
update Update to the latest version update Update to the latest version
help Show this help information help Show this help information
@@ -164,8 +166,7 @@ Options:
Examples: Examples:
$ cloudcli # Start with defaults $ cloudcli # Start with defaults
$ cloudcli --port 8080 # Start on port 8080 $ cloudcli --port 8080 # Start on port 8080
$ cloudcli -p 3000 # Short form for port $ cloudcli sandbox ~/my-project # Run in a Docker sandbox
$ cloudcli start --port 4000 # Explicit start command
$ cloudcli status # Show configuration $ cloudcli status # Show configuration
Environment Variables: Environment Variables:
@@ -244,6 +245,353 @@ async function updatePackage() {
} }
} }
// ── Sandbox command ─────────────────────────────────────────
const SANDBOX_TEMPLATES = {
claude: 'docker.io/cloudcliai/sandbox:claude-code',
codex: 'docker.io/cloudcliai/sandbox:codex',
gemini: 'docker.io/cloudcliai/sandbox:gemini',
};
const SANDBOX_SECRETS = {
claude: 'anthropic',
codex: 'openai',
gemini: 'google',
};
function parseSandboxArgs(args) {
const result = {
subcommand: null,
workspace: null,
agent: 'claude',
name: null,
port: 3001,
template: null,
env: [],
};
const subcommands = ['ls', 'stop', 'start', 'rm', 'logs', 'help'];
for (let i = 0; i < args.length; i++) {
const arg = args[i];
if (i === 0 && subcommands.includes(arg)) {
result.subcommand = arg;
} else if (arg === '--agent' || arg === '-a') {
result.agent = args[++i];
} else if (arg === '--name' || arg === '-n') {
result.name = args[++i];
} else if (arg === '--port') {
result.port = parseInt(args[++i], 10);
} else if (arg === '--template' || arg === '-t') {
result.template = args[++i];
} else if (arg === '--env' || arg === '-e') {
result.env.push(args[++i]);
} else if (!arg.startsWith('-')) {
if (!result.subcommand) {
result.workspace = arg;
} else {
result.name = arg; // for stop/start/rm/logs <name>
}
}
}
// Default subcommand based on what we got
if (!result.subcommand) {
result.subcommand = 'create';
}
// Derive name from workspace path if not set
if (!result.name && result.workspace) {
result.name = path.basename(path.resolve(result.workspace.replace(/^~/, os.homedir())));
}
// Default template from agent
if (!result.template) {
result.template = SANDBOX_TEMPLATES[result.agent] || SANDBOX_TEMPLATES.claude;
}
return result;
}
function showSandboxHelp() {
console.log(`
${c.bright('CloudCLI Sandbox')} — Run CloudCLI inside Docker Sandboxes
Usage:
cloudcli sandbox <workspace> Create and start a sandbox
cloudcli sandbox <subcommand> [name] Manage sandboxes
Subcommands:
${c.bright('(default)')} Create a sandbox and start the web UI
${c.bright('ls')} List all sandboxes
${c.bright('start')} Restart a stopped sandbox and re-launch the web UI
${c.bright('stop')} Stop a sandbox (preserves state)
${c.bright('rm')} Remove a sandbox
${c.bright('logs')} Show CloudCLI server logs
${c.bright('help')} Show this help
Options:
-a, --agent <agent> Agent to use: claude, codex, gemini (default: claude)
-n, --name <name> Sandbox name (default: derived from workspace folder)
-t, --template <image> Custom template image
-e, --env <KEY=VALUE> Set environment variable (repeatable)
--port <port> Host port for the web UI (default: 3001)
Examples:
$ cloudcli sandbox ~/my-project
$ cloudcli sandbox ~/my-project --agent codex --port 8080
$ cloudcli sandbox ~/my-project --env SERVER_PORT=8080 --env HOST=0.0.0.0
$ cloudcli sandbox ls
$ cloudcli sandbox stop my-project
$ cloudcli sandbox start my-project
$ cloudcli sandbox rm my-project
Prerequisites:
1. Install sbx CLI: https://docs.docker.com/ai/sandboxes/get-started/
2. Authenticate and store your API key:
sbx login
sbx secret set -g anthropic # for Claude
sbx secret set -g openai # for Codex
sbx secret set -g google # for Gemini
Advanced usage:
For branch mode, multiple workspaces, memory limits, network policies,
or passing prompts to the agent, use sbx directly with the template:
sbx run --template docker.io/cloudcliai/sandbox:claude-code claude ~/my-project --branch my-feature
sbx run --template docker.io/cloudcliai/sandbox:claude-code claude ~/project ~/libs:ro --memory 8g
Full Docker Sandboxes docs: https://docs.docker.com/ai/sandboxes/usage/
`);
}
async function sandboxCommand(args) {
const { execFileSync, spawn: spawnProcess } = await import('child_process');
// Safe execution — uses execFileSync (no shell) to prevent injection
const sbx = (subcmd, opts = {}) => {
const result = execFileSync('sbx', subcmd, {
encoding: 'utf8',
stdio: opts.inherit ? 'inherit' : 'pipe',
});
return result || '';
};
const opts = parseSandboxArgs(args);
if (opts.subcommand === 'help') {
showSandboxHelp();
return;
}
// Validate name (alphanumeric, hyphens, underscores only)
if (opts.name && !/^[\w-]+$/.test(opts.name)) {
console.error(`\n${c.error('❌')} Invalid sandbox name: ${opts.name}`);
console.log(` Names may only contain letters, numbers, hyphens, and underscores.\n`);
process.exit(1);
}
// Check sbx is installed
try {
sbx(['version']);
} catch {
console.error(`\n${c.error('❌')} ${c.bright('sbx')} CLI not found.\n`);
console.log(` Install it from: ${c.info('https://docs.docker.com/ai/sandboxes/get-started/')}`);
console.log(` Then run: ${c.bright('sbx login')}`);
console.log(` And store your API key: ${c.bright('sbx secret set -g anthropic')}\n`);
process.exit(1);
}
switch (opts.subcommand) {
case 'ls':
sbx(['ls'], { inherit: true });
break;
case 'stop':
if (!opts.name) {
console.error(`\n${c.error('❌')} Sandbox name required: cloudcli sandbox stop <name>\n`);
process.exit(1);
}
sbx(['stop', opts.name], { inherit: true });
break;
case 'rm':
if (!opts.name) {
console.error(`\n${c.error('❌')} Sandbox name required: cloudcli sandbox rm <name>\n`);
process.exit(1);
}
sbx(['rm', opts.name], { inherit: true });
break;
case 'logs':
if (!opts.name) {
console.error(`\n${c.error('❌')} Sandbox name required: cloudcli sandbox logs <name>\n`);
process.exit(1);
}
try {
sbx(['exec', opts.name, 'bash', '-c', 'cat /tmp/cloudcli-ui.log'], { inherit: true });
} catch (e) {
console.error(`\n${c.error('❌')} Could not read logs: ${e.message || 'Is the sandbox running?'}\n`);
}
break;
case 'start': {
if (!opts.name) {
console.error(`\n${c.error('❌')} Sandbox name required: cloudcli sandbox start <name>\n`);
process.exit(1);
}
console.log(`\n${c.info('▶')} Starting sandbox ${c.bright(opts.name)}...`);
const restartRun = spawnProcess('sbx', ['run', opts.name], {
detached: true,
stdio: ['ignore', 'ignore', 'ignore'],
});
restartRun.unref();
await new Promise(resolve => setTimeout(resolve, 5000));
console.log(`${c.info('▶')} Launching CloudCLI web server...`);
sbx(['exec', opts.name, 'bash', '-c', 'cloudcli start --port 3001 &']);
console.log(`${c.info('▶')} Forwarding port ${opts.port} → 3001...`);
try {
sbx(['ports', opts.name, '--publish', `${opts.port}:3001`]);
} catch (e) {
const msg = e.stdout || e.stderr || e.message || '';
if (msg.includes('address already in use')) {
const altPort = opts.port + 1;
console.log(`${c.warn('⚠')} Port ${opts.port} in use, trying ${altPort}...`);
try {
sbx(['ports', opts.name, '--publish', `${altPort}:3001`]);
opts.port = altPort;
} catch {
console.error(`${c.error('❌')} Ports ${opts.port} and ${altPort} both in use. Use --port to specify a free port.`);
process.exit(1);
}
} else {
throw e;
}
}
console.log(`\n${c.ok('✔')} ${c.bright('CloudCLI is ready!')}`);
console.log(` ${c.info('→')} ${c.bright(`http://localhost:${opts.port}`)}\n`);
break;
}
case 'create': {
if (!opts.workspace) {
console.error(`\n${c.error('❌')} Workspace path required: cloudcli sandbox <path>\n`);
console.log(` Example: ${c.bright('cloudcli sandbox ~/my-project')}\n`);
process.exit(1);
}
const workspace = opts.workspace.startsWith('~')
? opts.workspace.replace(/^~/, os.homedir())
: path.resolve(opts.workspace);
if (!fs.existsSync(workspace)) {
console.error(`\n${c.error('❌')} Workspace path not found: ${c.dim(workspace)}\n`);
process.exit(1);
}
const secret = SANDBOX_SECRETS[opts.agent] || 'anthropic';
// Check if the required secret is stored
try {
const secretList = sbx(['secret', 'ls']);
if (!secretList.includes(secret)) {
console.error(`\n${c.error('❌')} No ${c.bright(secret)} API key found.\n`);
console.log(` Run: ${c.bright(`sbx secret set -g ${secret}`)}\n`);
process.exit(1);
}
} catch { /* sbx secret ls not available, skip check */ }
console.log(`\n${c.bright('CloudCLI Sandbox')}`);
console.log(c.dim('─'.repeat(50)));
console.log(` Agent: ${c.info(opts.agent)} ${c.dim(`(${secret} credentials)`)}`);
console.log(` Workspace: ${c.dim(workspace)}`);
console.log(` Name: ${c.dim(opts.name)}`);
console.log(` Template: ${c.dim(opts.template)}`);
console.log(` Port: ${c.dim(String(opts.port))}`);
if (opts.env.length > 0) {
console.log(` Env: ${c.dim(opts.env.join(', '))}`);
}
console.log(c.dim('─'.repeat(50)));
// Step 1: Launch sandbox with sbx run in background.
// sbx run creates the sandbox (or reconnects) AND holds an active session,
// which prevents the sandbox from auto-stopping.
console.log(`\n${c.info('▶')} Creating sandbox ${c.bright(opts.name)}...`);
const bgRun = spawnProcess('sbx', [
'run', '--template', opts.template, '--name', opts.name, opts.agent, workspace,
], {
detached: true,
stdio: ['ignore', 'ignore', 'ignore'],
});
bgRun.unref();
// Wait for sandbox to be ready
await new Promise(resolve => setTimeout(resolve, 5000));
// Step 2: Inject environment variables
if (opts.env.length > 0) {
console.log(`${c.info('▶')} Setting environment variables...`);
const exports = opts.env
.filter(e => /^\w+=.+$/.test(e))
.map(e => `export ${e}`)
.join('\n');
if (exports) {
sbx(['exec', opts.name, 'bash', '-c', `echo '${exports}' >> /etc/sandbox-persistent.sh`]);
}
const invalid = opts.env.filter(e => !/^\w+=.+$/.test(e));
if (invalid.length > 0) {
console.log(`${c.warn('⚠')} Skipped invalid env vars: ${invalid.join(', ')} (expected KEY=VALUE)`);
}
}
// Step 3: Start CloudCLI inside the sandbox
console.log(`${c.info('▶')} Launching CloudCLI web server...`);
sbx(['exec', opts.name, 'bash', '-c', 'cloudcli start --port 3001 &']);
// Step 4: Forward port
console.log(`${c.info('▶')} Forwarding port ${opts.port} → 3001...`);
try {
sbx(['ports', opts.name, '--publish', `${opts.port}:3001`]);
} catch (e) {
const msg = e.stdout || e.stderr || e.message || '';
if (msg.includes('address already in use')) {
const altPort = opts.port + 1;
console.log(`${c.warn('⚠')} Port ${opts.port} in use, trying ${altPort}...`);
try {
sbx(['ports', opts.name, '--publish', `${altPort}:3001`]);
opts.port = altPort;
} catch {
console.error(`${c.error('❌')} Ports ${opts.port} and ${altPort} both in use. Use --port to specify a free port.`);
process.exit(1);
}
} else {
throw e;
}
}
// Done
console.log(`\n${c.ok('✔')} ${c.bright('CloudCLI is ready!')}`);
console.log(` ${c.info('→')} Open ${c.bright(`http://localhost:${opts.port}`)}`);
console.log(`\n${c.dim(' Manage with:')}`);
console.log(` ${c.dim('$')} sbx ls`);
console.log(` ${c.dim('$')} sbx stop ${opts.name}`);
console.log(` ${c.dim('$')} sbx start ${opts.name}`);
console.log(` ${c.dim('$')} sbx rm ${opts.name}`);
console.log(`\n${c.dim(' Or install globally:')} npm install -g @cloudcli-ai/cloudcli\n`);
break;
}
default:
showSandboxHelp();
}
}
// ── Server ──────────────────────────────────────────────────
// Start the server // Start the server
async function startServer() { async function startServer() {
// Check for updates silently on startup // Check for updates silently on startup
@@ -274,6 +622,10 @@ function parseArgs(args) {
parsed.command = 'version'; parsed.command = 'version';
} else if (!arg.startsWith('-')) { } else if (!arg.startsWith('-')) {
parsed.command = arg; parsed.command = arg;
if (arg === 'sandbox') {
parsed.remainingArgs = args.slice(i + 1);
break;
}
} }
} }
@@ -283,7 +635,7 @@ function parseArgs(args) {
// Main CLI handler // Main CLI handler
async function main() { async function main() {
const args = process.argv.slice(2); const args = process.argv.slice(2);
const { command, options } = parseArgs(args); const { command, options, remainingArgs } = parseArgs(args);
// Apply CLI options to environment variables // Apply CLI options to environment variables
if (options.serverPort) { if (options.serverPort) {
@@ -299,6 +651,9 @@ async function main() {
case 'start': case 'start':
await startServer(); await startServer();
break; break;
case 'sandbox':
await sandboxCommand(remainingArgs || []);
break;
case 'status': case 'status':
case 'info': case 'info':
showStatus(); showStatus();

View File

@@ -435,13 +435,20 @@ app.post('/api/system/update', authenticateToken, async (req, res) => {
console.log('Starting system update from directory:', projectRoot); console.log('Starting system update from directory:', projectRoot);
// Run the update command based on install mode // Platform deployments use their own update workflow from the project root.
const updateCommand = installMode === 'git' const updateCommand = IS_PLATFORM
? 'git checkout main && git pull && npm install' // In platform, husky and dev dependencies are not needed
: 'npm install -g @cloudcli-ai/cloudcli@latest'; ? 'npm run update:platform'
: installMode === 'git'
? 'git checkout main && git pull && npm install'
: 'npm install -g @cloudcli-ai/cloudcli@latest';
const updateCwd = IS_PLATFORM || installMode === 'git'
? projectRoot
: os.homedir();
const child = spawn('sh', ['-c', updateCommand], { const child = spawn('sh', ['-c', updateCommand], {
cwd: installMode === 'git' ? projectRoot : os.homedir(), cwd: updateCwd,
env: process.env env: process.env
}); });

View File

@@ -1,4 +1,5 @@
import { useState, useRef, useEffect } from 'react'; import { useState, useRef, useEffect, useCallback, type CSSProperties } from 'react';
import { createPortal } from 'react-dom';
import { Brain, X } from 'lucide-react'; import { Brain, X } from 'lucide-react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { thinkingModes } from '../../constants/thinkingModes'; import { thinkingModes } from '../../constants/thinkingModes';
@@ -12,6 +13,11 @@ type ThinkingModeSelectorProps = {
function ThinkingModeSelector({ selectedMode, onModeChange, onClose, className = '' }: ThinkingModeSelectorProps) { function ThinkingModeSelector({ selectedMode, onModeChange, onClose, className = '' }: ThinkingModeSelectorProps) {
const { t } = useTranslation('chat'); const { t } = useTranslation('chat');
const [isOpen, setIsOpen] = useState(false);
const containerRef = useRef<HTMLDivElement>(null);
const triggerRef = useRef<HTMLButtonElement>(null);
const dropdownRef = useRef<HTMLDivElement>(null);
const [dropdownStyle, setDropdownStyle] = useState<CSSProperties | null>(null);
// Mapping from mode ID to translation key // Mapping from mode ID to translation key
const modeKeyMap: Record<string, string> = { const modeKeyMap: Record<string, string> = {
@@ -29,50 +35,143 @@ function ThinkingModeSelector({ selectedMode, onModeChange, onClose, className =
}; };
}); });
const [isOpen, setIsOpen] = useState(false); const closeDropdown = useCallback(() => {
const dropdownRef = useRef<HTMLDivElement>(null); setIsOpen(false);
onClose?.();
}, [onClose]);
const updateDropdownPosition = useCallback(() => {
const trigger = triggerRef.current;
const dropdown = dropdownRef.current;
if (!trigger || !dropdown || typeof window === 'undefined') {
return;
}
const triggerRect = trigger.getBoundingClientRect();
const viewportPadding = window.innerWidth < 640 ? 12 : 16;
const spacing = 8;
const width = Math.min(window.innerWidth - viewportPadding * 2, window.innerWidth < 640 ? 320 : 256);
let left = triggerRect.left + triggerRect.width / 2 - width / 2;
left = Math.max(viewportPadding, Math.min(left, window.innerWidth - width - viewportPadding));
const measuredHeight = dropdown.offsetHeight || 0;
const spaceBelow = window.innerHeight - triggerRect.bottom - spacing - viewportPadding;
const spaceAbove = triggerRect.top - spacing - viewportPadding;
const openBelow = spaceBelow >= Math.min(measuredHeight || 320, 320) || spaceBelow >= spaceAbove;
const availableHeight = Math.min(
window.innerHeight - viewportPadding * 2,
Math.max(180, openBelow ? spaceBelow : spaceAbove),
);
const panelHeight = Math.min(measuredHeight || availableHeight, availableHeight);
const top = openBelow
? Math.min(triggerRect.bottom + spacing, window.innerHeight - viewportPadding - panelHeight)
: Math.max(viewportPadding, triggerRect.top - spacing - panelHeight);
setDropdownStyle({
position: 'fixed',
top,
left,
width,
maxHeight: availableHeight,
zIndex: 80,
});
}, []);
useEffect(() => { useEffect(() => {
const handleClickOutside = (event: MouseEvent) => { if (!isOpen) {
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) { setDropdownStyle(null);
setIsOpen(false); return;
if (onClose) onClose(); }
const rafId = window.requestAnimationFrame(updateDropdownPosition);
const handleViewportChange = () => updateDropdownPosition();
window.addEventListener('resize', handleViewportChange);
window.addEventListener('scroll', handleViewportChange, true);
return () => {
window.cancelAnimationFrame(rafId);
window.removeEventListener('resize', handleViewportChange);
window.removeEventListener('scroll', handleViewportChange, true);
};
}, [isOpen, updateDropdownPosition]);
useEffect(() => {
if (!isOpen) {
return;
}
const handlePointerDown = (event: PointerEvent) => {
const target = event.target;
if (!(target instanceof Node)) {
return;
}
if (containerRef.current?.contains(target) || dropdownRef.current?.contains(target)) {
return;
}
closeDropdown();
};
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
closeDropdown();
} }
}; };
document.addEventListener('mousedown', handleClickOutside); document.addEventListener('pointerdown', handlePointerDown, true);
return () => document.removeEventListener('mousedown', handleClickOutside); document.addEventListener('keydown', handleKeyDown);
}, [onClose]);
return () => {
document.removeEventListener('pointerdown', handlePointerDown, true);
document.removeEventListener('keydown', handleKeyDown);
};
}, [isOpen, closeDropdown]);
const currentMode = translatedModes.find(mode => mode.id === selectedMode) || translatedModes[0]; const currentMode = translatedModes.find(mode => mode.id === selectedMode) || translatedModes[0];
const IconComponent = currentMode.icon || Brain; const IconComponent = currentMode.icon || Brain;
return ( return (
<div className={`relative ${className}`} ref={dropdownRef}> <div className={`relative ${className}`} ref={containerRef}>
<button <button
ref={triggerRef}
type="button" type="button"
onClick={() => setIsOpen(!isOpen)} onClick={() => {
if (isOpen) {
closeDropdown();
return;
}
setIsOpen(true);
}}
className={`flex h-10 w-10 items-center justify-center rounded-full transition-all duration-200 sm:h-10 sm:w-10 ${selectedMode === 'none' className={`flex h-10 w-10 items-center justify-center rounded-full transition-all duration-200 sm:h-10 sm:w-10 ${selectedMode === 'none'
? 'bg-gray-100 hover:bg-gray-200 dark:bg-gray-700 dark:hover:bg-gray-600' ? 'bg-gray-100 hover:bg-gray-200 dark:bg-gray-700 dark:hover:bg-gray-600'
: 'bg-blue-100 hover:bg-blue-200 dark:bg-blue-900 dark:hover:bg-blue-800' : 'bg-blue-100 hover:bg-blue-200 dark:bg-blue-900 dark:hover:bg-blue-800'
}`} }`}
title={t('thinkingMode.buttonTitle', { mode: currentMode.name })} title={t('thinkingMode.buttonTitle', { mode: currentMode.name })}
aria-haspopup="dialog"
aria-expanded={isOpen}
> >
<IconComponent className={`h-5 w-5 ${currentMode.color}`} /> <IconComponent className={`h-5 w-5 ${currentMode.color}`} />
</button> </button>
{isOpen && ( {isOpen && typeof document !== 'undefined' && createPortal(
<div className="absolute bottom-full right-0 mb-2 w-64 overflow-hidden rounded-lg border border-gray-200 bg-white shadow-xl dark:border-gray-700 dark:bg-gray-800"> <div
ref={dropdownRef}
style={dropdownStyle || { position: 'fixed', top: 0, left: 0, visibility: 'hidden' }}
className="flex flex-col overflow-hidden rounded-xl border border-gray-200 bg-white shadow-xl dark:border-gray-700 dark:bg-gray-800"
role="dialog"
aria-modal="false"
>
<div className="border-b border-gray-200 p-3 dark:border-gray-700"> <div className="border-b border-gray-200 p-3 dark:border-gray-700">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<h3 className="text-sm font-semibold text-gray-900 dark:text-white"> <h3 className="text-sm font-semibold text-gray-900 dark:text-white">
{t('thinkingMode.selector.title')} {t('thinkingMode.selector.title')}
</h3> </h3>
<button <button
onClick={() => { type="button"
setIsOpen(false); onClick={closeDropdown}
if (onClose) onClose();
}}
className="rounded p-1 hover:bg-gray-100 dark:hover:bg-gray-700" className="rounded p-1 hover:bg-gray-100 dark:hover:bg-gray-700"
> >
<X className="h-4 w-4 text-gray-500" /> <X className="h-4 w-4 text-gray-500" />
@@ -83,7 +182,7 @@ function ThinkingModeSelector({ selectedMode, onModeChange, onClose, className =
</p> </p>
</div> </div>
<div className="py-1"> <div className="min-h-0 overflow-y-auto py-1">
{translatedModes.map((mode) => { {translatedModes.map((mode) => {
const ModeIcon = mode.icon; const ModeIcon = mode.icon;
const isSelected = mode.id === selectedMode; const isSelected = mode.id === selectedMode;
@@ -91,10 +190,10 @@ function ThinkingModeSelector({ selectedMode, onModeChange, onClose, className =
return ( return (
<button <button
key={mode.id} key={mode.id}
type="button"
onClick={() => { onClick={() => {
onModeChange(mode.id); onModeChange(mode.id);
setIsOpen(false); closeDropdown();
if (onClose) onClose();
}} }}
className={`w-full px-4 py-3 text-left transition-colors hover:bg-gray-50 dark:hover:bg-gray-700 ${isSelected ? 'bg-gray-50 dark:bg-gray-700' : '' className={`w-full px-4 py-3 text-left transition-colors hover:bg-gray-50 dark:hover:bg-gray-700 ${isSelected ? 'bg-gray-50 dark:bg-gray-700' : ''
}`} }`}
@@ -135,10 +234,11 @@ function ThinkingModeSelector({ selectedMode, onModeChange, onClose, className =
<strong>Tip:</strong> {t('thinkingMode.selector.tip')} <strong>Tip:</strong> {t('thinkingMode.selector.tip')}
</p> </p>
</div> </div>
</div> </div>,
document.body
)} )}
</div> </div>
); );
} }
export default ThinkingModeSelector; export default ThinkingModeSelector;

View File

@@ -4,6 +4,7 @@ import { authenticatedFetch } from "../../../utils/api";
import { ReleaseInfo } from "../../../types/sharedTypes"; import { ReleaseInfo } from "../../../types/sharedTypes";
import { copyTextToClipboard } from "../../../utils/clipboard"; import { copyTextToClipboard } from "../../../utils/clipboard";
import type { InstallMode } from "../../../hooks/useVersionCheck"; import type { InstallMode } from "../../../hooks/useVersionCheck";
import { IS_PLATFORM } from "../../../constants/config";
interface VersionUpgradeModalProps { interface VersionUpgradeModalProps {
isOpen: boolean; isOpen: boolean;
@@ -25,7 +26,9 @@ export function VersionUpgradeModal({
const { t } = useTranslation('common'); const { t } = useTranslation('common');
const upgradeCommand = installMode === 'npm' const upgradeCommand = installMode === 'npm'
? t('versionUpdate.npmUpgradeCommand') ? t('versionUpdate.npmUpgradeCommand')
: 'git checkout main && git pull && npm install'; : IS_PLATFORM
? 'npm run update:platform'
: 'git checkout main && git pull && npm install';
const [isUpdating, setIsUpdating] = useState(false); const [isUpdating, setIsUpdating] = useState(false);
const [updateOutput, setUpdateOutput] = useState(''); const [updateOutput, setUpdateOutput] = useState('');
const [updateError, setUpdateError] = useState(''); const [updateError, setUpdateError] = useState('');
@@ -46,7 +49,8 @@ export function VersionUpgradeModal({
if (response.ok) { if (response.ok) {
setUpdateOutput(prev => prev + data.output + '\n'); setUpdateOutput(prev => prev + data.output + '\n');
setUpdateOutput(prev => prev + '\n✅ Update completed successfully!\n'); setUpdateOutput(prev => prev + '\n✅ Update completed successfully!\n');
setUpdateOutput(prev => prev + 'Please restart the server to apply changes.\n'); const text = IS_PLATFORM ? 'Please refresh the page after 5 seconds to load the new version. If that doesn\'t work, RESTART the environment.' : 'Please restart the server to apply changes.';
setUpdateOutput(prev => prev + text + '\n');
} else { } else {
setUpdateError(data.error || 'Update failed'); setUpdateError(data.error || 'Update failed');
setUpdateOutput(prev => prev + '\n❌ Update failed: ' + (data.error || 'Unknown error') + '\n'); setUpdateOutput(prev => prev + '\n❌ Update failed: ' + (data.error || 'Unknown error') + '\n');