diff --git a/CHANGELOG.md b/CHANGELOG.md index f1f49d9f..149443d3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,32 @@ 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) ### New Features diff --git a/README.de.md b/README.de.md index 4e163781..461f59a8 100644 --- a/README.de.md +++ b/README.de.md @@ -76,6 +76,8 @@ Der schnellste Einstieg – keine lokale Einrichtung erforderlich. Erhalte eine ### Self-Hosted (Open Source) +#### npm + CloudCLI UI sofort mit **npx** ausprobieren (erfordert **Node.js** v22+): ```bash @@ -93,6 +95,15 @@ cloudcli 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/). --- diff --git a/README.ja.md b/README.ja.md index eada6dec..a3c7d04b 100644 --- a/README.ja.md +++ b/README.ja.md @@ -72,6 +72,8 @@ ### セルフホスト(オープンソース) +#### npm + **npx** で今すぐ CloudCLI UI を試せます(**Node.js** v22+ が必要): ```bash @@ -89,6 +91,15 @@ cloudcli より詳細な設定オプション、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/)をご覧ください。 --- diff --git a/README.ko.md b/README.ko.md index ed8ddd9f..f0c17471 100644 --- a/README.ko.md +++ b/README.ko.md @@ -72,6 +72,8 @@ ### 셀프 호스트 (오픈 소스) +#### npm + **npx**로 즉시 CloudCLI UI를 실행하세요 (Node.js v22+ 필요): ```bash @@ -87,7 +89,17 @@ cloudcli `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/)를 참고하세요. --- diff --git a/README.md b/README.md index 751f1659..b6f2cfb7 100644 --- a/README.md +++ b/README.md @@ -76,6 +76,8 @@ The fastest way to get started — no local setup required. Get a fully managed, ### Self-Hosted (Open source) +#### npm + 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. -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? -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 | -|---|---|---| -| **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 | -| **How you access it** | Browser via `[yourip]:port` | Browser, any IDE, REST API, n8n | -| **Setup** | `npx @cloudcli-ai/cloudcli` | No setup required | -| **Machine needs to stay on** | Yes | No | -| **Mobile access** | Any browser on your network | Any device, native app coming | -| **Sessions available** | All sessions auto-discovered from `~/.claude` | All sessions within your cloud environment | -| **Agents supported** | Claude Code, Cursor CLI, Codex, Gemini CLI | Claude Code, Cursor CLI, Codex, Gemini CLI | -| **File explorer and Git** | Yes, built into the UI | Yes, built into the UI | -| **MCP configuration** | Managed via UI, synced with your local `~/.claude` config | Managed via UI | -| **IDE access** | Your local IDE | Any IDE connected to your cloud environment | -| **REST API** | Yes | Yes | -| **n8n node** | No | Yes | -| **Team sharing** | No | Yes | -| **Platform cost** | Free, open source | Starts at $7/month | +| | Self-Hosted (npm) | Self-Hosted (Docker Sandbox) *(Experimental)* | CloudCLI Cloud | +|---|---|---|---| +| **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 via `localhost:port` | Browser, any IDE, REST API, n8n | +| **Setup** | `npx @cloudcli-ai/cloudcli` | `npx @cloudcli-ai/cloudcli@latest sandbox ~/project` | No setup required | +| **Isolation** | Runs on your host | Hypervisor-level sandbox (microVM) | Full cloud isolation | +| **Machine needs to stay on** | Yes | Yes | No | +| **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, Codex, Gemini CLI | Claude Code, Cursor CLI, Codex, Gemini CLI | +| **File explorer and Git** | Yes | Yes | Yes | +| **MCP configuration** | Synced with `~/.claude` | Managed via UI | Managed via UI | +| **REST API** | Yes | Yes | Yes | +| **Team sharing** | No | No | Yes | +| **Platform cost** | Free, open source | 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. --- diff --git a/README.ru.md b/README.ru.md index c7561ba6..a5907b38 100644 --- a/README.ru.md +++ b/README.ru.md @@ -76,6 +76,8 @@ ### Self-Hosted (Open source) +#### npm + Попробовать CloudCLI UI можно сразу через **npx** (требуется **Node.js** v22+): ```bash @@ -91,8 +93,17 @@ cloudcli Откройте `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/). --- diff --git a/README.zh-CN.md b/README.zh-CN.md index 690af7d8..daf9a1a9 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -72,6 +72,8 @@ ### 自托管(开源) +#### npm + 启动 CloudCLI UI,只需一行 `npx`(需要 Node.js v22+): ```bash @@ -87,7 +89,17 @@ cloudcli 打开 `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/)。 --- diff --git a/docker/README.md b/docker/README.md index 780fcf93..134ebad0 100644 --- a/docker/README.md +++ b/docker/README.md @@ -1,89 +1,160 @@ -# CloudCLI — Docker Sandbox Templates + + -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 | -|----------|-----------|-------| -| `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 | +### 1. Install the sbx 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 -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 -sbx ports --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. -``` -http://localhost:3001 +### Using a different agent + +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 -- **Files** — Visual file tree with syntax-highlighted editor -- **Git** — Diff viewer, staging, branch switching, and commit — all visual +These are used with `--template` when running `sbx` directly (see [Advanced usage](#advanced-usage)). + +## 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 -- **MCP** — Configure Model Context Protocol servers through the UI +- **MCP** — Configure Model Context Protocol servers visually - **Mobile** — Works on tablet and phone browsers -## Building Locally - -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. +Your project directory is mounted bidirectionally — edits propagate in real time, both ways. ## Configuration -| Environment Variable | Default | Description | -|---------------------|---------|-------------| -| `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: +Set variables at creation time with `--env`: ```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 -These templates are free and open-source under the same license as CloudCLI (AGPL-3.0-or-later). +AGPL-3.0-or-later diff --git a/docker/claude-code/Dockerfile b/docker/claude-code/Dockerfile index ed04cf6b..8d108a80 100644 --- a/docker/claude-code/Dockerfile +++ b/docker/claude-code/Dockerfile @@ -5,7 +5,7 @@ COPY shared/install-cloudcli.sh /tmp/install-cloudcli.sh RUN chmod +x /tmp/install-cloudcli.sh && /tmp/install-cloudcli.sh 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 diff --git a/docker/codex/Dockerfile b/docker/codex/Dockerfile index 9948d0af..c1bc1807 100644 --- a/docker/codex/Dockerfile +++ b/docker/codex/Dockerfile @@ -5,7 +5,7 @@ COPY shared/install-cloudcli.sh /tmp/install-cloudcli.sh RUN chmod +x /tmp/install-cloudcli.sh && /tmp/install-cloudcli.sh 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 diff --git a/docker/gemini/Dockerfile b/docker/gemini/Dockerfile index ec7209f0..dc06db69 100644 --- a/docker/gemini/Dockerfile +++ b/docker/gemini/Dockerfile @@ -5,7 +5,7 @@ COPY shared/install-cloudcli.sh /tmp/install-cloudcli.sh RUN chmod +x /tmp/install-cloudcli.sh && /tmp/install-cloudcli.sh 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 diff --git a/docker/shared/install-cloudcli.sh b/docker/shared/install-cloudcli.sh index 0390383e..4b43fe22 100644 --- a/docker/shared/install-cloudcli.sh +++ b/docker/shared/install-cloudcli.sh @@ -1,13 +1,10 @@ #!/bin/bash set -e -# Install Node.js 22 LTS -curl -fsSL https://deb.nodesource.com/setup_22.x | bash - - -# Install Node.js + build tools needed for native modules (node-pty, better-sqlite3, bcrypt) -# Node.js + build tools for native modules + common dev tools -apt-get install -y --no-install-recommends \ - nodejs build-essential python3 python3-setuptools \ +# Install build tools needed for native modules (node-pty, better-sqlite3, bcrypt) +# Node.js is already provided by the sandbox base image +apt-get update && apt-get install -y --no-install-recommends \ + build-essential python3 python3-setuptools \ jq ripgrep sqlite3 zip unzip tree vim-tiny # Clean up apt cache to reduce image size diff --git a/docker/shared/start-cloudcli.sh b/docker/shared/start-cloudcli.sh index 145c35c5..82b2fdc2 100644 --- a/docker/shared/start-cloudcli.sh +++ b/docker/shared/start-cloudcli.sh @@ -4,19 +4,14 @@ # This script is sourced from ~/.bashrc on sandbox shell open. 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 & 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 " CloudCLI is starting on port 3001..." echo "" - echo " To access the web UI, forward the port:" - echo " sbx ports \$(hostname) --publish 3001:3001" + echo " Forward the port from another terminal:" + echo " sbx ports --publish 3001:3001" echo "" echo " Then open: http://localhost:3001" echo "" diff --git a/package-lock.json b/package-lock.json index ce4258f5..10d6676e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@cloudcli-ai/cloudcli", - "version": "1.28.1", + "version": "1.29.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@cloudcli-ai/cloudcli", - "version": "1.28.1", + "version": "1.29.2", "hasInstallScript": true, "license": "AGPL-3.0-or-later", "dependencies": { diff --git a/package.json b/package.json index 9c352644..6f22ab0e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@cloudcli-ai/cloudcli", - "version": "1.28.1", + "version": "1.29.2", "description": "A web-based UI for Claude Code CLI", "type": "module", "main": "server/index.js", @@ -41,7 +41,8 @@ "release": "./release.sh", "prepublishOnly": "npm run build", "postinstall": "node scripts/fix-node-pty.js", - "prepare": "husky" + "prepare": "husky", + "update:platform": "./update-platform.sh" }, "keywords": [ "claude code", diff --git a/server/cli.js b/server/cli.js index 2f909d5d..7551afc4 100755 --- a/server/cli.js +++ b/server/cli.js @@ -7,6 +7,7 @@ * Commands: * (no args) - Start the server (default) * start - Start the server + * sandbox - Manage Docker sandbox environments * status - Show configuration and data locations * help - Show help information * version - Show version information @@ -154,6 +155,7 @@ Usage: Commands: start Start the CloudCLI server (default) + sandbox Manage Docker sandbox environments status Show configuration and data locations update Update to the latest version help Show this help information @@ -168,8 +170,7 @@ Options: Examples: $ cloudcli # Start with defaults $ cloudcli --port 8080 # Start on port 8080 - $ cloudcli -p 3000 # Short form for port - $ cloudcli start --port 4000 # Explicit start command + $ cloudcli sandbox ~/my-project # Run in a Docker sandbox $ cloudcli status # Show configuration Environment Variables: @@ -248,6 +249,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 + } + } + } + + // 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 Create and start a sandbox + cloudcli sandbox [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 to use: claude, codex, gemini (default: claude) + -n, --name Sandbox name (default: derived from workspace folder) + -t, --template Custom template image + -e, --env Set environment variable (repeatable) + --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 \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 \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 \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 \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 \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 async function startServer() { // Check for updates silently on startup @@ -278,6 +626,10 @@ function parseArgs(args) { parsed.command = 'version'; } else if (!arg.startsWith('-')) { parsed.command = arg; + if (arg === 'sandbox') { + parsed.remainingArgs = args.slice(i + 1); + break; + } } } @@ -287,7 +639,7 @@ function parseArgs(args) { // Main CLI handler async function main() { const args = process.argv.slice(2); - const { command, options } = parseArgs(args); + const { command, options, remainingArgs } = parseArgs(args); // Apply CLI options to environment variables if (options.serverPort) { @@ -303,6 +655,9 @@ async function main() { case 'start': await startServer(); break; + case 'sandbox': + await sandboxCommand(remainingArgs || []); + break; case 'status': case 'info': showStatus(); diff --git a/server/index.js b/server/index.js index 0b80b1f1..3e5e657d 100755 --- a/server/index.js +++ b/server/index.js @@ -435,13 +435,20 @@ app.post('/api/system/update', authenticateToken, async (req, res) => { console.log('Starting system update from directory:', projectRoot); - // Run the update command based on install mode - const updateCommand = installMode === 'git' - ? 'git checkout main && git pull && npm install' - : 'npm install -g @cloudcli-ai/cloudcli@latest'; + // Platform deployments use their own update workflow from the project root. + const updateCommand = IS_PLATFORM + // In platform, husky and dev dependencies are not needed + ? '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], { - cwd: installMode === 'git' ? projectRoot : os.homedir(), + cwd: updateCwd, env: process.env }); diff --git a/server/routes/cursor.js b/server/routes/cursor.js index 02269e45..7e395023 100644 --- a/server/routes/cursor.js +++ b/server/routes/cursor.js @@ -2,7 +2,6 @@ import express from 'express'; import { promises as fs } from 'fs'; import path from 'path'; import os from 'os'; -import { spawn } from 'child_process'; import sqlite3 from 'sqlite3'; import { open } from 'sqlite'; import crypto from 'crypto'; @@ -578,221 +577,4 @@ router.get('/sessions', async (req, res) => { }); } }); - -// GET /api/cursor/sessions/:sessionId - Get specific Cursor session from SQLite -router.get('/sessions/:sessionId', async (req, res) => { - try { - const { sessionId } = req.params; - const { projectPath } = req.query; - - // Calculate cwdID hash for the project path - const cwdId = crypto.createHash('md5').update(projectPath || process.cwd()).digest('hex'); - const storeDbPath = path.join(os.homedir(), '.cursor', 'chats', cwdId, sessionId, 'store.db'); - - - // Open SQLite database - const db = await open({ - filename: storeDbPath, - driver: sqlite3.Database, - mode: sqlite3.OPEN_READONLY - }); - - // Get all blobs to build the DAG structure - const allBlobs = await db.all(` - SELECT rowid, id, data FROM blobs - `); - - // Build the DAG structure from parent-child relationships - const blobMap = new Map(); // id -> blob data - const parentRefs = new Map(); // blob id -> [parent blob ids] - const childRefs = new Map(); // blob id -> [child blob ids] - const jsonBlobs = []; // Clean JSON messages - - for (const blob of allBlobs) { - blobMap.set(blob.id, blob); - - // Check if this is a JSON blob (actual message) or protobuf (DAG structure) - if (blob.data && blob.data[0] === 0x7B) { // Starts with '{' - JSON blob - try { - const parsed = JSON.parse(blob.data.toString('utf8')); - jsonBlobs.push({ ...blob, parsed }); - } catch (e) { - console.log('Failed to parse JSON blob:', blob.rowid); - } - } else if (blob.data) { // Protobuf blob - extract parent references - const parents = []; - let i = 0; - - // Scan for parent references (0x0A 0x20 followed by 32-byte hash) - while (i < blob.data.length - 33) { - if (blob.data[i] === 0x0A && blob.data[i+1] === 0x20) { - const parentHash = blob.data.slice(i+2, i+34).toString('hex'); - if (blobMap.has(parentHash)) { - parents.push(parentHash); - } - i += 34; - } else { - i++; - } - } - - if (parents.length > 0) { - parentRefs.set(blob.id, parents); - // Update child references - for (const parentId of parents) { - if (!childRefs.has(parentId)) { - childRefs.set(parentId, []); - } - childRefs.get(parentId).push(blob.id); - } - } - } - } - - // Perform topological sort to get chronological order - const visited = new Set(); - const sorted = []; - - // DFS-based topological sort - function visit(nodeId) { - if (visited.has(nodeId)) return; - visited.add(nodeId); - - // Visit all parents first (dependencies) - const parents = parentRefs.get(nodeId) || []; - for (const parentId of parents) { - visit(parentId); - } - - // Add this node after all its parents - const blob = blobMap.get(nodeId); - if (blob) { - sorted.push(blob); - } - } - - // Start with nodes that have no parents (roots) - for (const blob of allBlobs) { - if (!parentRefs.has(blob.id)) { - visit(blob.id); - } - } - - // Visit any remaining nodes (disconnected components) - for (const blob of allBlobs) { - visit(blob.id); - } - - // Now extract JSON messages in the order they appear in the sorted DAG - const messageOrder = new Map(); // JSON blob id -> order index - let orderIndex = 0; - - for (const blob of sorted) { - // Check if this blob references any JSON messages - if (blob.data && blob.data[0] !== 0x7B) { // Protobuf blob - // Look for JSON blob references - for (const jsonBlob of jsonBlobs) { - try { - const jsonIdBytes = Buffer.from(jsonBlob.id, 'hex'); - if (blob.data.includes(jsonIdBytes)) { - if (!messageOrder.has(jsonBlob.id)) { - messageOrder.set(jsonBlob.id, orderIndex++); - } - } - } catch (e) { - // Skip if can't convert ID - } - } - } - } - - // Sort JSON blobs by their appearance order in the DAG - const sortedJsonBlobs = jsonBlobs.sort((a, b) => { - const orderA = messageOrder.get(a.id) ?? Number.MAX_SAFE_INTEGER; - const orderB = messageOrder.get(b.id) ?? Number.MAX_SAFE_INTEGER; - if (orderA !== orderB) return orderA - orderB; - // Fallback to rowid if not in order map - return a.rowid - b.rowid; - }); - - // Use sorted JSON blobs - const blobs = sortedJsonBlobs.map((blob, idx) => ({ - ...blob, - sequence_num: idx + 1, - original_rowid: blob.rowid - })); - - // Get metadata from meta table - const metaRows = await db.all(` - SELECT key, value FROM meta - `); - - // Parse metadata - let metadata = {}; - for (const row of metaRows) { - if (row.value) { - try { - // Try to decode as hex-encoded JSON - const hexMatch = row.value.toString().match(/^[0-9a-fA-F]+$/); - if (hexMatch) { - const jsonStr = Buffer.from(row.value, 'hex').toString('utf8'); - metadata[row.key] = JSON.parse(jsonStr); - } else { - metadata[row.key] = row.value.toString(); - } - } catch (e) { - metadata[row.key] = row.value.toString(); - } - } - } - - // Extract messages from sorted JSON blobs - const messages = []; - for (const blob of blobs) { - try { - // We already parsed JSON blobs earlier - const parsed = blob.parsed; - - if (parsed) { - // Filter out ONLY system messages at the server level - // Check both direct role and nested message.role - const role = parsed?.role || parsed?.message?.role; - if (role === 'system') { - continue; // Skip only system messages - } - messages.push({ - id: blob.id, - sequence: blob.sequence_num, - rowid: blob.original_rowid, - content: parsed - }); - } - } catch (e) { - // Skip blobs that cause errors - console.log(`Skipping blob ${blob.id}: ${e.message}`); - } - } - - await db.close(); - - res.json({ - success: true, - session: { - id: sessionId, - projectPath: projectPath, - messages: messages, - metadata: metadata, - cwdId: cwdId - } - }); - - } catch (error) { - console.error('Error reading Cursor session:', error); - res.status(500).json({ - error: 'Failed to read Cursor session', - details: error.message - }); - } -}); - export default router; \ No newline at end of file diff --git a/shared/modelConstants.js b/shared/modelConstants.js index 514a1772..389fb633 100644 --- a/shared/modelConstants.js +++ b/shared/modelConstants.js @@ -18,9 +18,10 @@ export const CLAUDE_MODELS = { { value: "haiku", label: "Haiku" }, { value: "opusplan", label: "Opus Plan" }, { value: "sonnet[1m]", label: "Sonnet [1M]" }, + { value: "opus[1m]", label: "Opus [1M]" }, ], - DEFAULT: "sonnet", + DEFAULT: "opus", }; /** @@ -58,6 +59,7 @@ export const CURSOR_MODELS = { export const CODEX_MODELS = { OPTIONS: [ { value: "gpt-5.4", label: "GPT-5.4" }, + { value: "gpt-5.4-mini", label: "GPT-5.4 mini" }, { value: "gpt-5.3-codex", label: "GPT-5.3 Codex" }, { value: "gpt-5.2-codex", label: "GPT-5.2 Codex" }, { value: "gpt-5.2", label: "GPT-5.2" }, @@ -88,5 +90,5 @@ export const GEMINI_MODELS = { }, ], - DEFAULT: "gemini-2.5-flash", + DEFAULT: "gemini-3.1-pro-preview", }; diff --git a/src/components/chat/view/subcomponents/ThinkingModeSelector.tsx b/src/components/chat/view/subcomponents/ThinkingModeSelector.tsx index 9a92386d..2b8d8062 100644 --- a/src/components/chat/view/subcomponents/ThinkingModeSelector.tsx +++ b/src/components/chat/view/subcomponents/ThinkingModeSelector.tsx @@ -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 { useTranslation } from 'react-i18next'; import { thinkingModes } from '../../constants/thinkingModes'; @@ -12,6 +13,11 @@ type ThinkingModeSelectorProps = { function ThinkingModeSelector({ selectedMode, onModeChange, onClose, className = '' }: ThinkingModeSelectorProps) { const { t } = useTranslation('chat'); + const [isOpen, setIsOpen] = useState(false); + const containerRef = useRef(null); + const triggerRef = useRef(null); + const dropdownRef = useRef(null); + const [dropdownStyle, setDropdownStyle] = useState(null); // Mapping from mode ID to translation key const modeKeyMap: Record = { @@ -29,50 +35,143 @@ function ThinkingModeSelector({ selectedMode, onModeChange, onClose, className = }; }); - const [isOpen, setIsOpen] = useState(false); - const dropdownRef = useRef(null); + const closeDropdown = useCallback(() => { + 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(() => { - const handleClickOutside = (event: MouseEvent) => { - if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) { - setIsOpen(false); - if (onClose) onClose(); + if (!isOpen) { + setDropdownStyle(null); + return; + } + + 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); - return () => document.removeEventListener('mousedown', handleClickOutside); - }, [onClose]); + document.addEventListener('pointerdown', handlePointerDown, true); + document.addEventListener('keydown', handleKeyDown); + + return () => { + document.removeEventListener('pointerdown', handlePointerDown, true); + document.removeEventListener('keydown', handleKeyDown); + }; + }, [isOpen, closeDropdown]); const currentMode = translatedModes.find(mode => mode.id === selectedMode) || translatedModes[0]; const IconComponent = currentMode.icon || Brain; return ( -
+
- {isOpen && ( -
+ {isOpen && typeof document !== 'undefined' && createPortal( +

{t('thinkingMode.selector.title')}

-
+
{translatedModes.map((mode) => { const ModeIcon = mode.icon; const isSelected = mode.id === selectedMode; @@ -91,10 +190,10 @@ function ThinkingModeSelector({ selectedMode, onModeChange, onClose, className = return (
-
+
, + document.body )}
); } -export default ThinkingModeSelector; \ No newline at end of file +export default ThinkingModeSelector; diff --git a/src/components/version-upgrade/view/VersionUpgradeModal.tsx b/src/components/version-upgrade/view/VersionUpgradeModal.tsx index d4570ee5..cf6b94b7 100644 --- a/src/components/version-upgrade/view/VersionUpgradeModal.tsx +++ b/src/components/version-upgrade/view/VersionUpgradeModal.tsx @@ -1,9 +1,10 @@ -import { useCallback, useState } from "react"; +import { useCallback, useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; import { authenticatedFetch } from "../../../utils/api"; import { ReleaseInfo } from "../../../types/sharedTypes"; import { copyTextToClipboard } from "../../../utils/clipboard"; import type { InstallMode } from "../../../hooks/useVersionCheck"; +import { IS_PLATFORM } from "../../../constants/config"; interface VersionUpgradeModalProps { isOpen: boolean; @@ -14,6 +15,8 @@ interface VersionUpgradeModalProps { installMode: InstallMode; } +const RELOAD_COUNTDOWN_START = 30; + export function VersionUpgradeModal({ isOpen, onClose, @@ -25,14 +28,36 @@ export function VersionUpgradeModal({ const { t } = useTranslation('common'); const upgradeCommand = installMode === 'npm' ? 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 [updateOutput, setUpdateOutput] = useState(''); const [updateError, setUpdateError] = useState(''); + const [reloadCountdown, setReloadCountdown] = useState(null); + + useEffect(() => { + if (!IS_PLATFORM || reloadCountdown === null || reloadCountdown <= 0) { + return; + } + + const timeoutId = window.setTimeout(() => { + setReloadCountdown((previousCountdown) => { + if (previousCountdown === null) { + return null; + } + + return Math.max(previousCountdown - 1, 0); + }); + }, 1000); + + return () => window.clearTimeout(timeoutId); + }, [reloadCountdown]); const handleUpdateNow = useCallback(async () => { setIsUpdating(true); setUpdateOutput('Starting update...\n'); + setReloadCountdown(IS_PLATFORM ? RELOAD_COUNTDOWN_START : null); setUpdateError(''); try { @@ -46,7 +71,7 @@ export function VersionUpgradeModal({ if (response.ok) { setUpdateOutput(prev => prev + data.output + '\n'); setUpdateOutput(prev => prev + '\n✅ Update completed successfully!\n'); - setUpdateOutput(prev => prev + 'Please restart the server to apply changes.\n'); + setUpdateOutput(prev => prev + 'Please restart the server to apply changes.' + '\n'); } else { setUpdateError(data.error || 'Update failed'); setUpdateOutput(prev => prev + '\n❌ Update failed: ' + (data.error || 'Unknown error') + '\n'); @@ -143,6 +168,13 @@ export function VersionUpgradeModal({
{updateOutput}
+ {IS_PLATFORM && reloadCountdown !== null && ( +
+ {reloadCountdown === 0 + ? 'Refresh the page now. If that doesn\'t work, RESTART the environment.' + : `Refresh the page in ${reloadCountdown} ${reloadCountdown === 1 ? 'second' : 'seconds'}. If that doesn\'t work, RESTART the environment.`} +
+ )} {updateError && (
{updateError}