diff --git a/.opencode/tool/github-pr-search.txt b/.opencode/tool/github-pr-search.txt index 28d8643f13..1b658e71c4 100644 --- a/.opencode/tool/github-pr-search.txt +++ b/.opencode/tool/github-pr-search.txt @@ -1,6 +1,6 @@ Use this tool to search GitHub pull requests by title and description. -This tool searches PRs in the sst/opencode repository and returns LLM-friendly results including: +This tool searches PRs in the anomalyco/opencode repository and returns LLM-friendly results including: - PR number and title - Author - State (open/closed/merged) diff --git a/README.ar.md b/README.ar.md index 865fecb22b..beb44589e6 100644 --- a/README.ar.md +++ b/README.ar.md @@ -35,7 +35,8 @@ Türkçe | Українська | বাংলা | - Ελληνικά + Ελληνικά | + Tiếng Việt

[![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](https://opencode.ai) diff --git a/README.bn.md b/README.bn.md index 24c083e79e..c7abc7346a 100644 --- a/README.bn.md +++ b/README.bn.md @@ -35,7 +35,8 @@ Türkçe | Українська | বাংলা | - Ελληνικά + Ελληνικά | + Tiếng Việt

[![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](https://opencode.ai) diff --git a/README.br.md b/README.br.md index f7e82fa09d..6d1de21562 100644 --- a/README.br.md +++ b/README.br.md @@ -27,6 +27,7 @@ 日本語 | Polski | Русский | + Bosanski | العربية | Norsk | Português (Brasil) | @@ -34,7 +35,8 @@ Türkçe | Українська | বাংলা | - Ελληνικά + Ελληνικά | + Tiếng Việt

[![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](https://opencode.ai) diff --git a/README.bs.md b/README.bs.md index 5bba870859..2cff8e0279 100644 --- a/README.bs.md +++ b/README.bs.md @@ -35,7 +35,8 @@ Türkçe | Українська | বাংলা | - Ελληνικά + Ελληνικά | + Tiếng Việt

[![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](https://opencode.ai) diff --git a/README.da.md b/README.da.md index d1e686d7d7..ac522f29c4 100644 --- a/README.da.md +++ b/README.da.md @@ -27,6 +27,7 @@ 日本語 | Polski | Русский | + Bosanski | العربية | Norsk | Português (Brasil) | @@ -34,7 +35,8 @@ Türkçe | Українська | বাংলা | - Ελληνικά + Ελληνικά | + Tiếng Việt

[![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](https://opencode.ai) diff --git a/README.de.md b/README.de.md index 7a3572324a..87a670f3fc 100644 --- a/README.de.md +++ b/README.de.md @@ -27,6 +27,7 @@ 日本語 | Polski | Русский | + Bosanski | العربية | Norsk | Português (Brasil) | @@ -34,7 +35,8 @@ Türkçe | Українська | বাংলা | - Ελληνικά + Ελληνικά | + Tiếng Việt

[![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](https://opencode.ai) diff --git a/README.es.md b/README.es.md index b454182328..9e456af1c0 100644 --- a/README.es.md +++ b/README.es.md @@ -27,6 +27,7 @@ 日本語 | Polski | Русский | + Bosanski | العربية | Norsk | Português (Brasil) | @@ -34,7 +35,8 @@ Türkçe | Українська | বাংলা | - Ελληνικά + Ελληνικά | + Tiếng Việt

[![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](https://opencode.ai) diff --git a/README.fr.md b/README.fr.md index 02e66e5e87..c1fca23376 100644 --- a/README.fr.md +++ b/README.fr.md @@ -27,6 +27,7 @@ 日本語 | Polski | Русский | + Bosanski | العربية | Norsk | Português (Brasil) | @@ -34,7 +35,8 @@ Türkçe | Українська | বাংলা | - Ελληνικά + Ελληνικά | + Tiếng Việt

[![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](https://opencode.ai) diff --git a/README.gr.md b/README.gr.md index 976eab5cc3..2b2c2679d8 100644 --- a/README.gr.md +++ b/README.gr.md @@ -35,7 +35,8 @@ Türkçe | Українська | বাংলা | - Ελληνικά + Ελληνικά | + Tiếng Việt

[![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](https://opencode.ai) diff --git a/README.it.md b/README.it.md index b0d7247415..3e516a9027 100644 --- a/README.it.md +++ b/README.it.md @@ -27,6 +27,7 @@ 日本語 | Polski | Русский | + Bosanski | العربية | Norsk | Português (Brasil) | @@ -34,7 +35,8 @@ Türkçe | Українська | বাংলা | - Ελληνικά + Ελληνικά | + Tiếng Việt

[![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](https://opencode.ai) diff --git a/README.ja.md b/README.ja.md index e381fbc603..144dc7b6f8 100644 --- a/README.ja.md +++ b/README.ja.md @@ -27,6 +27,7 @@ 日本語 | Polski | Русский | + Bosanski | العربية | Norsk | Português (Brasil) | @@ -34,7 +35,8 @@ Türkçe | Українська | বাংলা | - Ελληνικά + Ελληνικά | + Tiếng Việt

[![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](https://opencode.ai) diff --git a/README.ko.md b/README.ko.md index 63b9fb4091..32defc0a5e 100644 --- a/README.ko.md +++ b/README.ko.md @@ -27,6 +27,7 @@ 日本語 | Polski | Русский | + Bosanski | العربية | Norsk | Português (Brasil) | @@ -34,7 +35,8 @@ Türkçe | Українська | বাংলা | - Ελληνικά + Ελληνικά | + Tiếng Việt

[![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](https://opencode.ai) diff --git a/README.md b/README.md index 8d92450374..79ccf8b349 100644 --- a/README.md +++ b/README.md @@ -35,7 +35,8 @@ Türkçe | Українська | বাংলা | - Ελληνικά + Ελληνικά | + Tiếng Việt

[![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](https://opencode.ai) diff --git a/README.no.md b/README.no.md index 1ccefaa760..c3348286b2 100644 --- a/README.no.md +++ b/README.no.md @@ -27,6 +27,7 @@ 日本語 | Polski | Русский | + Bosanski | العربية | Norsk | Português (Brasil) | @@ -34,7 +35,8 @@ Türkçe | Українська | বাংলা | - Ελληνικά + Ελληνικά | + Tiếng Việt

[![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](https://opencode.ai) diff --git a/README.pl.md b/README.pl.md index 0b246d5d5a..4c5a076656 100644 --- a/README.pl.md +++ b/README.pl.md @@ -27,6 +27,7 @@ 日本語 | Polski | Русский | + Bosanski | العربية | Norsk | Português (Brasil) | @@ -34,7 +35,8 @@ Türkçe | Українська | বাংলা | - Ελληνικά + Ελληνικά | + Tiếng Việt

[![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](https://opencode.ai) diff --git a/README.ru.md b/README.ru.md index ff30d380fd..e507be70e6 100644 --- a/README.ru.md +++ b/README.ru.md @@ -27,6 +27,7 @@ 日本語 | Polski | Русский | + Bosanski | العربية | Norsk | Português (Brasil) | @@ -34,7 +35,8 @@ Türkçe | Українська | বাংলা | - Ελληνικά + Ελληνικά | + Tiếng Việt

[![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](https://opencode.ai) diff --git a/README.th.md b/README.th.md index 6a9a956a88..4a4ea62c95 100644 --- a/README.th.md +++ b/README.th.md @@ -27,6 +27,7 @@ 日本語 | Polski | Русский | + Bosanski | العربية | Norsk | Português (Brasil) | @@ -34,7 +35,8 @@ Türkçe | Українська | বাংলা | - Ελληνικά + Ελληνικά | + Tiếng Việt

[![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](https://opencode.ai) diff --git a/README.tr.md b/README.tr.md index 9deedfb3c6..e88b40f875 100644 --- a/README.tr.md +++ b/README.tr.md @@ -27,6 +27,7 @@ 日本語 | Polski | Русский | + Bosanski | العربية | Norsk | Português (Brasil) | @@ -34,7 +35,8 @@ Türkçe | Українська | বাংলা | - Ελληνικά + Ελληνικά | + Tiếng Việt

[![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](https://opencode.ai) diff --git a/README.uk.md b/README.uk.md index dfd8fa8d75..a1a0259b6d 100644 --- a/README.uk.md +++ b/README.uk.md @@ -35,7 +35,8 @@ Türkçe | Українська | বাংলা | - Ελληνικά + Ελληνικά | + Tiếng Việt

[![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](https://opencode.ai) diff --git a/README.vi.md b/README.vi.md new file mode 100644 index 0000000000..0932c50f78 --- /dev/null +++ b/README.vi.md @@ -0,0 +1,141 @@ +

+ + + + + OpenCode logo + + +

+

Trợ lý lập trình AI mã nguồn mở.

+

+ Discord + npm + Build status +

+ +

+ English | + 简体中文 | + 繁體中文 | + 한국어 | + Deutsch | + Español | + Français | + Italiano | + Dansk | + 日本語 | + Polski | + Русский | + Bosanski | + العربية | + Norsk | + Português (Brasil) | + ไทย | + Türkçe | + Українська | + বাংলা | + Ελληνικά | + Tiếng Việt +

+ +[![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](https://opencode.ai) + +--- + +### Cài đặt + +```bash +# YOLO +curl -fsSL https://opencode.ai/install | bash + +# Các trình quản lý gói (Package managers) +npm i -g opencode-ai@latest # hoặc bun/pnpm/yarn +scoop install opencode # Windows +choco install opencode # Windows +brew install anomalyco/tap/opencode # macOS và Linux (khuyên dùng, luôn cập nhật) +brew install opencode # macOS và Linux (công thức brew chính thức, ít cập nhật hơn) +sudo pacman -S opencode # Arch Linux (Bản ổn định) +paru -S opencode-bin # Arch Linux (Bản mới nhất từ AUR) +mise use -g opencode # Mọi hệ điều hành +nix run nixpkgs#opencode # hoặc github:anomalyco/opencode cho nhánh dev mới nhất +``` + +> [!TIP] +> Hãy xóa các phiên bản cũ hơn 0.1.x trước khi cài đặt. + +### Ứng dụng Desktop (BETA) + +OpenCode cũng có sẵn dưới dạng ứng dụng desktop. Tải trực tiếp từ [trang releases](https://github.com/anomalyco/opencode/releases) hoặc [opencode.ai/download](https://opencode.ai/download). + +| Nền tảng | Tải xuống | +| --------------------- | ------------------------------------- | +| macOS (Apple Silicon) | `opencode-desktop-darwin-aarch64.dmg` | +| macOS (Intel) | `opencode-desktop-darwin-x64.dmg` | +| Windows | `opencode-desktop-windows-x64.exe` | +| Linux | `.deb`, `.rpm`, hoặc AppImage | + +```bash +# macOS (Homebrew) +brew install --cask opencode-desktop +# Windows (Scoop) +scoop bucket add extras; scoop install extras/opencode-desktop +``` + +#### Thư mục cài đặt + +Tập lệnh cài đặt tuân theo thứ tự ưu tiên sau cho đường dẫn cài đặt: + +1. `$OPENCODE_INSTALL_DIR` - Thư mục cài đặt tùy chỉnh +2. `$XDG_BIN_DIR` - Đường dẫn tuân thủ XDG Base Directory Specification +3. `$HOME/bin` - Thư mục nhị phân tiêu chuẩn của người dùng (nếu tồn tại hoặc có thể tạo) +4. `$HOME/.opencode/bin` - Mặc định dự phòng + +```bash +# Ví dụ +OPENCODE_INSTALL_DIR=/usr/local/bin curl -fsSL https://opencode.ai/install | bash +XDG_BIN_DIR=$HOME/.local/bin curl -fsSL https://opencode.ai/install | bash +``` + +### Agents (Đại diện) + +OpenCode bao gồm hai agent được tích hợp sẵn mà bạn có thể chuyển đổi bằng phím `Tab`. + +- **build** - Agent mặc định, có toàn quyền truy cập cho công việc lập trình +- **plan** - Agent chỉ đọc dùng để phân tích và khám phá mã nguồn + - Mặc định từ chối việc chỉnh sửa tệp + - Hỏi quyền trước khi chạy các lệnh bash + - Lý tưởng để khám phá các codebase lạ hoặc lên kế hoạch thay đổi + +Ngoài ra còn có một subagent **general** dùng cho các tìm kiếm phức tạp và tác vụ nhiều bước. +Agent này được sử dụng nội bộ và có thể gọi bằng cách dùng `@general` trong tin nhắn. + +Tìm hiểu thêm về [agents](https://opencode.ai/docs/agents). + +### Tài liệu + +Để biết thêm thông tin về cách cấu hình OpenCode, [**hãy truy cập tài liệu của chúng tôi**](https://opencode.ai/docs). + +### Đóng góp + +Nếu bạn muốn đóng góp cho OpenCode, vui lòng đọc [tài liệu hướng dẫn đóng góp](./CONTRIBUTING.md) trước khi gửi pull request. + +### Xây dựng trên nền tảng OpenCode + +Nếu bạn đang làm việc trên một dự án liên quan đến OpenCode và sử dụng "opencode" như một phần của tên dự án, ví dụ "opencode-dashboard" hoặc "opencode-mobile", vui lòng thêm một ghi chú vào README của bạn để làm rõ rằng dự án đó không được xây dựng bởi đội ngũ OpenCode và không liên kết với chúng tôi dưới bất kỳ hình thức nào. + +### Các câu hỏi thường gặp (FAQ) + +#### OpenCode khác biệt thế nào so với Claude Code? + +Về mặt tính năng, nó rất giống Claude Code. Dưới đây là những điểm khác biệt chính: + +- 100% mã nguồn mở +- Không bị ràng buộc với bất kỳ nhà cung cấp nào. Mặc dù chúng tôi khuyên dùng các mô hình được cung cấp qua [OpenCode Zen](https://opencode.ai/zen), OpenCode có thể được sử dụng với Claude, OpenAI, Google, hoặc thậm chí các mô hình chạy cục bộ. Khi các mô hình phát triển, khoảng cách giữa chúng sẽ thu hẹp lại và giá cả sẽ giảm, vì vậy việc không phụ thuộc vào nhà cung cấp là rất quan trọng. +- Hỗ trợ LSP ngay từ đầu +- Tập trung vào TUI (Giao diện người dùng dòng lệnh). OpenCode được xây dựng bởi những người dùng neovim và đội ngũ tạo ra [terminal.shop](https://terminal.shop); chúng tôi sẽ đẩy giới hạn của những gì có thể làm được trên terminal lên mức tối đa. +- Kiến trúc client/server. Chẳng hạn, điều này cho phép OpenCode chạy trên máy tính của bạn trong khi bạn điều khiển nó từ xa qua một ứng dụng di động, nghĩa là frontend TUI chỉ là một trong những client có thể dùng. + +--- + +**Tham gia cộng đồng của chúng tôi** [Discord](https://discord.gg/opencode) | [X.com](https://x.com/opencode) diff --git a/README.zh.md b/README.zh.md index 9a1e1b2fb6..b11d9857c9 100644 --- a/README.zh.md +++ b/README.zh.md @@ -27,6 +27,7 @@ 日本語 | Polski | Русский | + Bosanski | العربية | Norsk | Português (Brasil) | @@ -34,7 +35,8 @@ Türkçe | Українська | বাংলা | - Ελληνικά + Ελληνικά | + Tiếng Việt

[![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](https://opencode.ai) diff --git a/README.zht.md b/README.zht.md index 238f11289f..573ca85ab4 100644 --- a/README.zht.md +++ b/README.zht.md @@ -27,6 +27,7 @@ 日本語 | Polski | Русский | + Bosanski | العربية | Norsk | Português (Brasil) | @@ -34,7 +35,8 @@ Türkçe | Українська | বাংলা | - Ελληνικά + Ελληνικά | + Tiếng Việt

[![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](https://opencode.ai) diff --git a/bun.lock b/bun.lock index 6539faa830..f19cacbe3d 100644 --- a/bun.lock +++ b/bun.lock @@ -26,7 +26,7 @@ }, "packages/app": { "name": "@opencode-ai/app", - "version": "1.2.19", + "version": "1.2.21", "dependencies": { "@kobalte/core": "catalog:", "@opencode-ai/sdk": "workspace:*", @@ -76,7 +76,7 @@ }, "packages/console/app": { "name": "@opencode-ai/console-app", - "version": "1.2.19", + "version": "1.2.21", "dependencies": { "@cloudflare/vite-plugin": "1.15.2", "@ibm/plex": "6.4.1", @@ -110,7 +110,7 @@ }, "packages/console/core": { "name": "@opencode-ai/console-core", - "version": "1.2.19", + "version": "1.2.21", "dependencies": { "@aws-sdk/client-sts": "3.782.0", "@jsx-email/render": "1.1.1", @@ -137,7 +137,7 @@ }, "packages/console/function": { "name": "@opencode-ai/console-function", - "version": "1.2.19", + "version": "1.2.21", "dependencies": { "@ai-sdk/anthropic": "2.0.0", "@ai-sdk/openai": "2.0.2", @@ -161,7 +161,7 @@ }, "packages/console/mail": { "name": "@opencode-ai/console-mail", - "version": "1.2.19", + "version": "1.2.21", "dependencies": { "@jsx-email/all": "2.2.3", "@jsx-email/cli": "1.4.3", @@ -185,7 +185,7 @@ }, "packages/desktop": { "name": "@opencode-ai/desktop", - "version": "1.2.19", + "version": "1.2.21", "dependencies": { "@opencode-ai/app": "workspace:*", "@opencode-ai/ui": "workspace:*", @@ -218,7 +218,7 @@ }, "packages/desktop-electron": { "name": "@opencode-ai/desktop-electron", - "version": "1.2.19", + "version": "1.2.21", "dependencies": { "@opencode-ai/app": "workspace:*", "@opencode-ai/ui": "workspace:*", @@ -248,7 +248,7 @@ }, "packages/enterprise": { "name": "@opencode-ai/enterprise", - "version": "1.2.19", + "version": "1.2.21", "dependencies": { "@opencode-ai/ui": "workspace:*", "@opencode-ai/util": "workspace:*", @@ -277,7 +277,7 @@ }, "packages/function": { "name": "@opencode-ai/function", - "version": "1.2.19", + "version": "1.2.21", "dependencies": { "@octokit/auth-app": "8.0.1", "@octokit/rest": "catalog:", @@ -293,7 +293,7 @@ }, "packages/opencode": { "name": "opencode", - "version": "1.2.19", + "version": "1.2.21", "bin": { "opencode": "./bin/opencode", }, @@ -351,7 +351,7 @@ "clipboardy": "4.0.0", "decimal.js": "10.5.0", "diff": "catalog:", - "drizzle-orm": "1.0.0-beta.12-a5629fb", + "drizzle-orm": "1.0.0-beta.16-ea816b6", "fuzzysort": "3.1.0", "glob": "13.0.5", "google-auth-library": "10.5.0", @@ -399,8 +399,8 @@ "@types/which": "3.0.4", "@types/yargs": "17.0.33", "@typescript/native-preview": "catalog:", - "drizzle-kit": "1.0.0-beta.12-a5629fb", - "drizzle-orm": "1.0.0-beta.12-a5629fb", + "drizzle-kit": "1.0.0-beta.16-ea816b6", + "drizzle-orm": "1.0.0-beta.16-ea816b6", "typescript": "catalog:", "vscode-languageserver-types": "3.17.5", "why-is-node-running": "3.2.2", @@ -409,7 +409,7 @@ }, "packages/plugin": { "name": "@opencode-ai/plugin", - "version": "1.2.19", + "version": "1.2.21", "dependencies": { "@opencode-ai/sdk": "workspace:*", "zod": "catalog:", @@ -429,7 +429,7 @@ }, "packages/sdk/js": { "name": "@opencode-ai/sdk", - "version": "1.2.19", + "version": "1.2.21", "devDependencies": { "@hey-api/openapi-ts": "0.90.10", "@tsconfig/node22": "catalog:", @@ -440,7 +440,7 @@ }, "packages/slack": { "name": "@opencode-ai/slack", - "version": "1.2.19", + "version": "1.2.21", "dependencies": { "@opencode-ai/sdk": "workspace:*", "@slack/bolt": "^3.17.1", @@ -475,7 +475,7 @@ }, "packages/ui": { "name": "@opencode-ai/ui", - "version": "1.2.19", + "version": "1.2.21", "dependencies": { "@kobalte/core": "catalog:", "@opencode-ai/sdk": "workspace:*", @@ -521,7 +521,7 @@ }, "packages/util": { "name": "@opencode-ai/util", - "version": "1.2.19", + "version": "1.2.21", "dependencies": { "zod": "catalog:", }, @@ -532,7 +532,7 @@ }, "packages/web": { "name": "@opencode-ai/web", - "version": "1.2.19", + "version": "1.2.21", "dependencies": { "@astrojs/cloudflare": "12.6.3", "@astrojs/markdown-remark": "6.3.1", @@ -601,8 +601,8 @@ "ai": "5.0.124", "diff": "8.0.2", "dompurify": "3.3.1", - "drizzle-kit": "1.0.0-beta.12-a5629fb", - "drizzle-orm": "1.0.0-beta.12-a5629fb", + "drizzle-kit": "1.0.0-beta.16-ea816b6", + "drizzle-orm": "1.0.0-beta.16-ea816b6", "fuzzysort": "3.1.0", "hono": "4.10.7", "hono-openapi": "1.1.2", @@ -2684,9 +2684,9 @@ "dotenv-expand": ["dotenv-expand@11.0.7", "", { "dependencies": { "dotenv": "^16.4.5" } }, "sha512-zIHwmZPRshsCdpMDyVsqGmgyP0yT8GAgXUnkdAoJisxvf33k7yO6OuoKmcTGuXPWSsm8Oh88nZicRLA9Y0rUeA=="], - "drizzle-kit": ["drizzle-kit@1.0.0-beta.12-a5629fb", "", { "dependencies": { "@drizzle-team/brocli": "^0.11.0", "@js-temporal/polyfill": "^0.5.1", "esbuild": "^0.25.10", "tsx": "^4.20.6" }, "bin": { "drizzle-kit": "bin.cjs" } }, "sha512-l+p4QOMvPGYBYEE9NBlU7diu+NSlxuOUwi0I7i01Uj1PpfU0NxhPzaks/9q1MDw4FAPP8vdD0dOhoqosKtRWWQ=="], + "drizzle-kit": ["drizzle-kit@1.0.0-beta.16-ea816b6", "", { "dependencies": { "@drizzle-team/brocli": "^0.11.0", "@js-temporal/polyfill": "^0.5.1", "esbuild": "^0.25.10", "jiti": "^2.6.1" }, "bin": { "drizzle-kit": "bin.cjs" } }, "sha512-GiJQqCNPZP8Kk+i7/sFa3rtXbq26tLDNi3LbMx9aoLuwF2ofk8CS7cySUGdI+r4J3q0a568quC8FZeaFTCw4IA=="], - "drizzle-orm": ["drizzle-orm@1.0.0-beta.12-a5629fb", "", { "peerDependencies": { "@aws-sdk/client-rds-data": ">=3", "@cloudflare/workers-types": ">=4", "@effect/sql": "^0.48.5", "@effect/sql-pg": "^0.49.7", "@electric-sql/pglite": ">=0.2.0", "@libsql/client": ">=0.10.0", "@libsql/client-wasm": ">=0.10.0", "@neondatabase/serverless": ">=0.10.0", "@op-engineering/op-sqlite": ">=2", "@opentelemetry/api": "^1.4.1", "@planetscale/database": ">=1.13", "@prisma/client": "*", "@sqlitecloud/drivers": ">=1.0.653", "@tidbcloud/serverless": "*", "@tursodatabase/database": ">=0.2.1", "@tursodatabase/database-common": ">=0.2.1", "@tursodatabase/database-wasm": ">=0.2.1", "@types/better-sqlite3": "*", "@types/mssql": "^9.1.4", "@types/pg": "*", "@types/sql.js": "*", "@upstash/redis": ">=1.34.7", "@vercel/postgres": ">=0.8.0", "@xata.io/client": "*", "better-sqlite3": ">=9.3.0", "bun-types": "*", "expo-sqlite": ">=14.0.0", "gel": ">=2", "mssql": "^11.0.1", "mysql2": ">=2", "pg": ">=8", "postgres": ">=3", "sql.js": ">=1", "sqlite3": ">=5" }, "optionalPeers": ["@aws-sdk/client-rds-data", "@cloudflare/workers-types", "@effect/sql", "@effect/sql-pg", "@electric-sql/pglite", "@libsql/client", "@libsql/client-wasm", "@neondatabase/serverless", "@op-engineering/op-sqlite", "@opentelemetry/api", "@planetscale/database", "@prisma/client", "@sqlitecloud/drivers", "@tidbcloud/serverless", "@tursodatabase/database", "@tursodatabase/database-common", "@tursodatabase/database-wasm", "@types/better-sqlite3", "@types/pg", "@types/sql.js", "@upstash/redis", "@vercel/postgres", "@xata.io/client", "better-sqlite3", "bun-types", "expo-sqlite", "gel", "mysql2", "pg", "postgres", "sql.js", "sqlite3"] }, "sha512-wyOAgr9Cy9oEN6z5S0JGhfipLKbRRJtQKgbDO9SXGR9swMBbGNIlXkeMqPRrqYQ8k70mh+7ZJ/eVmJ2F7zR3Vg=="], + "drizzle-orm": ["drizzle-orm@1.0.0-beta.16-ea816b6", "", { "peerDependencies": { "@aws-sdk/client-rds-data": ">=3", "@cloudflare/workers-types": ">=4", "@effect/sql": "^0.48.5", "@effect/sql-pg": "^0.49.7", "@electric-sql/pglite": ">=0.2.0", "@libsql/client": ">=0.10.0", "@libsql/client-wasm": ">=0.10.0", "@neondatabase/serverless": ">=0.10.0", "@op-engineering/op-sqlite": ">=2", "@opentelemetry/api": "^1.4.1", "@planetscale/database": ">=1.13", "@prisma/client": "*", "@sinclair/typebox": ">=0.34.8", "@sqlitecloud/drivers": ">=1.0.653", "@tidbcloud/serverless": "*", "@tursodatabase/database": ">=0.2.1", "@tursodatabase/database-common": ">=0.2.1", "@tursodatabase/database-wasm": ">=0.2.1", "@types/better-sqlite3": "*", "@types/mssql": "^9.1.4", "@types/pg": "*", "@types/sql.js": "*", "@upstash/redis": ">=1.34.7", "@vercel/postgres": ">=0.8.0", "@xata.io/client": "*", "arktype": ">=2.0.0", "better-sqlite3": ">=9.3.0", "bun-types": "*", "expo-sqlite": ">=14.0.0", "gel": ">=2", "mssql": "^11.0.1", "mysql2": ">=2", "pg": ">=8", "postgres": ">=3", "sql.js": ">=1", "sqlite3": ">=5", "typebox": ">=1.0.0", "valibot": ">=1.0.0-beta.7", "zod": "^3.25.0 || ^4.0.0" }, "optionalPeers": ["@aws-sdk/client-rds-data", "@cloudflare/workers-types", "@effect/sql", "@effect/sql-pg", "@electric-sql/pglite", "@libsql/client", "@libsql/client-wasm", "@neondatabase/serverless", "@op-engineering/op-sqlite", "@opentelemetry/api", "@planetscale/database", "@prisma/client", "@sinclair/typebox", "@sqlitecloud/drivers", "@tidbcloud/serverless", "@tursodatabase/database", "@tursodatabase/database-common", "@tursodatabase/database-wasm", "@types/better-sqlite3", "@types/pg", "@types/sql.js", "@upstash/redis", "@vercel/postgres", "@xata.io/client", "arktype", "better-sqlite3", "bun-types", "expo-sqlite", "gel", "mysql2", "pg", "postgres", "sql.js", "sqlite3", "typebox", "valibot", "zod"] }, "sha512-k9gT4f0O9Qvah5YK/zL+FZonQ8TPyVxcG/ojN4dzO0fHP8hs8tBno8lqmJo53g0JLWv3Q2nsTUoyBRKM2TljFw=="], "dset": ["dset@3.1.4", "", {}, "sha512-2QF/g9/zTaPDc3BjNcVTGoBbXBgYfMTTceLaYcFJ/W9kggFUkhxD/hMEeuLKbugyef9SqAx8cpgwlIP/jinUTA=="], @@ -5270,6 +5270,8 @@ "cross-spawn/which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], + "db0/drizzle-orm": ["drizzle-orm@1.0.0-beta.12-a5629fb", "", { "peerDependencies": { "@aws-sdk/client-rds-data": ">=3", "@cloudflare/workers-types": ">=4", "@effect/sql": "^0.48.5", "@effect/sql-pg": "^0.49.7", "@electric-sql/pglite": ">=0.2.0", "@libsql/client": ">=0.10.0", "@libsql/client-wasm": ">=0.10.0", "@neondatabase/serverless": ">=0.10.0", "@op-engineering/op-sqlite": ">=2", "@opentelemetry/api": "^1.4.1", "@planetscale/database": ">=1.13", "@prisma/client": "*", "@sqlitecloud/drivers": ">=1.0.653", "@tidbcloud/serverless": "*", "@tursodatabase/database": ">=0.2.1", "@tursodatabase/database-common": ">=0.2.1", "@tursodatabase/database-wasm": ">=0.2.1", "@types/better-sqlite3": "*", "@types/mssql": "^9.1.4", "@types/pg": "*", "@types/sql.js": "*", "@upstash/redis": ">=1.34.7", "@vercel/postgres": ">=0.8.0", "@xata.io/client": "*", "better-sqlite3": ">=9.3.0", "bun-types": "*", "expo-sqlite": ">=14.0.0", "gel": ">=2", "mssql": "^11.0.1", "mysql2": ">=2", "pg": ">=8", "postgres": ">=3", "sql.js": ">=1", "sqlite3": ">=5" }, "optionalPeers": ["@aws-sdk/client-rds-data", "@cloudflare/workers-types", "@effect/sql", "@effect/sql-pg", "@electric-sql/pglite", "@libsql/client", "@libsql/client-wasm", "@neondatabase/serverless", "@op-engineering/op-sqlite", "@opentelemetry/api", "@planetscale/database", "@prisma/client", "@sqlitecloud/drivers", "@tidbcloud/serverless", "@tursodatabase/database", "@tursodatabase/database-common", "@tursodatabase/database-wasm", "@types/better-sqlite3", "@types/pg", "@types/sql.js", "@upstash/redis", "@vercel/postgres", "@xata.io/client", "better-sqlite3", "bun-types", "expo-sqlite", "gel", "mysql2", "pg", "postgres", "sql.js", "sqlite3"] }, "sha512-wyOAgr9Cy9oEN6z5S0JGhfipLKbRRJtQKgbDO9SXGR9swMBbGNIlXkeMqPRrqYQ8k70mh+7ZJ/eVmJ2F7zR3Vg=="], + "defaults/clone": ["clone@1.0.4", "", {}, "sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg=="], "dir-compare/minimatch": ["minimatch@3.1.5", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w=="], diff --git a/nix/hashes.json b/nix/hashes.json index 326cc98a66..73491735f4 100644 --- a/nix/hashes.json +++ b/nix/hashes.json @@ -1,8 +1,8 @@ { "nodeModules": { - "x86_64-linux": "sha256-pBTIT8Pgdm3272YhBjiAZsmj0SSpHTklh6lGc8YcMoE=", - "aarch64-linux": "sha256-prt039++d5UZgtldAN6+RVOR557ifIeusiy5XpzN8QU=", - "aarch64-darwin": "sha256-Y3f+cXcIGLqz6oyc5fG22t6CLD4wGkvwqO6RNXjFriQ=", - "x86_64-darwin": "sha256-BjbBBhQUgGhrlP56skABcrObvutNUZSWnrnPCg1OTKE=" + "x86_64-linux": "sha256-4kjoJ06VNvHltPHfzQRBG0bC6R39jao10ffGzrNZ230=", + "aarch64-linux": "sha256-6Uio+S2rcyBWbBEeOZb9N1CCKgkbKi68lOIKi3Ws/pQ=", + "aarch64-darwin": "sha256-8ngN5KVN4vhdsk0QJ11BGgSVBrcaEbwSj23c77HBpgs=", + "x86_64-darwin": "sha256-v/ueYGb9a0Nymzy+mkO4uQr78DAuJnES1qOT0onFgnQ=" } } diff --git a/package.json b/package.json index 36cf31d346..530ab937c2 100644 --- a/package.json +++ b/package.json @@ -41,8 +41,8 @@ "@tailwindcss/vite": "4.1.11", "diff": "8.0.2", "dompurify": "3.3.1", - "drizzle-kit": "1.0.0-beta.12-a5629fb", - "drizzle-orm": "1.0.0-beta.12-a5629fb", + "drizzle-kit": "1.0.0-beta.16-ea816b6", + "drizzle-orm": "1.0.0-beta.16-ea816b6", "ai": "5.0.124", "hono": "4.10.7", "hono-openapi": "1.1.2", diff --git a/packages/app/e2e/AGENTS.md b/packages/app/e2e/AGENTS.md index 59662dbea5..8bfbd111b2 100644 --- a/packages/app/e2e/AGENTS.md +++ b/packages/app/e2e/AGENTS.md @@ -71,6 +71,12 @@ test("test description", async ({ page, sdk, gotoSession }) => { - `closeDialog(page, dialog)` - Close any dialog - `openSidebar(page)` / `closeSidebar(page)` - Toggle sidebar - `withSession(sdk, title, callback)` - Create temp session +- `withProject(...)` - Create temp project/workspace +- `sessionIDFromUrl(url)` - Read session ID from URL +- `slugFromUrl(url)` - Read workspace slug from URL +- `waitSlug(page, skip?)` - Wait for resolved workspace slug +- `trackSession(sessionID, directory?)` - Register session for fixture cleanup +- `trackDirectory(directory)` - Register directory for fixture cleanup - `clickListItem(container, filter)` - Click list item by key/text **Selectors** (`selectors.ts`): @@ -109,7 +115,7 @@ import { test, expect } from "@playwright/test" ### Error Handling -Tests should clean up after themselves: +Tests should clean up after themselves. Prefer fixture-managed cleanup: ```typescript test("test with cleanup", async ({ page, sdk, gotoSession }) => { @@ -120,6 +126,11 @@ test("test with cleanup", async ({ page, sdk, gotoSession }) => { }) ``` +- Prefer `withSession(...)` for temp sessions +- In `withProject(...)` tests that create sessions or extra workspaces, call `trackSession(sessionID, directory?)` and `trackDirectory(directory)` +- This lets fixture teardown abort, wait for idle, and clean up safely under CI concurrency +- Avoid calling `sdk.session.delete(...)` directly + ### Timeouts Default: 60s per test, 10s per assertion. Override when needed: @@ -161,9 +172,10 @@ await page.keyboard.press(`${modKey}+Comma`) // Open settings 1. Choose appropriate folder or create new one 2. Import from `../fixtures` 3. Use helper functions from `../actions` and `../selectors` -4. Clean up any created resources -5. Use specific selectors (avoid CSS classes) -6. Test one feature per test file +4. When validating routing, use shared helpers from `../actions`. Workspace URL slugs can be canonicalized on Windows, so assert against canonical or resolved workspace slugs. +5. Clean up any created resources +6. Use specific selectors (avoid CSS classes) +7. Test one feature per test file ## Local Development diff --git a/packages/app/e2e/actions.ts b/packages/app/e2e/actions.ts index fbb13008b2..90a449d500 100644 --- a/packages/app/e2e/actions.ts +++ b/packages/app/e2e/actions.ts @@ -3,12 +3,13 @@ import fs from "node:fs/promises" import os from "node:os" import path from "node:path" import { execSync } from "node:child_process" -import { modKey, serverUrl } from "./utils" +import { createSdk, modKey, resolveDirectory, serverUrl } from "./utils" import { - sessionItemSelector, dropdownMenuTriggerSelector, dropdownMenuContentSelector, + sessionTimelineHeaderSelector, projectMenuTriggerSelector, + projectCloseMenuSelector, projectWorkspacesToggleSelector, titlebarRightSelector, popoverBodySelector, @@ -18,7 +19,6 @@ import { workspaceItemSelector, workspaceMenuTriggerSelector, } from "./selectors" -import type { createSdk } from "./utils" export async function defocus(page: Page) { await page @@ -61,9 +61,9 @@ export async function closeDialog(page: Page, dialog: Locator) { } export async function isSidebarClosed(page: Page) { - const main = page.locator("main") - const classes = (await main.getAttribute("class")) ?? "" - return classes.includes("xl:border-l") + const button = page.getByRole("button", { name: /toggle sidebar/i }).first() + await expect(button).toBeVisible() + return (await button.getAttribute("aria-expanded")) !== "true" } export async function toggleSidebar(page: Page) { @@ -75,48 +75,34 @@ export async function openSidebar(page: Page) { if (!(await isSidebarClosed(page))) return const button = page.getByRole("button", { name: /toggle sidebar/i }).first() - const visible = await button - .isVisible() - .then((x) => x) - .catch(() => false) + await button.click() - if (visible) await button.click() - if (!visible) await toggleSidebar(page) - - const main = page.locator("main") - const opened = await expect(main) - .not.toHaveClass(/xl:border-l/, { timeout: 1500 }) + const opened = await expect(button) + .toHaveAttribute("aria-expanded", "true", { timeout: 1500 }) .then(() => true) .catch(() => false) if (opened) return await toggleSidebar(page) - await expect(main).not.toHaveClass(/xl:border-l/) + await expect(button).toHaveAttribute("aria-expanded", "true") } export async function closeSidebar(page: Page) { if (await isSidebarClosed(page)) return const button = page.getByRole("button", { name: /toggle sidebar/i }).first() - const visible = await button - .isVisible() - .then((x) => x) - .catch(() => false) + await button.click() - if (visible) await button.click() - if (!visible) await toggleSidebar(page) - - const main = page.locator("main") - const closed = await expect(main) - .toHaveClass(/xl:border-l/, { timeout: 1500 }) + const closed = await expect(button) + .toHaveAttribute("aria-expanded", "false", { timeout: 1500 }) .then(() => true) .catch(() => false) if (closed) return await toggleSidebar(page) - await expect(main).toHaveClass(/xl:border-l/) + await expect(button).toHaveAttribute("aria-expanded", "false") } export async function openSettings(page: Page) { @@ -204,7 +190,7 @@ export async function createTestProject() { stdio: "ignore", }) - return root + return resolveDirectory(root) } export async function cleanupTestProject(directory: string) { @@ -214,13 +200,40 @@ export async function cleanupTestProject(directory: string) { await fs.rm(directory, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }).catch(() => undefined) } +export function slugFromUrl(url: string) { + return /\/([^/]+)\/session(?:[/?#]|$)/.exec(url)?.[1] ?? "" +} + +export async function waitSlug(page: Page, skip: string[] = []) { + let prev = "" + let next = "" + await expect + .poll( + () => { + const slug = slugFromUrl(page.url()) + if (!slug) return "" + if (skip.includes(slug)) return "" + if (slug !== prev) { + prev = slug + next = "" + return "" + } + next = slug + return slug + }, + { timeout: 45_000 }, + ) + .not.toBe("") + return next +} + export function sessionIDFromUrl(url: string) { const match = /\/session\/([^/?#]+)/.exec(url) return match?.[1] } export async function hoverSessionItem(page: Page, sessionID: string) { - const sessionEl = page.locator(sessionItemSelector(sessionID)).first() + const sessionEl = page.locator(`[data-session-id="${sessionID}"]`).last() await expect(sessionEl).toBeVisible() await sessionEl.hover() return sessionEl @@ -231,7 +244,9 @@ export async function openSessionMoreMenu(page: Page, sessionID: string) { const scroller = page.locator(".scroll-view__viewport").first() await expect(scroller).toBeVisible() - await expect(scroller.getByRole("heading", { level: 1 }).first()).toBeVisible({ timeout: 30_000 }) + const header = page.locator(sessionTimelineHeaderSelector).first() + await expect(header).toBeVisible({ timeout: 30_000 }) + await expect(header.getByRole("heading", { level: 1 }).first()).toBeVisible({ timeout: 30_000 }) const menu = page .locator(dropdownMenuContentSelector) @@ -247,7 +262,7 @@ export async function openSessionMoreMenu(page: Page, sessionID: string) { if (opened) return menu - const menuTrigger = scroller.getByRole("button", { name: /more options/i }).first() + const menuTrigger = header.getByRole("button", { name: /more options/i }).first() await expect(menuTrigger).toBeVisible() await menuTrigger.click() @@ -321,6 +336,57 @@ export async function clickListItem( return item } +async function status(sdk: ReturnType, sessionID: string) { + const data = await sdk.session + .status() + .then((x) => x.data ?? {}) + .catch(() => undefined) + return data?.[sessionID] +} + +async function stable(sdk: ReturnType, sessionID: string, timeout = 10_000) { + let prev = "" + await expect + .poll( + async () => { + const info = await sdk.session + .get({ sessionID }) + .then((x) => x.data) + .catch(() => undefined) + if (!info) return true + const next = `${info.title}:${info.time.updated ?? info.time.created}` + if (next !== prev) { + prev = next + return false + } + return true + }, + { timeout }, + ) + .toBe(true) +} + +export async function waitSessionIdle(sdk: ReturnType, sessionID: string, timeout = 30_000) { + await expect.poll(() => status(sdk, sessionID).then((x) => !x || x.type === "idle"), { timeout }).toBe(true) +} + +export async function cleanupSession(input: { + sessionID: string + directory?: string + sdk?: ReturnType +}) { + const sdk = input.sdk ?? (input.directory ? createSdk(input.directory) : undefined) + if (!sdk) throw new Error("cleanupSession requires sdk or directory") + await waitSessionIdle(sdk, input.sessionID, 5_000).catch(() => undefined) + const current = await status(sdk, input.sessionID).catch(() => undefined) + if (current && current.type !== "idle") { + await sdk.session.abort({ sessionID: input.sessionID }).catch(() => undefined) + await waitSessionIdle(sdk, input.sessionID).catch(() => undefined) + } + await stable(sdk, input.sessionID).catch(() => undefined) + await sdk.session.delete({ sessionID: input.sessionID }).catch(() => undefined) +} + export async function withSession( sdk: ReturnType, title: string, @@ -332,7 +398,7 @@ export async function withSession( try { return await callback(session) } finally { - await sdk.session.delete({ sessionID: session.id }).catch(() => undefined) + await cleanupSession({ sdk, sessionID: session.id }) } } @@ -445,6 +511,57 @@ export async function seedSessionPermission( return { id: result.id } } +export async function seedSessionTask( + sdk: ReturnType, + input: { + sessionID: string + description: string + prompt: string + subagentType?: string + }, +) { + const text = [ + "Your only valid response is one task tool call.", + `Use this JSON input: ${JSON.stringify({ + description: input.description, + prompt: input.prompt, + subagent_type: input.subagentType ?? "general", + })}`, + "Do not output plain text.", + "Wait for the task to start and return the child session id.", + ].join("\n") + + const result = await seed({ + sdk, + sessionID: input.sessionID, + prompt: text, + timeout: 90_000, + probe: async () => { + const messages = await sdk.session.messages({ sessionID: input.sessionID, limit: 50 }).then((x) => x.data ?? []) + const part = messages + .flatMap((message) => message.parts) + .find((part) => { + if (part.type !== "tool" || part.tool !== "task") return false + if (part.state.input?.description !== input.description) return false + return typeof part.state.metadata?.sessionId === "string" && part.state.metadata.sessionId.length > 0 + }) + + if (!part) return + const id = part.state.metadata?.sessionId + if (typeof id !== "string" || !id) return + const child = await sdk.session + .get({ sessionID: id }) + .then((x) => x.data) + .catch(() => undefined) + if (!child?.id) return + return { sessionID: id } + }, + }) + + if (!result) throw new Error("Timed out seeding task tool") + return result +} + export async function seedSessionTodos( sdk: ReturnType, input: { @@ -519,32 +636,42 @@ export async function openProjectMenu(page: Page, projectSlug: string) { const trigger = page.locator(projectMenuTriggerSelector(projectSlug)).first() await expect(trigger).toHaveCount(1) + const menu = page + .locator(dropdownMenuContentSelector) + .filter({ has: page.locator(projectCloseMenuSelector(projectSlug)) }) + .first() + const close = menu.locator(projectCloseMenuSelector(projectSlug)).first() + + const clicked = await trigger + .click({ timeout: 1500 }) + .then(() => true) + .catch(() => false) + + if (clicked) { + const opened = await menu + .waitFor({ state: "visible", timeout: 1500 }) + .then(() => true) + .catch(() => false) + if (opened) { + await expect(close).toBeVisible() + return menu + } + } + await trigger.focus() await page.keyboard.press("Enter") - const menu = page.locator(dropdownMenuContentSelector).first() const opened = await menu .waitFor({ state: "visible", timeout: 1500 }) .then(() => true) .catch(() => false) if (opened) { - const viewport = page.viewportSize() - const x = viewport ? Math.max(viewport.width - 5, 0) : 1200 - const y = viewport ? Math.max(viewport.height - 5, 0) : 800 - await page.mouse.move(x, y) + await expect(close).toBeVisible() return menu } - await trigger.click({ force: true }) - - await expect(menu).toBeVisible() - - const viewport = page.viewportSize() - const x = viewport ? Math.max(viewport.width - 5, 0) : 1200 - const y = viewport ? Math.max(viewport.height - 5, 0) : 800 - await page.mouse.move(x, y) - return menu + throw new Error(`Failed to open project menu: ${projectSlug}`) } export async function setWorkspacesEnabled(page: Page, projectSlug: string, enabled: boolean) { @@ -557,11 +684,18 @@ export async function setWorkspacesEnabled(page: Page, projectSlug: string, enab if (current === enabled) return - await openProjectMenu(page, projectSlug) + const flip = async (timeout?: number) => { + const menu = await openProjectMenu(page, projectSlug) + const toggle = menu.locator(projectWorkspacesToggleSelector(projectSlug)).first() + await expect(toggle).toBeVisible() + return toggle.click({ force: true, timeout }) + } - const toggle = page.locator(projectWorkspacesToggleSelector(projectSlug)).first() - await expect(toggle).toBeVisible() - await toggle.click({ force: true }) + const flipped = await flip(1500) + .then(() => true) + .catch(() => false) + + if (!flipped) await flip() const expected = enabled ? "New workspace" : "New session" await expect(page.getByRole("button", { name: expected }).first()).toBeVisible() diff --git a/packages/app/e2e/app/home.spec.ts b/packages/app/e2e/app/home.spec.ts index f21dc40ec2..a3cedf7cb6 100644 --- a/packages/app/e2e/app/home.spec.ts +++ b/packages/app/e2e/app/home.spec.ts @@ -1,17 +1,17 @@ import { test, expect } from "../fixtures" -import { serverName } from "../utils" +import { serverNamePattern } from "../utils" test("home renders and shows core entrypoints", async ({ page }) => { await page.goto("/") await expect(page.getByRole("button", { name: "Open project" }).first()).toBeVisible() - await expect(page.getByRole("button", { name: serverName })).toBeVisible() + await expect(page.getByRole("button", { name: serverNamePattern })).toBeVisible() }) test("server picker dialog opens from home", async ({ page }) => { await page.goto("/") - const trigger = page.getByRole("button", { name: serverName }) + const trigger = page.getByRole("button", { name: serverNamePattern }) await expect(trigger).toBeVisible() await trigger.click() diff --git a/packages/app/e2e/app/server-default.spec.ts b/packages/app/e2e/app/server-default.spec.ts index adbc83473b..2c63130f67 100644 --- a/packages/app/e2e/app/server-default.spec.ts +++ b/packages/app/e2e/app/server-default.spec.ts @@ -1,6 +1,6 @@ import { test, expect } from "../fixtures" -import { serverName, serverUrl } from "../utils" -import { clickListItem, closeDialog, clickMenuItem } from "../actions" +import { serverNamePattern, serverUrls } from "../utils" +import { closeDialog, clickMenuItem } from "../actions" const DEFAULT_SERVER_URL_KEY = "opencode.settings.dat:defaultServerUrl" @@ -31,10 +31,9 @@ test("can set a default server on web", async ({ page, gotoSession }) => { const dialog = page.getByRole("dialog") await expect(dialog).toBeVisible() - const row = dialog.locator('[data-slot="list-item"]').filter({ hasText: serverName }).first() - await expect(row).toBeVisible() + await expect(dialog.getByText(serverNamePattern).first()).toBeVisible() - const menuTrigger = row.locator('[data-slot="dropdown-menu-trigger"]').first() + const menuTrigger = dialog.locator('[data-slot="dropdown-menu-trigger"]').first() await expect(menuTrigger).toBeVisible() await menuTrigger.click({ force: true }) @@ -42,14 +41,18 @@ test("can set a default server on web", async ({ page, gotoSession }) => { await expect(menu).toBeVisible() await clickMenuItem(menu, /set as default/i) - await expect.poll(() => page.evaluate((key) => localStorage.getItem(key), DEFAULT_SERVER_URL_KEY)).toBe(serverUrl) - await expect(row.getByText("Default", { exact: true })).toBeVisible() + await expect + .poll(async () => + serverUrls.includes((await page.evaluate((key) => localStorage.getItem(key), DEFAULT_SERVER_URL_KEY)) ?? ""), + ) + .toBe(true) + await expect(dialog.getByText("Default", { exact: true })).toBeVisible() await closeDialog(page, dialog) await ensurePopoverOpen() - const serverRow = popover.locator("button").filter({ hasText: serverName }).first() + const serverRow = popover.locator("button").filter({ hasText: serverNamePattern }).first() await expect(serverRow).toBeVisible() await expect(serverRow.getByText("Default", { exact: true })).toBeVisible() }) diff --git a/packages/app/e2e/app/titlebar-history.spec.ts b/packages/app/e2e/app/titlebar-history.spec.ts index 9d6091176e..a4592ff1db 100644 --- a/packages/app/e2e/app/titlebar-history.spec.ts +++ b/packages/app/e2e/app/titlebar-history.spec.ts @@ -16,7 +16,6 @@ test("titlebar back/forward navigates between sessions", async ({ page, slug, sd const link = page.locator(`[data-session-id="${two.id}"] a`).first() await expect(link).toBeVisible() - await link.scrollIntoViewIfNeeded() await link.click() await expect(page).toHaveURL(new RegExp(`/${slug}/session/${two.id}(?:\\?|#|$)`)) @@ -56,7 +55,6 @@ test("titlebar forward is cleared after branching history from sidebar", async ( const second = page.locator(`[data-session-id="${b.id}"] a`).first() await expect(second).toBeVisible() - await second.scrollIntoViewIfNeeded() await second.click() await expect(page).toHaveURL(new RegExp(`/${slug}/session/${b.id}(?:\\?|#|$)`)) @@ -76,7 +74,6 @@ test("titlebar forward is cleared after branching history from sidebar", async ( const third = page.locator(`[data-session-id="${c.id}"] a`).first() await expect(third).toBeVisible() - await third.scrollIntoViewIfNeeded() await third.click() await expect(page).toHaveURL(new RegExp(`/${slug}/session/${c.id}(?:\\?|#|$)`)) @@ -102,7 +99,6 @@ test("keyboard shortcuts navigate titlebar history", async ({ page, slug, sdk, g const link = page.locator(`[data-session-id="${two.id}"] a`).first() await expect(link).toBeVisible() - await link.scrollIntoViewIfNeeded() await link.click() await expect(page).toHaveURL(new RegExp(`/${slug}/session/${two.id}(?:\\?|#|$)`)) diff --git a/packages/app/e2e/commands/panels.spec.ts b/packages/app/e2e/commands/panels.spec.ts index 58c1f0a9af..7e5d7bd6e7 100644 --- a/packages/app/e2e/commands/panels.spec.ts +++ b/packages/app/e2e/commands/panels.spec.ts @@ -10,6 +10,8 @@ const expanded = async (el: { getAttribute: (name: string) => Promise { await gotoSession() + const reviewPanel = page.locator("#review-panel") + const treeToggle = page.getByRole("button", { name: "Toggle file tree" }).first() await expect(treeToggle).toBeVisible() if (await expanded(treeToggle)) await treeToggle.click() @@ -19,13 +21,13 @@ test("review panel can be toggled via keybind", async ({ page, gotoSession }) => await expect(reviewToggle).toBeVisible() if (await expanded(reviewToggle)) await reviewToggle.click() await expect(reviewToggle).toHaveAttribute("aria-expanded", "false") - await expect(page.locator("#review-panel")).toHaveCount(0) + await expect(reviewPanel).toHaveAttribute("aria-hidden", "true") await page.keyboard.press(`${modKey}+Shift+R`) await expect(reviewToggle).toHaveAttribute("aria-expanded", "true") - await expect(page.locator("#review-panel")).toBeVisible() + await expect(reviewPanel).toHaveAttribute("aria-hidden", "false") await page.keyboard.press(`${modKey}+Shift+R`) await expect(reviewToggle).toHaveAttribute("aria-expanded", "false") - await expect(page.locator("#review-panel")).toHaveCount(0) + await expect(reviewPanel).toHaveAttribute("aria-hidden", "true") }) diff --git a/packages/app/e2e/files/file-tree.spec.ts b/packages/app/e2e/files/file-tree.spec.ts index 44efb7f004..a5872bdf87 100644 --- a/packages/app/e2e/files/file-tree.spec.ts +++ b/packages/app/e2e/files/file-tree.spec.ts @@ -43,6 +43,13 @@ test("file tree can expand folders and open a file", async ({ page, gotoSession await tab.click() await expect(tab).toHaveAttribute("aria-selected", "true") + await toggle.click() + await expect(toggle).toHaveAttribute("aria-expanded", "false") + + await toggle.click() + await expect(toggle).toHaveAttribute("aria-expanded", "true") + await expect(allTab).toHaveAttribute("aria-selected", "true") + const viewer = page.locator('[data-component="file"][data-mode="text"]').first() await expect(viewer).toBeVisible() await expect(viewer).toContainText("export default function FileTree") diff --git a/packages/app/e2e/fixtures.ts b/packages/app/e2e/fixtures.ts index ea41ed8516..6a35c6901e 100644 --- a/packages/app/e2e/fixtures.ts +++ b/packages/app/e2e/fixtures.ts @@ -1,5 +1,5 @@ import { test as base, expect, type Page } from "@playwright/test" -import { cleanupTestProject, createTestProject, seedProjects } from "./actions" +import { cleanupSession, cleanupTestProject, createTestProject, seedProjects, sessionIDFromUrl } from "./actions" import { promptSelector } from "./selectors" import { createSdk, dirSlug, getWorktree, sessionPath } from "./utils" @@ -13,6 +13,8 @@ type TestFixtures = { directory: string slug: string gotoSession: (sessionID?: string) => Promise + trackSession: (sessionID: string, directory?: string) => void + trackDirectory: (directory: string) => void }) => Promise, options?: { extra?: string[] }, ) => Promise @@ -51,20 +53,36 @@ export const test = base.extend({ }, withProject: async ({ page }, use) => { await use(async (callback, options) => { - const directory = await createTestProject() - const slug = dirSlug(directory) - await seedStorage(page, { directory, extra: options?.extra }) + const root = await createTestProject() + const slug = dirSlug(root) + const sessions = new Map() + const dirs = new Set() + await seedStorage(page, { directory: root, extra: options?.extra }) const gotoSession = async (sessionID?: string) => { - await page.goto(sessionPath(directory, sessionID)) + await page.goto(sessionPath(root, sessionID)) await expect(page.locator(promptSelector)).toBeVisible() + const current = sessionIDFromUrl(page.url()) + if (current) trackSession(current) + } + + const trackSession = (sessionID: string, directory?: string) => { + sessions.set(sessionID, directory ?? root) + } + + const trackDirectory = (directory: string) => { + if (directory !== root) dirs.add(directory) } try { await gotoSession() - return await callback({ directory, slug, gotoSession }) + return await callback({ directory: root, slug, gotoSession, trackSession, trackDirectory }) } finally { - await cleanupTestProject(directory) + await Promise.allSettled( + Array.from(sessions, ([sessionID, directory]) => cleanupSession({ sessionID, directory })), + ) + await Promise.allSettled(Array.from(dirs, (directory) => cleanupTestProject(directory))) + await cleanupTestProject(root) } }) }, diff --git a/packages/app/e2e/projects/project-edit.spec.ts b/packages/app/e2e/projects/project-edit.spec.ts index 4a286fea75..7c20f29ec1 100644 --- a/packages/app/e2e/projects/project-edit.spec.ts +++ b/packages/app/e2e/projects/project-edit.spec.ts @@ -1,25 +1,15 @@ import { test, expect } from "../fixtures" -import { openSidebar } from "../actions" +import { clickMenuItem, openProjectMenu, openSidebar } from "../actions" test("dialog edit project updates name and startup script", async ({ page, withProject }) => { await page.setViewportSize({ width: 1400, height: 800 }) - await withProject(async () => { + await withProject(async ({ slug }) => { await openSidebar(page) const open = async () => { - const header = page.locator(".group\\/project").first() - await header.hover() - const trigger = header.getByRole("button", { name: "More options" }).first() - await expect(trigger).toBeVisible() - await trigger.click({ force: true }) - - const menu = page.locator('[data-component="dropdown-menu-content"]').first() - await expect(menu).toBeVisible() - - const editItem = menu.getByRole("menuitem", { name: "Edit" }).first() - await expect(editItem).toBeVisible() - await editItem.click({ force: true }) + const menu = await openProjectMenu(page, slug) + await clickMenuItem(menu, /^Edit$/i, { force: true }) const dialog = page.getByRole("dialog") await expect(dialog).toBeVisible() diff --git a/packages/app/e2e/projects/projects-close.spec.ts b/packages/app/e2e/projects/projects-close.spec.ts index 4b39ed82c3..76b1487e9e 100644 --- a/packages/app/e2e/projects/projects-close.spec.ts +++ b/packages/app/e2e/projects/projects-close.spec.ts @@ -1,36 +1,8 @@ import { test, expect } from "../fixtures" import { createTestProject, cleanupTestProject, openSidebar, clickMenuItem, openProjectMenu } from "../actions" -import { projectCloseHoverSelector, projectSwitchSelector } from "../selectors" +import { projectSwitchSelector } from "../selectors" import { dirSlug } from "../utils" -test("can close a project via hover card close button", async ({ page, withProject }) => { - await page.setViewportSize({ width: 1400, height: 800 }) - - const other = await createTestProject() - const otherSlug = dirSlug(other) - - try { - await withProject( - async () => { - await openSidebar(page) - - const otherButton = page.locator(projectSwitchSelector(otherSlug)).first() - await expect(otherButton).toBeVisible() - await otherButton.hover() - - const close = page.locator(projectCloseHoverSelector(otherSlug)).first() - await expect(close).toBeVisible() - await close.click() - - await expect(otherButton).toHaveCount(0) - }, - { extra: [other] }, - ) - } finally { - await cleanupTestProject(other) - } -}) - test("closing active project navigates to another open project", async ({ page, withProject }) => { await page.setViewportSize({ width: 1400, height: 800 }) diff --git a/packages/app/e2e/projects/projects-switch.spec.ts b/packages/app/e2e/projects/projects-switch.spec.ts index 81cca6988d..6ad64f5927 100644 --- a/packages/app/e2e/projects/projects-switch.spec.ts +++ b/packages/app/e2e/projects/projects-switch.spec.ts @@ -1,18 +1,39 @@ import { base64Decode } from "@opencode-ai/util/encode" +import type { Page } from "@playwright/test" import { test, expect } from "../fixtures" -import { - defocus, - createTestProject, - cleanupTestProject, - openSidebar, - setWorkspacesEnabled, - sessionIDFromUrl, -} from "../actions" +import { defocus, createTestProject, cleanupTestProject, openSidebar, sessionIDFromUrl, waitSlug } from "../actions" import { projectSwitchSelector, promptSelector, workspaceItemSelector, workspaceNewSessionSelector } from "../selectors" -import { createSdk, dirSlug, sessionPath } from "../utils" +import { dirSlug, resolveDirectory } from "../utils" -function slugFromUrl(url: string) { - return /\/([^/]+)\/session(?:\/|$)/.exec(url)?.[1] ?? "" +async function workspaces(page: Page, directory: string, enabled: boolean) { + await page.evaluate( + ({ directory, enabled }: { directory: string; enabled: boolean }) => { + const key = "opencode.global.dat:layout" + const raw = localStorage.getItem(key) + const data = raw ? JSON.parse(raw) : {} + const sidebar = data.sidebar && typeof data.sidebar === "object" ? data.sidebar : {} + const current = + sidebar.workspaces && typeof sidebar.workspaces === "object" && !Array.isArray(sidebar.workspaces) + ? sidebar.workspaces + : {} + const next = { ...current } + + if (enabled) next[directory] = true + if (!enabled) delete next[directory] + + localStorage.setItem( + key, + JSON.stringify({ + ...data, + sidebar: { + ...sidebar, + workspaces: next, + }, + }), + ) + }, + { directory, enabled }, + ) } test("can switch between projects from sidebar", async ({ page, withProject }) => { @@ -51,46 +72,39 @@ test("switching back to a project opens the latest workspace session", async ({ const other = await createTestProject() const otherSlug = dirSlug(other) - let rootDir: string | undefined - let workspaceDir: string | undefined - let sessionID: string | undefined - try { await withProject( - async ({ directory, slug }) => { - rootDir = directory + async ({ directory, slug, trackSession, trackDirectory }) => { await defocus(page) + await workspaces(page, directory, true) + await page.reload() + await expect(page.locator(promptSelector)).toBeVisible() await openSidebar(page) - await setWorkspacesEnabled(page, slug, true) + await expect(page.getByRole("button", { name: "New workspace" }).first()).toBeVisible() await page.getByRole("button", { name: "New workspace" }).first().click() - await expect - .poll( - () => { - const next = slugFromUrl(page.url()) - if (!next) return "" - if (next === slug) return "" - return next - }, - { timeout: 45_000 }, - ) - .not.toBe("") - - const workspaceSlug = slugFromUrl(page.url()) - workspaceDir = base64Decode(workspaceSlug) - if (!workspaceDir) throw new Error(`Failed to decode workspace slug: ${workspaceSlug}`) + const raw = await waitSlug(page, [slug]) + const dir = base64Decode(raw) + if (!dir) throw new Error(`Failed to decode workspace slug: ${raw}`) + const space = await resolveDirectory(dir) + const next = dirSlug(space) + trackDirectory(space) await openSidebar(page) - const workspace = page.locator(workspaceItemSelector(workspaceSlug)).first() - await expect(workspace).toBeVisible() - await workspace.hover() + const item = page.locator(`${workspaceItemSelector(next)}, ${workspaceItemSelector(raw)}`).first() + await expect(item).toBeVisible() + await item.hover() - const newSession = page.locator(workspaceNewSessionSelector(workspaceSlug)).first() - await expect(newSession).toBeVisible() - await newSession.click({ force: true }) + const btn = page.locator(`${workspaceNewSessionSelector(next)}, ${workspaceNewSessionSelector(raw)}`).first() + await expect(btn).toBeVisible() + await btn.click({ force: true }) - await expect(page).toHaveURL(new RegExp(`/${workspaceSlug}/session(?:[/?#]|$)`)) + // A new workspace can be discovered via a transient slug before the route and sidebar + // settle to the canonical workspace path on Windows, so interact with either and assert + // against the resolved workspace slug. + await waitSlug(page) + await expect(page).toHaveURL(new RegExp(`/${next}/session(?:[/?#]|$)`)) // Create a session by sending a prompt const prompt = page.locator(promptSelector) @@ -103,9 +117,9 @@ test("switching back to a project opens the latest workspace session", async ({ const created = sessionIDFromUrl(page.url()) if (!created) throw new Error(`Failed to get session ID from url: ${page.url()}`) - sessionID = created + trackSession(created, space) - await expect(page).toHaveURL(new RegExp(`/${workspaceSlug}/session/${created}(?:[/?#]|$)`)) + await expect(page).toHaveURL(new RegExp(`/${next}/session/${created}(?:[/?#]|$)`)) await openSidebar(page) @@ -124,20 +138,6 @@ test("switching back to a project opens the latest workspace session", async ({ { extra: [other] }, ) } finally { - if (sessionID) { - const id = sessionID - const dirs = [rootDir, workspaceDir].filter((x): x is string => !!x) - await Promise.all( - dirs.map((directory) => - createSdk(directory) - .session.delete({ sessionID: id }) - .catch(() => undefined), - ), - ) - } - if (workspaceDir) { - await cleanupTestProject(workspaceDir) - } await cleanupTestProject(other) } }) diff --git a/packages/app/e2e/projects/workspace-new-session.spec.ts b/packages/app/e2e/projects/workspace-new-session.spec.ts index f33972cc3a..18fa46d329 100644 --- a/packages/app/e2e/projects/workspace-new-session.spec.ts +++ b/packages/app/e2e/projects/workspace-new-session.spec.ts @@ -1,14 +1,10 @@ import { base64Decode } from "@opencode-ai/util/encode" import type { Page } from "@playwright/test" import { test, expect } from "../fixtures" -import { cleanupTestProject, openSidebar, sessionIDFromUrl, setWorkspacesEnabled } from "../actions" +import { openSidebar, sessionIDFromUrl, setWorkspacesEnabled, slugFromUrl, waitSlug } from "../actions" import { promptSelector, workspaceItemSelector, workspaceNewSessionSelector } from "../selectors" import { createSdk } from "../utils" -function slugFromUrl(url: string) { - return /\/([^/]+)\/session(?:\/|$)/.exec(url)?.[1] ?? "" -} - async function waitWorkspaceReady(page: Page, slug: string) { await openSidebar(page) await expect @@ -31,20 +27,7 @@ async function createWorkspace(page: Page, root: string, seen: string[]) { await openSidebar(page) await page.getByRole("button", { name: "New workspace" }).first().click() - await expect - .poll( - () => { - const slug = slugFromUrl(page.url()) - if (!slug) return "" - if (slug === root) return "" - if (seen.includes(slug)) return "" - return slug - }, - { timeout: 45_000 }, - ) - .not.toBe("") - - const slug = slugFromUrl(page.url()) + const slug = await waitSlug(page, [root, ...seen]) const directory = base64Decode(slug) if (!directory) throw new Error(`Failed to decode workspace slug: ${slug}`) return { slug, directory } @@ -60,12 +43,13 @@ async function openWorkspaceNewSession(page: Page, slug: string) { await expect(button).toBeVisible() await button.click({ force: true }) - await expect.poll(() => slugFromUrl(page.url())).toBe(slug) - await expect(page).toHaveURL(new RegExp(`/${slug}/session(?:[/?#]|$)`)) + const next = await waitSlug(page) + await expect(page).toHaveURL(new RegExp(`/${next}/session(?:[/?#]|$)`)) + return next } async function createSessionFromWorkspace(page: Page, slug: string, text: string) { - await openWorkspaceNewSession(page, slug) + const next = await openWorkspaceNewSession(page, slug) const prompt = page.locator(promptSelector) await expect(prompt).toBeVisible() @@ -76,13 +60,13 @@ async function createSessionFromWorkspace(page: Page, slug: string, text: string await expect.poll(async () => ((await prompt.textContent()) ?? "").trim()).toContain(text) await prompt.press("Enter") - await expect.poll(() => slugFromUrl(page.url())).toBe(slug) + await expect.poll(() => slugFromUrl(page.url())).toBe(next) await expect.poll(() => sessionIDFromUrl(page.url()) ?? "", { timeout: 30_000 }).not.toBe("") const sessionID = sessionIDFromUrl(page.url()) if (!sessionID) throw new Error(`Failed to parse session id from url: ${page.url()}`) - await expect(page).toHaveURL(new RegExp(`/${slug}/session/${sessionID}(?:[/?#]|$)`)) - return sessionID + await expect(page).toHaveURL(new RegExp(`/${next}/session/${sessionID}(?:[/?#]|$)`)) + return { sessionID, slug: next } } async function sessionDirectory(directory: string, sessionID: string) { @@ -97,48 +81,29 @@ async function sessionDirectory(directory: string, sessionID: string) { test("new sessions from sidebar workspace actions stay in selected workspace", async ({ page, withProject }) => { await page.setViewportSize({ width: 1400, height: 800 }) - await withProject(async ({ directory, slug: root }) => { - const workspaces = [] as { slug: string; directory: string }[] - const sessions = [] as string[] + await withProject(async ({ directory, slug: root, trackSession, trackDirectory }) => { + await openSidebar(page) + await setWorkspacesEnabled(page, root, true) - try { - await openSidebar(page) - await setWorkspacesEnabled(page, root, true) + const first = await createWorkspace(page, root, []) + trackDirectory(first.directory) + await waitWorkspaceReady(page, first.slug) - const first = await createWorkspace(page, root, []) - workspaces.push(first) - await waitWorkspaceReady(page, first.slug) + const second = await createWorkspace(page, root, [first.slug]) + trackDirectory(second.directory) + await waitWorkspaceReady(page, second.slug) - const second = await createWorkspace(page, root, [first.slug]) - workspaces.push(second) - await waitWorkspaceReady(page, second.slug) + const firstSession = await createSessionFromWorkspace(page, first.slug, `workspace one ${Date.now()}`) + trackSession(firstSession.sessionID, first.directory) - const firstSession = await createSessionFromWorkspace(page, first.slug, `workspace one ${Date.now()}`) - sessions.push(firstSession) + const secondSession = await createSessionFromWorkspace(page, second.slug, `workspace two ${Date.now()}`) + trackSession(secondSession.sessionID, second.directory) - const secondSession = await createSessionFromWorkspace(page, second.slug, `workspace two ${Date.now()}`) - sessions.push(secondSession) + const thirdSession = await createSessionFromWorkspace(page, first.slug, `workspace one again ${Date.now()}`) + trackSession(thirdSession.sessionID, first.directory) - const thirdSession = await createSessionFromWorkspace(page, first.slug, `workspace one again ${Date.now()}`) - sessions.push(thirdSession) - - await expect.poll(() => sessionDirectory(first.directory, firstSession)).toBe(first.directory) - await expect.poll(() => sessionDirectory(second.directory, secondSession)).toBe(second.directory) - await expect.poll(() => sessionDirectory(first.directory, thirdSession)).toBe(first.directory) - } finally { - const dirs = [directory, ...workspaces.map((workspace) => workspace.directory)] - await Promise.all( - sessions.map((sessionID) => - Promise.all( - dirs.map((dir) => - createSdk(dir) - .session.delete({ sessionID }) - .catch(() => undefined), - ), - ), - ), - ) - await Promise.all(workspaces.map((workspace) => cleanupTestProject(workspace.directory))) - } + await expect.poll(() => sessionDirectory(first.directory, firstSession.sessionID)).toBe(first.directory) + await expect.poll(() => sessionDirectory(second.directory, secondSession.sessionID)).toBe(second.directory) + await expect.poll(() => sessionDirectory(first.directory, thirdSession.sessionID)).toBe(first.directory) }) }) diff --git a/packages/app/e2e/projects/workspaces.spec.ts b/packages/app/e2e/projects/workspaces.spec.ts index 3867395267..aeeccb9bba 100644 --- a/packages/app/e2e/projects/workspaces.spec.ts +++ b/packages/app/e2e/projects/workspaces.spec.ts @@ -14,14 +14,12 @@ import { openSidebar, openWorkspaceMenu, setWorkspacesEnabled, + slugFromUrl, + waitSlug, } from "../actions" import { dropdownMenuContentSelector, inlineInputSelector, workspaceItemSelector } from "../selectors" import { createSdk, dirSlug } from "../utils" -function slugFromUrl(url: string) { - return /\/([^/]+)\/session(?:\/|$)/.exec(url)?.[1] ?? "" -} - async function setupWorkspaceTest(page: Page, project: { slug: string }) { const rootSlug = project.slug await openSidebar(page) @@ -29,17 +27,7 @@ async function setupWorkspaceTest(page: Page, project: { slug: string }) { await setWorkspacesEnabled(page, rootSlug, true) await page.getByRole("button", { name: "New workspace" }).first().click() - await expect - .poll( - () => { - const slug = slugFromUrl(page.url()) - return slug.length > 0 && slug !== rootSlug - }, - { timeout: 45_000 }, - ) - .toBe(true) - - const slug = slugFromUrl(page.url()) + const slug = await waitSlug(page, [rootSlug]) const dir = base64Decode(slug) await openSidebar(page) @@ -91,18 +79,7 @@ test("can create a workspace", async ({ page, withProject }) => { await expect(page.getByRole("button", { name: "New workspace" }).first()).toBeVisible() await page.getByRole("button", { name: "New workspace" }).first().click() - - await expect - .poll( - () => { - const currentSlug = slugFromUrl(page.url()) - return currentSlug.length > 0 && currentSlug !== slug - }, - { timeout: 45_000 }, - ) - .toBe(true) - - const workspaceSlug = slugFromUrl(page.url()) + const workspaceSlug = await waitSlug(page, [slug]) const workspaceDir = base64Decode(workspaceSlug) await openSidebar(page) @@ -279,7 +256,7 @@ test("can delete a workspace", async ({ page, withProject }) => { await clickMenuItem(menu, /^Delete$/i, { force: true }) await confirmDialog(page, /^Delete workspace$/i) - await expect(page).toHaveURL(new RegExp(`/${rootSlug}/session`)) + await expect.poll(() => base64Decode(slugFromUrl(page.url()))).toBe(project.directory) await expect .poll( @@ -336,9 +313,6 @@ test("can reorder workspaces by drag and drop", async ({ page, withProject }) => const src = page.locator(workspaceItemSelector(from)).first() const dst = page.locator(workspaceItemSelector(to)).first() - await src.scrollIntoViewIfNeeded() - await dst.scrollIntoViewIfNeeded() - const a = await src.boundingBox() const b = await dst.boundingBox() if (!a || !b) throw new Error("Failed to resolve workspace drag bounds") @@ -357,17 +331,7 @@ test("can reorder workspaces by drag and drop", async ({ page, withProject }) => for (const _ of [0, 1]) { const prev = slugFromUrl(page.url()) await page.getByRole("button", { name: "New workspace" }).first().click() - await expect - .poll( - () => { - const slug = slugFromUrl(page.url()) - return slug.length > 0 && slug !== rootSlug && slug !== prev - }, - { timeout: 45_000 }, - ) - .toBe(true) - - const slug = slugFromUrl(page.url()) + const slug = await waitSlug(page, [rootSlug, prev]) const dir = base64Decode(slug) workspaces.push({ slug, directory: dir }) diff --git a/packages/app/e2e/prompt/prompt-async.spec.ts b/packages/app/e2e/prompt/prompt-async.spec.ts index ce9b1a7a3b..51fbc3e4ae 100644 --- a/packages/app/e2e/prompt/prompt-async.spec.ts +++ b/packages/app/e2e/prompt/prompt-async.spec.ts @@ -1,6 +1,8 @@ import { test, expect } from "../fixtures" import { promptSelector } from "../selectors" -import { sessionIDFromUrl } from "../actions" +import { cleanupSession, sessionIDFromUrl, withSession } from "../actions" + +const text = (value: string | null) => (value ?? "").replace(/\u200B/g, "").trim() // Regression test for Issue #12453: the synchronous POST /message endpoint holds // the connection open while the agent works, causing "Failed to fetch" over @@ -38,6 +40,37 @@ test("prompt succeeds when sync message endpoint is unreachable", async ({ page, ) .toContain(token) } finally { - await sdk.session.delete({ sessionID }).catch(() => undefined) + await cleanupSession({ sdk, sessionID }) } }) + +test("failed prompt send restores the composer input", async ({ page, sdk, gotoSession }) => { + await withSession(sdk, `e2e prompt failure ${Date.now()}`, async (session) => { + const prompt = page.locator(promptSelector) + const value = `restore ${Date.now()}` + + await page.route(`**/session/${session.id}/prompt_async`, (route) => + route.fulfill({ + status: 500, + contentType: "application/json", + body: JSON.stringify({ message: "e2e prompt failure" }), + }), + ) + + await gotoSession(session.id) + await prompt.click() + await page.keyboard.type(value) + await page.keyboard.press("Enter") + + await expect.poll(async () => text(await prompt.textContent())).toBe(value) + await expect + .poll( + async () => { + const messages = await sdk.session.messages({ sessionID: session.id, limit: 50 }).then((r) => r.data ?? []) + return messages.length + }, + { timeout: 15_000 }, + ) + .toBe(0) + }) +}) diff --git a/packages/app/e2e/prompt/prompt-history.spec.ts b/packages/app/e2e/prompt/prompt-history.spec.ts new file mode 100644 index 0000000000..ec68998144 --- /dev/null +++ b/packages/app/e2e/prompt/prompt-history.spec.ts @@ -0,0 +1,181 @@ +import type { ToolPart } from "@opencode-ai/sdk/v2/client" +import type { Page } from "@playwright/test" +import { test, expect } from "../fixtures" +import { withSession } from "../actions" +import { promptSelector } from "../selectors" + +const text = (value: string | null) => (value ?? "").replace(/\u200B/g, "").trim() + +const isBash = (part: unknown): part is ToolPart => { + if (!part || typeof part !== "object") return false + if (!("type" in part) || part.type !== "tool") return false + if (!("tool" in part) || part.tool !== "bash") return false + return "state" in part +} + +async function edge(page: Page, pos: "start" | "end") { + await page.locator(promptSelector).evaluate((el: HTMLDivElement, pos: "start" | "end") => { + const selection = window.getSelection() + if (!selection) return + + const walk = document.createTreeWalker(el, NodeFilter.SHOW_TEXT) + const nodes: Text[] = [] + for (let node = walk.nextNode(); node; node = walk.nextNode()) { + nodes.push(node as Text) + } + + if (nodes.length === 0) { + const node = document.createTextNode("") + el.appendChild(node) + nodes.push(node) + } + + const node = pos === "start" ? nodes[0]! : nodes[nodes.length - 1]! + const range = document.createRange() + range.setStart(node, pos === "start" ? 0 : (node.textContent ?? "").length) + range.collapse(true) + selection.removeAllRanges() + selection.addRange(range) + }, pos) +} + +async function wait(page: Page, value: string) { + await expect.poll(async () => text(await page.locator(promptSelector).textContent())).toBe(value) +} + +async function reply(sdk: Parameters[0], sessionID: string, token: string) { + await expect + .poll( + async () => { + const messages = await sdk.session.messages({ sessionID, limit: 50 }).then((r) => r.data ?? []) + return messages + .filter((item) => item.info.role === "assistant") + .flatMap((item) => item.parts) + .filter((item) => item.type === "text") + .map((item) => item.text) + .join("\n") + }, + { timeout: 90_000 }, + ) + .toContain(token) +} + +async function shell(sdk: Parameters[0], sessionID: string, cmd: string, token: string) { + await expect + .poll( + async () => { + const messages = await sdk.session.messages({ sessionID, limit: 50 }).then((r) => r.data ?? []) + const part = messages + .filter((item) => item.info.role === "assistant") + .flatMap((item) => item.parts) + .filter(isBash) + .find((item) => item.state.input?.command === cmd && item.state.status === "completed") + + if (!part || part.state.status !== "completed") return + return typeof part.state.metadata?.output === "string" ? part.state.metadata.output : part.state.output + }, + { timeout: 90_000 }, + ) + .toContain(token) +} + +test("prompt history restores unsent draft with arrow navigation", async ({ page, sdk, gotoSession }) => { + test.setTimeout(120_000) + + await withSession(sdk, `e2e prompt history ${Date.now()}`, async (session) => { + await gotoSession(session.id) + + const prompt = page.locator(promptSelector) + const firstToken = `E2E_HISTORY_ONE_${Date.now()}` + const secondToken = `E2E_HISTORY_TWO_${Date.now()}` + const first = `Reply with exactly: ${firstToken}` + const second = `Reply with exactly: ${secondToken}` + const draft = `draft ${Date.now()}` + + await prompt.click() + await page.keyboard.type(first) + await page.keyboard.press("Enter") + await wait(page, "") + await reply(sdk, session.id, firstToken) + + await prompt.click() + await page.keyboard.type(second) + await page.keyboard.press("Enter") + await wait(page, "") + await reply(sdk, session.id, secondToken) + + await prompt.click() + await page.keyboard.type(draft) + await wait(page, draft) + + await edge(page, "start") + await page.keyboard.press("ArrowUp") + await wait(page, second) + + await page.keyboard.press("ArrowUp") + await wait(page, first) + + await page.keyboard.press("ArrowDown") + await wait(page, second) + + await page.keyboard.press("ArrowDown") + await wait(page, draft) + }) +}) + +test("shell history stays separate from normal prompt history", async ({ page, sdk, gotoSession }) => { + test.setTimeout(120_000) + + await withSession(sdk, `e2e shell history ${Date.now()}`, async (session) => { + await gotoSession(session.id) + + const prompt = page.locator(promptSelector) + const firstToken = `E2E_SHELL_ONE_${Date.now()}` + const secondToken = `E2E_SHELL_TWO_${Date.now()}` + const normalToken = `E2E_NORMAL_${Date.now()}` + const first = `echo ${firstToken}` + const second = `echo ${secondToken}` + const normal = `Reply with exactly: ${normalToken}` + + await prompt.click() + await page.keyboard.type("!") + await page.keyboard.type(first) + await page.keyboard.press("Enter") + await wait(page, "") + await shell(sdk, session.id, first, firstToken) + + await prompt.click() + await page.keyboard.type("!") + await page.keyboard.type(second) + await page.keyboard.press("Enter") + await wait(page, "") + await shell(sdk, session.id, second, secondToken) + + await prompt.click() + await page.keyboard.type("!") + await page.keyboard.press("ArrowUp") + await wait(page, second) + + await page.keyboard.press("ArrowUp") + await wait(page, first) + + await page.keyboard.press("ArrowDown") + await wait(page, second) + + await page.keyboard.press("ArrowDown") + await wait(page, "") + + await page.keyboard.press("Escape") + await wait(page, "") + + await prompt.click() + await page.keyboard.type(normal) + await page.keyboard.press("Enter") + await wait(page, "") + await reply(sdk, session.id, normalToken) + + await prompt.click() + await page.keyboard.press("ArrowUp") + await wait(page, normal) + }) +}) diff --git a/packages/app/e2e/prompt/prompt-shell.spec.ts b/packages/app/e2e/prompt/prompt-shell.spec.ts new file mode 100644 index 0000000000..4c92f4a2f2 --- /dev/null +++ b/packages/app/e2e/prompt/prompt-shell.spec.ts @@ -0,0 +1,62 @@ +import type { ToolPart } from "@opencode-ai/sdk/v2/client" +import { test, expect } from "../fixtures" +import { sessionIDFromUrl } from "../actions" +import { promptSelector } from "../selectors" +import { createSdk } from "../utils" + +const isBash = (part: unknown): part is ToolPart => { + if (!part || typeof part !== "object") return false + if (!("type" in part) || part.type !== "tool") return false + if (!("tool" in part) || part.tool !== "bash") return false + return "state" in part +} + +test("shell mode runs a command in the project directory", async ({ page, withProject }) => { + test.setTimeout(120_000) + + await withProject(async ({ directory, gotoSession, trackSession }) => { + const sdk = createSdk(directory) + const prompt = page.locator(promptSelector) + const cmd = process.platform === "win32" ? "dir" : "ls" + + await gotoSession() + await prompt.click() + await page.keyboard.type("!") + await expect(prompt).toHaveAttribute("aria-label", /enter shell command/i) + + await page.keyboard.type(cmd) + await page.keyboard.press("Enter") + + await expect(page).toHaveURL(/\/session\/[^/?#]+/, { timeout: 30_000 }) + + const id = sessionIDFromUrl(page.url()) + if (!id) throw new Error(`Failed to parse session id from url: ${page.url()}`) + trackSession(id, directory) + + await expect + .poll( + async () => { + const list = await sdk.session.messages({ sessionID: id, limit: 50 }).then((x) => x.data ?? []) + const msg = list.findLast( + (item) => item.info.role === "assistant" && "path" in item.info && item.info.path.cwd === directory, + ) + if (!msg) return + + const part = msg.parts + .filter(isBash) + .find((item) => item.state.input?.command === cmd && item.state.status === "completed") + + if (!part || part.state.status !== "completed") return + const output = + typeof part.state.metadata?.output === "string" ? part.state.metadata.output : part.state.output + if (!output.includes("README.md")) return + + return { cwd: directory, output } + }, + { timeout: 90_000 }, + ) + .toEqual(expect.objectContaining({ cwd: directory, output: expect.stringContaining("README.md") })) + + await expect(prompt).toHaveText("") + }) +}) diff --git a/packages/app/e2e/prompt/prompt-slash-share.spec.ts b/packages/app/e2e/prompt/prompt-slash-share.spec.ts new file mode 100644 index 0000000000..817b353a7c --- /dev/null +++ b/packages/app/e2e/prompt/prompt-slash-share.spec.ts @@ -0,0 +1,64 @@ +import { test, expect } from "../fixtures" +import { promptSelector } from "../selectors" +import { withSession } from "../actions" + +const shareDisabled = process.env.OPENCODE_DISABLE_SHARE === "true" || process.env.OPENCODE_DISABLE_SHARE === "1" + +async function seed(sdk: Parameters[0], sessionID: string) { + await sdk.session.promptAsync({ + sessionID, + noReply: true, + parts: [{ type: "text", text: "e2e share seed" }], + }) + + await expect + .poll( + async () => { + const messages = await sdk.session.messages({ sessionID, limit: 1 }).then((r) => r.data ?? []) + return messages.length + }, + { timeout: 30_000 }, + ) + .toBeGreaterThan(0) +} + +test("/share and /unshare update session share state", async ({ page, sdk, gotoSession }) => { + test.skip(shareDisabled, "Share is disabled in this environment (OPENCODE_DISABLE_SHARE).") + + await withSession(sdk, `e2e slash share ${Date.now()}`, async (session) => { + const prompt = page.locator(promptSelector) + + await seed(sdk, session.id) + await gotoSession(session.id) + + await prompt.click() + await page.keyboard.type("/share") + await expect(page.locator('[data-slash-id="session.share"]').first()).toBeVisible() + await page.keyboard.press("Enter") + + await expect + .poll( + async () => { + const data = await sdk.session.get({ sessionID: session.id }).then((r) => r.data) + return data?.share?.url || undefined + }, + { timeout: 30_000 }, + ) + .not.toBeUndefined() + + await prompt.click() + await page.keyboard.type("/unshare") + await expect(page.locator('[data-slash-id="session.unshare"]').first()).toBeVisible() + await page.keyboard.press("Enter") + + await expect + .poll( + async () => { + const data = await sdk.session.get({ sessionID: session.id }).then((r) => r.data) + return data?.share?.url || undefined + }, + { timeout: 30_000 }, + ) + .toBeUndefined() + }) +}) diff --git a/packages/app/e2e/prompt/prompt.spec.ts b/packages/app/e2e/prompt/prompt.spec.ts index ff9f5daf0d..0466d0988c 100644 --- a/packages/app/e2e/prompt/prompt.spec.ts +++ b/packages/app/e2e/prompt/prompt.spec.ts @@ -1,6 +1,6 @@ import { test, expect } from "../fixtures" import { promptSelector } from "../selectors" -import { sessionIDFromUrl, withSession } from "../actions" +import { cleanupSession, sessionIDFromUrl, withSession } from "../actions" test("can send a prompt and receive a reply", async ({ page, sdk, gotoSession }) => { test.setTimeout(120_000) @@ -46,7 +46,7 @@ test("can send a prompt and receive a reply", async ({ page, sdk, gotoSession }) .toContain(token) } finally { page.off("pageerror", onPageError) - await sdk.session.delete({ sessionID }).catch(() => undefined) + await cleanupSession({ sdk, sessionID }) } if (pageErrors.length > 0) { diff --git a/packages/app/e2e/selectors.ts b/packages/app/e2e/selectors.ts index 5fad2c06b5..002ac2114c 100644 --- a/packages/app/e2e/selectors.ts +++ b/packages/app/e2e/selectors.ts @@ -30,8 +30,6 @@ export const sidebarNavSelector = '[data-component="sidebar-nav-desktop"]' export const projectSwitchSelector = (slug: string) => `${sidebarNavSelector} [data-action="project-switch"][data-project="${slug}"]` -export const projectCloseHoverSelector = (slug: string) => `[data-action="project-close-hover"][data-project="${slug}"]` - export const projectMenuTriggerSelector = (slug: string) => `${sidebarNavSelector} [data-action="project-menu"][data-project="${slug}"]` @@ -53,6 +51,8 @@ export const dropdownMenuContentSelector = '[data-component="dropdown-menu-conte export const inlineInputSelector = '[data-component="inline-input"]' +export const sessionTimelineHeaderSelector = "[data-session-title]" + export const sessionItemSelector = (sessionID: string) => `${sidebarNavSelector} [data-session-id="${sessionID}"]` export const workspaceItemSelector = (slug: string) => diff --git a/packages/app/e2e/session/session-child-navigation.spec.ts b/packages/app/e2e/session/session-child-navigation.spec.ts new file mode 100644 index 0000000000..ac2dca33c8 --- /dev/null +++ b/packages/app/e2e/session/session-child-navigation.spec.ts @@ -0,0 +1,37 @@ +import { seedSessionTask, withSession } from "../actions" +import { test, expect } from "../fixtures" + +test("task tool child-session link does not trigger stale show errors", async ({ page, sdk, gotoSession }) => { + test.setTimeout(120_000) + + const errs: string[] = [] + const onError = (err: Error) => { + errs.push(err.message) + } + page.on("pageerror", onError) + + await withSession(sdk, `e2e child nav ${Date.now()}`, async (session) => { + const child = await seedSessionTask(sdk, { + sessionID: session.id, + description: "Open child session", + prompt: "Search the repository for AssistantParts and then reply with exactly CHILD_OK.", + }) + + try { + await gotoSession(session.id) + + const link = page + .locator("a.subagent-link") + .filter({ hasText: /open child session/i }) + .first() + await expect(link).toBeVisible({ timeout: 30_000 }) + await link.click() + + await expect(page).toHaveURL(new RegExp(`/session/${child.sessionID}(?:[/?#]|$)`), { timeout: 30_000 }) + await page.waitForTimeout(1000) + expect(errs).toEqual([]) + } finally { + page.off("pageerror", onError) + } + }) +}) diff --git a/packages/app/e2e/session/session-composer-dock.spec.ts b/packages/app/e2e/session/session-composer-dock.spec.ts index 4cf075fc9a..055e8eed29 100644 --- a/packages/app/e2e/session/session-composer-dock.spec.ts +++ b/packages/app/e2e/session/session-composer-dock.spec.ts @@ -1,5 +1,5 @@ import { test, expect } from "../fixtures" -import { clearSessionDockSeed, seedSessionQuestion, seedSessionTodos } from "../actions" +import { cleanupSession, clearSessionDockSeed, seedSessionQuestion, seedSessionTodos } from "../actions" import { permissionDockSelector, promptSelector, @@ -26,7 +26,7 @@ async function withDockSession( try { return await fn(session) } finally { - await sdk.session.delete({ sessionID: session.id }).catch(() => undefined) + await cleanupSession({ sdk, sessionID: session.id }) } } @@ -311,7 +311,7 @@ test("child session question request blocks parent dock and unblocks after submi await expect(page.locator(promptSelector)).toBeVisible() }) } finally { - await sdk.session.delete({ sessionID: child.id }).catch(() => undefined) + await cleanupSession({ sdk, sessionID: child.id }) } }) }) @@ -358,7 +358,7 @@ test("child session permission request blocks parent dock and supports allow onc }, ) } finally { - await sdk.session.delete({ sessionID: child.id }).catch(() => undefined) + await cleanupSession({ sdk, sessionID: child.id }) } }) }) diff --git a/packages/app/e2e/session/session-undo-redo.spec.ts b/packages/app/e2e/session/session-undo-redo.spec.ts index c6ea2aea0a..eb0840f7cc 100644 --- a/packages/app/e2e/session/session-undo-redo.spec.ts +++ b/packages/app/e2e/session/session-undo-redo.spec.ts @@ -45,7 +45,7 @@ async function seedConversation(input: { .toBe(true) if (!userMessageID) throw new Error("Expected a user message id") - await expect(input.page.locator(`[data-message-id="${userMessageID}"]`).first()).toBeVisible({ timeout: 30_000 }) + await expect(input.page.locator(`[data-message-id="${userMessageID}"]`)).toHaveCount(1, { timeout: 30_000 }) return { prompt, userMessageID } } @@ -123,7 +123,7 @@ test("slash redo clears revert and restores latest state", async ({ page, withPr .toBeUndefined() await expect(seeded.prompt).not.toContainText(token) - await expect(page.locator(`[data-message-id="${seeded.userMessageID}"]`).first()).toBeVisible() + await expect(page.locator(`[data-message-id="${seeded.userMessageID}"]`)).toHaveCount(1) }) }) }) @@ -158,8 +158,8 @@ test("slash undo/redo traverses multi-step revert stack", async ({ page, withPro const firstMessage = page.locator(`[data-message-id="${first.userMessageID}"]`) const secondMessage = page.locator(`[data-message-id="${second.userMessageID}"]`) - await expect(firstMessage.first()).toBeVisible() - await expect(secondMessage.first()).toBeVisible() + await expect(firstMessage).toHaveCount(1) + await expect(secondMessage).toHaveCount(1) await second.prompt.click() await page.keyboard.press(`${modKey}+A`) @@ -176,7 +176,7 @@ test("slash undo/redo traverses multi-step revert stack", async ({ page, withPro }) .toBe(second.userMessageID) - await expect(firstMessage.first()).toBeVisible() + await expect(firstMessage).toHaveCount(1) await expect(secondMessage).toHaveCount(0) await second.prompt.click() @@ -210,7 +210,7 @@ test("slash undo/redo traverses multi-step revert stack", async ({ page, withPro }) .toBe(second.userMessageID) - await expect(firstMessage.first()).toBeVisible() + await expect(firstMessage).toHaveCount(1) await expect(secondMessage).toHaveCount(0) await second.prompt.click() @@ -226,8 +226,8 @@ test("slash undo/redo traverses multi-step revert stack", async ({ page, withPro }) .toBeUndefined() - await expect(firstMessage.first()).toBeVisible() - await expect(secondMessage.first()).toBeVisible() + await expect(firstMessage).toHaveCount(1) + await expect(secondMessage).toHaveCount(1) }) }) }) diff --git a/packages/app/e2e/session/session.spec.ts b/packages/app/e2e/session/session.spec.ts index 68d9929499..e541738c59 100644 --- a/packages/app/e2e/session/session.spec.ts +++ b/packages/app/e2e/session/session.spec.ts @@ -7,7 +7,7 @@ import { openSharePopover, withSession, } from "../actions" -import { sessionItemSelector, inlineInputSelector } from "../selectors" +import { sessionItemSelector, inlineInputSelector, sessionTimelineHeaderSelector } from "../selectors" const shareDisabled = process.env.OPENCODE_DISABLE_SHARE === "true" || process.env.OPENCODE_DISABLE_SHARE === "1" @@ -39,12 +39,14 @@ test("session can be renamed via header menu", async ({ page, sdk, gotoSession } await withSession(sdk, originalTitle, async (session) => { await seedMessage(sdk, session.id) await gotoSession(session.id) - await expect(page.getByRole("heading", { level: 1 }).first()).toHaveText(originalTitle) + await expect(page.locator(sessionTimelineHeaderSelector).getByRole("heading", { level: 1 }).first()).toHaveText( + originalTitle, + ) const menu = await openSessionMoreMenu(page, session.id) await clickMenuItem(menu, /rename/i) - const input = page.locator(".scroll-view__viewport").locator(inlineInputSelector).first() + const input = page.locator(sessionTimelineHeaderSelector).locator(inlineInputSelector).first() await expect(input).toBeVisible() await expect(input).toBeFocused() await input.fill(renamedTitle) @@ -61,7 +63,9 @@ test("session can be renamed via header menu", async ({ page, sdk, gotoSession } ) .toBe(renamedTitle) - await expect(page.getByRole("heading", { level: 1 }).first()).toHaveText(renamedTitle) + await expect(page.locator(sessionTimelineHeaderSelector).getByRole("heading", { level: 1 }).first()).toHaveText( + renamedTitle, + ) }) }) diff --git a/packages/app/e2e/settings/settings-keybinds.spec.ts b/packages/app/e2e/settings/settings-keybinds.spec.ts index 5e98bd158a..e0d590b31a 100644 --- a/packages/app/e2e/settings/settings-keybinds.spec.ts +++ b/packages/app/e2e/settings/settings-keybinds.spec.ts @@ -32,22 +32,19 @@ test("changing sidebar toggle keybind works", async ({ page, gotoSession }) => { await closeDialog(page, dialog) - const main = page.locator("main") - const initialClasses = (await main.getAttribute("class")) ?? "" - const initiallyClosed = initialClasses.includes("xl:border-l") + const button = page.getByRole("button", { name: /toggle sidebar/i }).first() + const initiallyClosed = (await button.getAttribute("aria-expanded")) !== "true" await page.keyboard.press(`${modKey}+Shift+H`) - await page.waitForTimeout(100) + await expect(button).toHaveAttribute("aria-expanded", initiallyClosed ? "true" : "false") - const afterToggleClasses = (await main.getAttribute("class")) ?? "" - const afterToggleClosed = afterToggleClasses.includes("xl:border-l") + const afterToggleClosed = (await button.getAttribute("aria-expanded")) !== "true" expect(afterToggleClosed).toBe(!initiallyClosed) await page.keyboard.press(`${modKey}+Shift+H`) - await page.waitForTimeout(100) + await expect(button).toHaveAttribute("aria-expanded", initiallyClosed ? "false" : "true") - const finalClasses = (await main.getAttribute("class")) ?? "" - const finalClosed = finalClasses.includes("xl:border-l") + const finalClosed = (await button.getAttribute("aria-expanded")) !== "true" expect(finalClosed).toBe(initiallyClosed) }) diff --git a/packages/app/e2e/sidebar/sidebar-popover-actions.spec.ts b/packages/app/e2e/sidebar/sidebar-popover-actions.spec.ts index e37f94f3a7..d10fca0e49 100644 --- a/packages/app/e2e/sidebar/sidebar-popover-actions.spec.ts +++ b/packages/app/e2e/sidebar/sidebar-popover-actions.spec.ts @@ -1,6 +1,6 @@ import { test, expect } from "../fixtures" -import { closeSidebar, hoverSessionItem } from "../actions" -import { projectSwitchSelector, sessionItemSelector } from "../selectors" +import { cleanupSession, closeSidebar, hoverSessionItem } from "../actions" +import { projectSwitchSelector } from "../selectors" test("collapsed sidebar popover stays open when archiving a session", async ({ page, slug, sdk, gotoSession }) => { const stamp = Date.now() @@ -15,12 +15,15 @@ test("collapsed sidebar popover stays open when archiving a session", async ({ p await gotoSession(one.id) await closeSidebar(page) + const oneItem = page.locator(`[data-session-id="${one.id}"]`).last() + const twoItem = page.locator(`[data-session-id="${two.id}"]`).last() + const project = page.locator(projectSwitchSelector(slug)).first() await expect(project).toBeVisible() await project.hover() - await expect(page.locator(sessionItemSelector(one.id)).first()).toBeVisible() - await expect(page.locator(sessionItemSelector(two.id)).first()).toBeVisible() + await expect(oneItem).toBeVisible() + await expect(twoItem).toBeVisible() const item = await hoverSessionItem(page, one.id) await item @@ -28,9 +31,9 @@ test("collapsed sidebar popover stays open when archiving a session", async ({ p .first() .click() - await expect(page.locator(sessionItemSelector(two.id)).first()).toBeVisible() + await expect(twoItem).toBeVisible() } finally { - await sdk.session.delete({ sessionID: one.id }).catch(() => undefined) - await sdk.session.delete({ sessionID: two.id }).catch(() => undefined) + await cleanupSession({ sdk, sessionID: one.id }) + await cleanupSession({ sdk, sessionID: two.id }) } }) diff --git a/packages/app/e2e/sidebar/sidebar-session-links.spec.ts b/packages/app/e2e/sidebar/sidebar-session-links.spec.ts index cda2278a95..22f98e94ca 100644 --- a/packages/app/e2e/sidebar/sidebar-session-links.spec.ts +++ b/packages/app/e2e/sidebar/sidebar-session-links.spec.ts @@ -1,5 +1,5 @@ import { test, expect } from "../fixtures" -import { openSidebar, withSession } from "../actions" +import { cleanupSession, openSidebar, withSession } from "../actions" import { promptSelector } from "../selectors" test("sidebar session links navigate to the selected session", async ({ page, slug, sdk, gotoSession }) => { @@ -18,14 +18,13 @@ test("sidebar session links navigate to the selected session", async ({ page, sl const target = page.locator(`[data-session-id="${two.id}"] a`).first() await expect(target).toBeVisible() - await target.scrollIntoViewIfNeeded() await target.click() await expect(page).toHaveURL(new RegExp(`/${slug}/session/${two.id}(?:\\?|#|$)`)) await expect(page.locator(promptSelector)).toBeVisible() await expect(page.locator(`[data-session-id="${two.id}"] a`).first()).toHaveClass(/\bactive\b/) } finally { - await sdk.session.delete({ sessionID: one.id }).catch(() => undefined) - await sdk.session.delete({ sessionID: two.id }).catch(() => undefined) + await cleanupSession({ sdk, sessionID: one.id }) + await cleanupSession({ sdk, sessionID: two.id }) } }) diff --git a/packages/app/e2e/sidebar/sidebar.spec.ts b/packages/app/e2e/sidebar/sidebar.spec.ts index 5c78c2220d..c6bf3fa9ab 100644 --- a/packages/app/e2e/sidebar/sidebar.spec.ts +++ b/packages/app/e2e/sidebar/sidebar.spec.ts @@ -5,12 +5,14 @@ test("sidebar can be collapsed and expanded", async ({ page, gotoSession }) => { await gotoSession() await openSidebar(page) + const button = page.getByRole("button", { name: /toggle sidebar/i }).first() + await expect(button).toHaveAttribute("aria-expanded", "true") await toggleSidebar(page) - await expect(page.locator("main")).toHaveClass(/xl:border-l/) + await expect(button).toHaveAttribute("aria-expanded", "false") await toggleSidebar(page) - await expect(page.locator("main")).not.toHaveClass(/xl:border-l/) + await expect(button).toHaveAttribute("aria-expanded", "true") }) test("sidebar collapsed state persists across navigation and reload", async ({ page, sdk, gotoSession }) => { @@ -19,14 +21,15 @@ test("sidebar collapsed state persists across navigation and reload", async ({ p await gotoSession(session1.id) await openSidebar(page) + const button = page.getByRole("button", { name: /toggle sidebar/i }).first() await toggleSidebar(page) - await expect(page.locator("main")).toHaveClass(/xl:border-l/) + await expect(button).toHaveAttribute("aria-expanded", "false") await gotoSession(session2.id) - await expect(page.locator("main")).toHaveClass(/xl:border-l/) + await expect(button).toHaveAttribute("aria-expanded", "false") await page.reload() - await expect(page.locator("main")).toHaveClass(/xl:border-l/) + await expect(button).toHaveAttribute("aria-expanded", "false") const opened = await page.evaluate( () => JSON.parse(localStorage.getItem("opencode.global.dat:layout") ?? "{}").sidebar?.opened, diff --git a/packages/app/e2e/terminal/terminal-tabs.spec.ts b/packages/app/e2e/terminal/terminal-tabs.spec.ts new file mode 100644 index 0000000000..f76a86cf70 --- /dev/null +++ b/packages/app/e2e/terminal/terminal-tabs.spec.ts @@ -0,0 +1,120 @@ +import type { Page } from "@playwright/test" +import { test, expect } from "../fixtures" +import { terminalSelector } from "../selectors" +import { terminalToggleKey, workspacePersistKey } from "../utils" + +type State = { + active?: string + all: Array<{ + id: string + title: string + titleNumber: number + buffer?: string + }> +} + +async function open(page: Page) { + const terminal = page.locator(terminalSelector) + const visible = await terminal.isVisible().catch(() => false) + if (!visible) await page.keyboard.press(terminalToggleKey) + await expect(terminal).toBeVisible() + await expect(terminal.locator("textarea")).toHaveCount(1) +} + +async function run(page: Page, cmd: string) { + const terminal = page.locator(terminalSelector) + await expect(terminal).toBeVisible() + await terminal.click() + await page.keyboard.type(cmd) + await page.keyboard.press("Enter") +} + +async function store(page: Page, key: string) { + return page.evaluate((key) => { + const raw = localStorage.getItem(key) + if (raw) return JSON.parse(raw) as State + + for (let i = 0; i < localStorage.length; i++) { + const next = localStorage.key(i) + if (!next?.endsWith(":workspace:terminal")) continue + const value = localStorage.getItem(next) + if (!value) continue + return JSON.parse(value) as State + } + }, key) +} + +test("terminal tab buffers persist across tab switches", async ({ page, withProject }) => { + await withProject(async ({ directory, gotoSession }) => { + const key = workspacePersistKey(directory, "terminal") + const one = `E2E_TERM_ONE_${Date.now()}` + const two = `E2E_TERM_TWO_${Date.now()}` + const tabs = page.locator('#terminal-panel [data-slot="tabs-trigger"]') + + await gotoSession() + await open(page) + + await run(page, `echo ${one}`) + + await page.getByRole("button", { name: /new terminal/i }).click() + await expect(tabs).toHaveCount(2) + + await run(page, `echo ${two}`) + + await tabs + .filter({ hasText: /Terminal 1/ }) + .first() + .click() + + await expect + .poll( + async () => { + const state = await store(page, key) + const first = state?.all.find((item) => item.titleNumber === 1)?.buffer ?? "" + const second = state?.all.find((item) => item.titleNumber === 2)?.buffer ?? "" + return first.includes(one) && second.includes(two) + }, + { timeout: 30_000 }, + ) + .toBe(true) + }) +}) + +test("closing the active terminal tab falls back to the previous tab", async ({ page, withProject }) => { + await withProject(async ({ directory, gotoSession }) => { + const key = workspacePersistKey(directory, "terminal") + const tabs = page.locator('#terminal-panel [data-slot="tabs-trigger"]') + + await gotoSession() + await open(page) + + await page.getByRole("button", { name: /new terminal/i }).click() + await expect(tabs).toHaveCount(2) + + const second = tabs.filter({ hasText: /Terminal 2/ }).first() + await second.click() + await expect(second).toHaveAttribute("aria-selected", "true") + + await second.hover() + await page + .getByRole("button", { name: /close terminal/i }) + .nth(1) + .click({ force: true }) + + const first = tabs.filter({ hasText: /Terminal 1/ }).first() + await expect(tabs).toHaveCount(1) + await expect(first).toHaveAttribute("aria-selected", "true") + await expect + .poll( + async () => { + const state = await store(page, key) + return { + count: state?.all.length ?? 0, + first: state?.all.some((item) => item.titleNumber === 1) ?? false, + } + }, + { timeout: 15_000 }, + ) + .toEqual({ count: 1, first: true }) + }) +}) diff --git a/packages/app/e2e/utils.ts b/packages/app/e2e/utils.ts index e015a1e9b9..0dbc5f8b5a 100644 --- a/packages/app/e2e/utils.ts +++ b/packages/app/e2e/utils.ts @@ -1,5 +1,5 @@ import { createOpencodeClient } from "@opencode-ai/sdk/v2/client" -import { base64Encode } from "@opencode-ai/util/encode" +import { base64Encode, checksum } from "@opencode-ai/util/encode" export const serverHost = process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1" export const serverPort = process.env.PLAYWRIGHT_SERVER_PORT ?? "4096" @@ -7,6 +7,22 @@ export const serverPort = process.env.PLAYWRIGHT_SERVER_PORT ?? "4096" export const serverUrl = `http://${serverHost}:${serverPort}` export const serverName = `${serverHost}:${serverPort}` +const localHosts = ["127.0.0.1", "localhost"] + +const serverLabels = (() => { + const url = new URL(serverUrl) + if (!localHosts.includes(url.hostname)) return [serverName] + return localHosts.map((host) => `${host}:${url.port}`) +})() + +export const serverNames = [...new Set(serverLabels)] + +export const serverUrls = serverNames.map((name) => `http://${name}`) + +const escape = (value: string) => value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&") + +export const serverNamePattern = new RegExp(`(?:${serverNames.map(escape).join("|")})`) + export const modKey = process.platform === "darwin" ? "Meta" : "Control" export const terminalToggleKey = "Control+Backquote" @@ -14,6 +30,12 @@ export function createSdk(directory?: string) { return createOpencodeClient({ baseUrl: serverUrl, directory, throwOnError: true }) } +export async function resolveDirectory(directory: string) { + return createSdk(directory) + .path.get() + .then((x) => x.data?.directory ?? directory) +} + export async function getWorktree() { const sdk = createSdk() const result = await sdk.path.get() @@ -33,3 +55,9 @@ export function dirPath(directory: string) { export function sessionPath(directory: string, sessionID?: string) { return `${dirPath(directory)}/session${sessionID ? `/${sessionID}` : ""}` } + +export function workspacePersistKey(directory: string, key: string) { + const head = directory.slice(0, 12) || "workspace" + const sum = checksum(directory) ?? "0" + return `opencode.workspace.${head}.${sum}.dat:workspace:${key}` +} diff --git a/packages/app/package.json b/packages/app/package.json index 809a285374..f87bd978f6 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/app", - "version": "1.2.19", + "version": "1.2.21", "description": "", "type": "module", "exports": { diff --git a/packages/app/src/components/prompt-input.tsx b/packages/app/src/components/prompt-input.tsx index c9c8bc6b44..532edd3bcd 100644 --- a/packages/app/src/components/prompt-input.tsx +++ b/packages/app/src/components/prompt-input.tsx @@ -244,7 +244,6 @@ export const PromptInput: Component = (props) => { draggingType: "image" | "@mention" | null mode: "normal" | "shell" applyingHistory: boolean - pendingAutoAccept: boolean }>({ popover: null, historyIndex: -1, @@ -253,7 +252,6 @@ export const PromptInput: Component = (props) => { draggingType: null, mode: "normal", applyingHistory: false, - pendingAutoAccept: false, }) const buttonsSpring = useSpring(() => (store.mode === "normal" ? 1 : 0), { visualDuration: 0.2, bounce: 0 }) @@ -306,12 +304,6 @@ export const PromptInput: Component = (props) => { }), ) - createEffect( - on(sessionKey, () => { - setStore("pendingAutoAccept", false) - }), - ) - const historyComments = () => { const byID = new Map(comments.all().map((item) => [`${item.file}\n${item.id}`, item] as const)) return prompt.context.items().flatMap((item) => { @@ -961,7 +953,7 @@ export const PromptInput: Component = (props) => { const variants = createMemo(() => ["default", ...local.model.variant.list()]) const accepting = createMemo(() => { const id = params.id - if (!id) return store.pendingAutoAccept + if (!id) return permission.isAutoAcceptingDirectory(sdk.directory) return permission.isAutoAccepting(id, sdk.directory) }) @@ -1211,9 +1203,9 @@ export const PromptInput: Component = (props) => { aria-multiline="true" aria-label={placeholder()} contenteditable="true" - autocapitalize="off" - autocorrect="off" - spellcheck={false} + autocapitalize={store.mode === "normal" ? "sentences" : "off"} + autocorrect={store.mode === "normal" ? "on" : "off"} + spellcheck={store.mode === "normal"} onInput={handleInput} onPaste={handlePaste} onCompositionStart={() => setComposing(true)} @@ -1336,7 +1328,7 @@ export const PromptInput: Component = (props) => { variant="ghost" onClick={() => { if (!params.id) { - setStore("pendingAutoAccept", (value) => !value) + permission.toggleAutoAcceptDirectory(sdk.directory) return } permission.toggleAutoAccept(params.id, sdk.directory) diff --git a/packages/app/src/components/prompt-input/submit.test.ts b/packages/app/src/components/prompt-input/submit.test.ts index c633525a28..4109417d2b 100644 --- a/packages/app/src/components/prompt-input/submit.test.ts +++ b/packages/app/src/components/prompt-input/submit.test.ts @@ -6,10 +6,19 @@ let createPromptSubmit: typeof import("./submit").createPromptSubmit const createdClients: string[] = [] const createdSessions: string[] = [] const enabledAutoAccept: Array<{ sessionID: string; directory: string }> = [] +const optimistic: Array<{ + message: { + agent: string + model: { providerID: string; modelID: string } + variant?: string + } +}> = [] const sentShell: string[] = [] const syncedDirectories: string[] = [] +let params: { id?: string } = {} let selected = "/repo/worktree-a" +let variant: string | undefined const promptValue: Prompt = [{ type: "text", content: "ls", start: 0, end: 2 }] @@ -26,6 +35,7 @@ const clientFor = (directory: string) => { return { data: undefined } }, prompt: async () => ({ data: undefined }), + promptAsync: async () => ({ data: undefined }), command: async () => ({ data: undefined }), abort: async () => ({ data: undefined }), }, @@ -40,7 +50,7 @@ beforeAll(async () => { mock.module("@solidjs/router", () => ({ useNavigate: () => () => undefined, - useParams: () => ({}), + useParams: () => params, })) mock.module("@opencode-ai/sdk/v2/client", () => ({ @@ -62,7 +72,7 @@ beforeAll(async () => { useLocal: () => ({ model: { current: () => ({ id: "model", provider: { id: "provider" } }), - variant: { current: () => undefined }, + variant: { current: () => variant }, }, agent: { current: () => ({ name: "agent" }), @@ -118,7 +128,11 @@ beforeAll(async () => { data: { command: [] }, session: { optimistic: { - add: () => undefined, + add: (value: { + message: { agent: string; model: { providerID: string; modelID: string }; variant?: string } + }) => { + optimistic.push(value) + }, remove: () => undefined, }, }, @@ -155,9 +169,12 @@ beforeEach(() => { createdClients.length = 0 createdSessions.length = 0 enabledAutoAccept.length = 0 + optimistic.length = 0 + params = {} sentShell.length = 0 syncedDirectories.length = 0 selected = "/repo/worktree-a" + variant = undefined }) describe("prompt submit worktree selection", () => { @@ -219,4 +236,39 @@ describe("prompt submit worktree selection", () => { expect(enabledAutoAccept).toEqual([{ sessionID: "session-1", directory: "/repo/worktree-a" }]) }) + + test("includes the selected variant on optimistic prompts", async () => { + params = { id: "session-1" } + variant = "high" + + const submit = createPromptSubmit({ + info: () => ({ id: "session-1" }), + imageAttachments: () => [], + commentCount: () => 0, + autoAccept: () => false, + mode: () => "normal", + working: () => false, + editor: () => undefined, + queueScroll: () => undefined, + promptLength: (value) => value.reduce((sum, part) => sum + ("content" in part ? part.content.length : 0), 0), + addToHistory: () => undefined, + resetHistoryNavigation: () => undefined, + setMode: () => undefined, + setPopover: () => undefined, + onSubmit: () => undefined, + }) + + const event = { preventDefault: () => undefined } as unknown as Event + + await submit.handleSubmit(event) + + expect(optimistic).toHaveLength(1) + expect(optimistic[0]).toMatchObject({ + message: { + agent: "agent", + model: { providerID: "provider", modelID: "model" }, + variant: "high", + }, + }) + }) }) diff --git a/packages/app/src/components/prompt-input/submit.ts b/packages/app/src/components/prompt-input/submit.ts index db1b5a5ca1..fee6b070d9 100644 --- a/packages/app/src/components/prompt-input/submit.ts +++ b/packages/app/src/components/prompt-input/submit.ts @@ -316,6 +316,7 @@ export function createPromptSubmit(input: PromptSubmitInput) { time: { created: Date.now() }, agent, model, + variant, } const addOptimisticMessage = () => diff --git a/packages/app/src/components/session/session-header.tsx b/packages/app/src/components/session/session-header.tsx index bb4d981250..9b4551584c 100644 --- a/packages/app/src/components/session/session-header.tsx +++ b/packages/app/src/components/session/session-header.tsx @@ -303,7 +303,12 @@ export function SessionHeader() { }) const canOpen = createMemo(() => platform.platform === "desktop" && !!platform.openPath && server.isLocal()) - const current = createMemo(() => options().find((o) => o.id === prefs.app) ?? options()[0]) + const current = createMemo( + () => + options().find((o) => o.id === prefs.app) ?? + options()[0] ?? + ({ id: "finder", label: fileManager().label, icon: fileManager().icon } as const), + ) const opening = createMemo(() => openRequest.app !== undefined) const selectApp = (app: OpenApp) => { diff --git a/packages/app/src/components/session/session-new-view.tsx b/packages/app/src/components/session/session-new-view.tsx index f2ecd51501..5eb9314e8f 100644 --- a/packages/app/src/components/session/session-new-view.tsx +++ b/packages/app/src/components/session/session-new-view.tsx @@ -8,8 +8,7 @@ import { getDirectory, getFilename } from "@opencode-ai/util/path" const MAIN_WORKTREE = "main" const CREATE_WORKTREE = "create" -const ROOT_CLASS = - "size-full flex flex-col justify-end items-start gap-4 flex-[1_0_0] self-stretch max-w-200 mx-auto 2xl:max-w-[1000px] px-6 pb-16" +const ROOT_CLASS = "size-full flex flex-col" interface NewSessionViewProps { worktree: string @@ -50,33 +49,40 @@ export function NewSessionView(props: NewSessionViewProps) { return (
-
{language.t("command.session.new")}
-
- -
- {getDirectory(projectRoot())} - {getFilename(projectRoot())} +
+
+
+
{language.t("session.new.title")}
+
+
+
+ {getDirectory(projectRoot())} + {getFilename(projectRoot())} +
+
+
+ +
+ {label(current())} +
+
+ + {(project) => ( +
+
+ {language.t("session.new.lastModified")}  + + {DateTime.fromMillis(project().time.updated ?? project().time.created) + .setLocale(language.intl()) + .toRelative()} + +
+
+ )} +
+
-
- -
{label(current())}
-
- - {(project) => ( -
- -
- {language.t("session.new.lastModified")}  - - {DateTime.fromMillis(project().time.updated ?? project().time.created) - .setLocale(language.intl()) - .toRelative()} - -
-
- )} -
) } diff --git a/packages/app/src/components/titlebar.tsx b/packages/app/src/components/titlebar.tsx index c2b5a1ef44..b45f811501 100644 --- a/packages/app/src/components/titlebar.tsx +++ b/packages/app/src/components/titlebar.tsx @@ -155,7 +155,7 @@ export function Titlebar() { return (
-
+
= { } const localeMatchers: Array<{ locale: Locale; match: (language: string) => boolean }> = [ + { locale: "en", match: (language) => language.startsWith("en") }, { locale: "zht", match: (language) => language.startsWith("zh") && language.includes("hant") }, { locale: "zh", match: (language) => language.startsWith("zh") }, { locale: "ko", match: (language) => language.startsWith("ko") }, @@ -217,6 +218,7 @@ export const { use: useLanguage, provider: LanguageProvider } = createSimpleCont ) const locale = createMemo(() => normalizeLocale(store.locale)) + console.log("locale", locale()) const intl = createMemo(() => INTL[locale()]) const dict = createMemo(() => DICT[locale()]) diff --git a/packages/app/src/context/permission-auto-respond.test.ts b/packages/app/src/context/permission-auto-respond.test.ts index 2e4cf4fafb..7556113005 100644 --- a/packages/app/src/context/permission-auto-respond.test.ts +++ b/packages/app/src/context/permission-auto-respond.test.ts @@ -1,7 +1,7 @@ import { describe, expect, test } from "bun:test" import type { PermissionRequest, Session } from "@opencode-ai/sdk/v2/client" import { base64Encode } from "@opencode-ai/util/encode" -import { autoRespondsPermission } from "./permission-auto-respond" +import { autoRespondsPermission, isDirectoryAutoAccepting } from "./permission-auto-respond" const session = (input: { id: string; parentID?: string }) => ({ @@ -60,4 +60,43 @@ describe("autoRespondsPermission", () => { expect(autoRespondsPermission(autoAccept, sessions, permission("child"), directory)).toBe(true) }) + + test("falls back to directory-level auto-accept", () => { + const directory = "/tmp/project" + const sessions = [session({ id: "root" })] + const autoAccept = { + [`${base64Encode(directory)}/*`]: true, + } + + expect(autoRespondsPermission(autoAccept, sessions, permission("root"), directory)).toBe(true) + }) + + test("session-level override takes precedence over directory-level", () => { + const directory = "/tmp/project" + const sessions = [session({ id: "root" })] + const autoAccept = { + [`${base64Encode(directory)}/*`]: true, + [`${base64Encode(directory)}/root`]: false, + } + + expect(autoRespondsPermission(autoAccept, sessions, permission("root"), directory)).toBe(false) + }) +}) + +describe("isDirectoryAutoAccepting", () => { + test("returns true when directory key is set", () => { + const directory = "/tmp/project" + const autoAccept = { [`${base64Encode(directory)}/*`]: true } + expect(isDirectoryAutoAccepting(autoAccept, directory)).toBe(true) + }) + + test("returns false when directory key is not set", () => { + expect(isDirectoryAutoAccepting({}, "/tmp/project")).toBe(false) + }) + + test("returns false when directory key is explicitly false", () => { + const directory = "/tmp/project" + const autoAccept = { [`${base64Encode(directory)}/*`]: false } + expect(isDirectoryAutoAccepting(autoAccept, directory)).toBe(false) + }) }) diff --git a/packages/app/src/context/permission-auto-respond.ts b/packages/app/src/context/permission-auto-respond.ts index 727ccc9375..b206deedff 100644 --- a/packages/app/src/context/permission-auto-respond.ts +++ b/packages/app/src/context/permission-auto-respond.ts @@ -5,9 +5,19 @@ export function acceptKey(sessionID: string, directory?: string) { return `${base64Encode(directory)}/${sessionID}` } +export function directoryAcceptKey(directory: string) { + return `${base64Encode(directory)}/*` +} + function accepted(autoAccept: Record, sessionID: string, directory?: string) { const key = acceptKey(sessionID, directory) - return autoAccept[key] ?? autoAccept[sessionID] + const directoryKey = directory ? directoryAcceptKey(directory) : undefined + return autoAccept[key] ?? autoAccept[sessionID] ?? (directoryKey ? autoAccept[directoryKey] : undefined) +} + +export function isDirectoryAutoAccepting(autoAccept: Record, directory: string) { + const key = directoryAcceptKey(directory) + return autoAccept[key] ?? false } function sessionLineage(session: { id: string; parentID?: string }[], sessionID: string) { diff --git a/packages/app/src/context/permission.tsx b/packages/app/src/context/permission.tsx index 73ee08c9ac..672f84f82a 100644 --- a/packages/app/src/context/permission.tsx +++ b/packages/app/src/context/permission.tsx @@ -1,4 +1,4 @@ -import { createMemo, onCleanup } from "solid-js" +import { createEffect, createMemo, onCleanup } from "solid-js" import { createStore, produce } from "solid-js/store" import { createSimpleContext } from "@opencode-ai/ui/context" import type { PermissionRequest } from "@opencode-ai/sdk/v2/client" @@ -7,7 +7,12 @@ import { useGlobalSDK } from "@/context/global-sdk" import { useGlobalSync } from "./global-sync" import { useParams } from "@solidjs/router" import { decode64 } from "@/utils/base64" -import { acceptKey, autoRespondsPermission } from "./permission-auto-respond" +import { + acceptKey, + directoryAcceptKey, + isDirectoryAutoAccepting, + autoRespondsPermission, +} from "./permission-auto-respond" type PermissionRespondFn = (input: { sessionID: string @@ -76,6 +81,25 @@ export const { use: usePermission, provider: PermissionProvider } = createSimple }), ) + // When config has permission: "allow", auto-enable directory-level auto-accept + createEffect(() => { + if (!ready()) return + const directory = decode64(params.dir) + if (!directory) return + const [childStore] = globalSync.child(directory) + const perm = childStore.config.permission + if (typeof perm === "string" && perm === "allow") { + const key = directoryAcceptKey(directory) + if (store.autoAccept[key] === undefined) { + setStore( + produce((draft) => { + draft.autoAccept[key] = true + }), + ) + } + } + }) + const MAX_RESPONDED = 1000 const RESPONDED_TTL_MS = 60 * 60 * 1000 const responded = new Map() @@ -119,6 +143,10 @@ export const { use: usePermission, provider: PermissionProvider } = createSimple return autoRespondsPermission(store.autoAccept, session, { sessionID }, directory) } + function isAutoAcceptingDirectory(directory: string) { + return isDirectoryAutoAccepting(store.autoAccept, directory) + } + function shouldAutoRespond(permission: PermissionRequest, directory?: string) { const session = directory ? globalSync.child(directory, { bootstrap: false })[0].session : [] return autoRespondsPermission(store.autoAccept, session, permission, directory) @@ -142,6 +170,36 @@ export const { use: usePermission, provider: PermissionProvider } = createSimple }) onCleanup(unsubscribe) + function enableDirectory(directory: string) { + const key = directoryAcceptKey(directory) + setStore( + produce((draft) => { + draft.autoAccept[key] = true + }), + ) + + globalSDK.client.permission + .list({ directory }) + .then((x) => { + if (!isAutoAcceptingDirectory(directory)) return + for (const perm of x.data ?? []) { + if (!perm?.id) continue + if (!shouldAutoRespond(perm, directory)) continue + respondOnce(perm, directory) + } + }) + .catch(() => undefined) + } + + function disableDirectory(directory: string) { + const key = directoryAcceptKey(directory) + setStore( + produce((draft) => { + draft.autoAccept[key] = false + }), + ) + } + function enable(sessionID: string, directory: string) { const key = acceptKey(sessionID, directory) const version = bumpEnableVersion(sessionID, directory) @@ -185,6 +243,7 @@ export const { use: usePermission, provider: PermissionProvider } = createSimple return shouldAutoRespond(permission, directory) }, isAutoAccepting, + isAutoAcceptingDirectory, toggleAutoAccept(sessionID: string, directory: string) { if (isAutoAccepting(sessionID, directory)) { disable(sessionID, directory) @@ -193,6 +252,13 @@ export const { use: usePermission, provider: PermissionProvider } = createSimple enable(sessionID, directory) }, + toggleAutoAcceptDirectory(directory: string) { + if (isAutoAcceptingDirectory(directory)) { + disableDirectory(directory) + return + } + enableDirectory(directory) + }, enableAutoAccept(sessionID: string, directory: string) { if (isAutoAccepting(sessionID, directory)) return enable(sessionID, directory) @@ -201,6 +267,11 @@ export const { use: usePermission, provider: PermissionProvider } = createSimple disable(sessionID, directory) }, permissionsEnabled, + isPermissionAllowAll(directory: string) { + const [childStore] = globalSync.child(directory) + const perm = childStore.config.permission + return typeof perm === "string" && perm === "allow" + }, } }, }) diff --git a/packages/app/src/context/sync.tsx b/packages/app/src/context/sync.tsx index ed54751c3c..562a2d19ce 100644 --- a/packages/app/src/context/sync.tsx +++ b/packages/app/src/context/sync.tsx @@ -199,6 +199,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ parts: Part[] agent: string model: { providerID: string; modelID: string } + variant?: string }) { const message: Message = { id: input.messageID, @@ -207,6 +208,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ time: { created: Date.now() }, agent: input.agent, model: input.model, + variant: input.variant, } const [, setStore] = target() setOptimisticAdd(setStore as (...args: unknown[]) => void, { diff --git a/packages/app/src/i18n/ar.ts b/packages/app/src/i18n/ar.ts index 16f2fbf492..c9b92db501 100644 --- a/packages/app/src/i18n/ar.ts +++ b/packages/app/src/i18n/ar.ts @@ -456,6 +456,7 @@ export const dict = { "session.todo.title": "المهام", "session.todo.collapse": "طي", "session.todo.expand": "توسيع", + "session.new.title": "ابنِ أي شيء", "session.new.worktree.main": "الفرع الرئيسي", "session.new.worktree.mainWithBranch": "الفرع الرئيسي ({{branch}})", "session.new.worktree.create": "إنشاء شجرة عمل جديدة", diff --git a/packages/app/src/i18n/br.ts b/packages/app/src/i18n/br.ts index 26cf433e0e..951edf0a5c 100644 --- a/packages/app/src/i18n/br.ts +++ b/packages/app/src/i18n/br.ts @@ -459,6 +459,7 @@ export const dict = { "session.todo.title": "Tarefas", "session.todo.collapse": "Recolher", "session.todo.expand": "Expandir", + "session.new.title": "Crie qualquer coisa", "session.new.worktree.main": "Branch principal", "session.new.worktree.mainWithBranch": "Branch principal ({{branch}})", "session.new.worktree.create": "Criar novo worktree", diff --git a/packages/app/src/i18n/bs.ts b/packages/app/src/i18n/bs.ts index 6c8198bd71..e8bdcde596 100644 --- a/packages/app/src/i18n/bs.ts +++ b/packages/app/src/i18n/bs.ts @@ -515,6 +515,7 @@ export const dict = { "session.todo.collapse": "Sažmi", "session.todo.expand": "Proširi", + "session.new.title": "Napravi bilo šta", "session.new.worktree.main": "Glavna grana", "session.new.worktree.mainWithBranch": "Glavna grana ({{branch}})", "session.new.worktree.create": "Kreiraj novi worktree", diff --git a/packages/app/src/i18n/da.ts b/packages/app/src/i18n/da.ts index 11da681760..5ea52a5c92 100644 --- a/packages/app/src/i18n/da.ts +++ b/packages/app/src/i18n/da.ts @@ -510,6 +510,7 @@ export const dict = { "session.todo.collapse": "Skjul", "session.todo.expand": "Udvid", + "session.new.title": "Byg hvad som helst", "session.new.worktree.main": "Hovedgren", "session.new.worktree.mainWithBranch": "Hovedgren ({{branch}})", "session.new.worktree.create": "Opret nyt worktree", diff --git a/packages/app/src/i18n/de.ts b/packages/app/src/i18n/de.ts index 51b9ec3531..a6cf8045c0 100644 --- a/packages/app/src/i18n/de.ts +++ b/packages/app/src/i18n/de.ts @@ -467,6 +467,7 @@ export const dict = { "session.todo.title": "Aufgaben", "session.todo.collapse": "Einklappen", "session.todo.expand": "Ausklappen", + "session.new.title": "Baue, was du willst", "session.new.worktree.main": "Haupt-Branch", "session.new.worktree.mainWithBranch": "Haupt-Branch ({{branch}})", "session.new.worktree.create": "Neuen Worktree erstellen", diff --git a/packages/app/src/i18n/en.ts b/packages/app/src/i18n/en.ts index 7e95fd739d..97a572f1cf 100644 --- a/packages/app/src/i18n/en.ts +++ b/packages/app/src/i18n/en.ts @@ -511,11 +511,13 @@ export const dict = { "session.review.change.other": "Changes", "session.review.loadingChanges": "Loading changes...", "session.review.empty": "No changes in this session yet", - "session.review.noVcs": "No git VCS detected, so session changes will not be detected", + "session.review.noVcs": "No Git Version Control System detected, changes not displayed", + "session.review.noSnapshot": "Snapshot tracking is disabled in config, so session changes are unavailable", "session.review.noChanges": "No changes", "session.files.selectToOpen": "Select a file to open", "session.files.all": "All files", + "session.files.empty": "No files", "session.files.binaryContent": "Binary file (content cannot be displayed)", "session.messages.renderEarlier": "Render earlier messages", @@ -529,6 +531,7 @@ export const dict = { "session.todo.collapse": "Collapse", "session.todo.expand": "Expand", + "session.new.title": "Build anything", "session.new.worktree.main": "Main branch", "session.new.worktree.mainWithBranch": "Main branch ({{branch}})", "session.new.worktree.create": "Create new worktree", diff --git a/packages/app/src/i18n/es.ts b/packages/app/src/i18n/es.ts index 2665a80850..77ef7970c4 100644 --- a/packages/app/src/i18n/es.ts +++ b/packages/app/src/i18n/es.ts @@ -516,6 +516,7 @@ export const dict = { "session.todo.collapse": "Contraer", "session.todo.expand": "Expandir", + "session.new.title": "Construye lo que quieras", "session.new.worktree.main": "Rama principal", "session.new.worktree.mainWithBranch": "Rama principal ({{branch}})", "session.new.worktree.create": "Crear nuevo árbol de trabajo", diff --git a/packages/app/src/i18n/fr.ts b/packages/app/src/i18n/fr.ts index 1e67db1933..c887f9ee8b 100644 --- a/packages/app/src/i18n/fr.ts +++ b/packages/app/src/i18n/fr.ts @@ -463,6 +463,7 @@ export const dict = { "session.todo.title": "Tâches", "session.todo.collapse": "Réduire", "session.todo.expand": "Développer", + "session.new.title": "Créez ce que vous voulez", "session.new.worktree.main": "Branche principale", "session.new.worktree.mainWithBranch": "Branche principale ({{branch}})", "session.new.worktree.create": "Créer un nouvel arbre de travail", diff --git a/packages/app/src/i18n/ja.ts b/packages/app/src/i18n/ja.ts index ecd38d3324..9ddb6baf4a 100644 --- a/packages/app/src/i18n/ja.ts +++ b/packages/app/src/i18n/ja.ts @@ -457,6 +457,7 @@ export const dict = { "session.todo.title": "ToDo", "session.todo.collapse": "折りたたむ", "session.todo.expand": "展開", + "session.new.title": "何でも作る", "session.new.worktree.main": "メインブランチ", "session.new.worktree.mainWithBranch": "メインブランチ ({{branch}})", "session.new.worktree.create": "新しいワークツリーを作成", diff --git a/packages/app/src/i18n/ko.ts b/packages/app/src/i18n/ko.ts index 8f54b8abdc..1e35106d1b 100644 --- a/packages/app/src/i18n/ko.ts +++ b/packages/app/src/i18n/ko.ts @@ -459,6 +459,7 @@ export const dict = { "session.todo.title": "할 일", "session.todo.collapse": "접기", "session.todo.expand": "펼치기", + "session.new.title": "무엇이든 만들기", "session.new.worktree.main": "메인 브랜치", "session.new.worktree.mainWithBranch": "메인 브랜치 ({{branch}})", "session.new.worktree.create": "새 작업 트리 생성", diff --git a/packages/app/src/i18n/no.ts b/packages/app/src/i18n/no.ts index 0c94046eb0..d9dac8ee55 100644 --- a/packages/app/src/i18n/no.ts +++ b/packages/app/src/i18n/no.ts @@ -516,6 +516,7 @@ export const dict = { "session.todo.collapse": "Skjul", "session.todo.expand": "Utvid", + "session.new.title": "Bygg hva som helst", "session.new.worktree.main": "Hovedgren", "session.new.worktree.mainWithBranch": "Hovedgren ({{branch}})", "session.new.worktree.create": "Opprett nytt worktree", diff --git a/packages/app/src/i18n/pl.ts b/packages/app/src/i18n/pl.ts index 59c0513be6..b63fe5ee40 100644 --- a/packages/app/src/i18n/pl.ts +++ b/packages/app/src/i18n/pl.ts @@ -458,6 +458,7 @@ export const dict = { "session.todo.title": "Zadania", "session.todo.collapse": "Zwiń", "session.todo.expand": "Rozwiń", + "session.new.title": "Zbuduj cokolwiek", "session.new.worktree.main": "Główna gałąź", "session.new.worktree.mainWithBranch": "Główna gałąź ({{branch}})", "session.new.worktree.create": "Utwórz nowe drzewo robocze", diff --git a/packages/app/src/i18n/ru.ts b/packages/app/src/i18n/ru.ts index 2071eaae7b..aadb926d27 100644 --- a/packages/app/src/i18n/ru.ts +++ b/packages/app/src/i18n/ru.ts @@ -514,6 +514,7 @@ export const dict = { "session.todo.collapse": "Свернуть", "session.todo.expand": "Развернуть", + "session.new.title": "Создавайте что угодно", "session.new.worktree.main": "Основная ветка", "session.new.worktree.mainWithBranch": "Основная ветка ({{branch}})", "session.new.worktree.create": "Создать новый worktree", diff --git a/packages/app/src/i18n/th.ts b/packages/app/src/i18n/th.ts index 9871555536..6a25a356a9 100644 --- a/packages/app/src/i18n/th.ts +++ b/packages/app/src/i18n/th.ts @@ -511,6 +511,7 @@ export const dict = { "session.todo.collapse": "ย่อ", "session.todo.expand": "ขยาย", + "session.new.title": "สร้างอะไรก็ได้", "session.new.worktree.main": "สาขาหลัก", "session.new.worktree.mainWithBranch": "สาขาหลัก ({{branch}})", "session.new.worktree.create": "สร้าง worktree ใหม่", diff --git a/packages/app/src/i18n/tr.ts b/packages/app/src/i18n/tr.ts index 701ee09192..50e5598324 100644 --- a/packages/app/src/i18n/tr.ts +++ b/packages/app/src/i18n/tr.ts @@ -523,6 +523,7 @@ export const dict = { "session.todo.collapse": "Daralt", "session.todo.expand": "Genişlet", + "session.new.title": "İstediğini yap", "session.new.worktree.main": "Ana dal", "session.new.worktree.mainWithBranch": "Ana dal ({{branch}})", "session.new.worktree.create": "Yeni çalışma ağacı oluştur", diff --git a/packages/app/src/i18n/zh.ts b/packages/app/src/i18n/zh.ts index e72d4c0e3b..1f88a82223 100644 --- a/packages/app/src/i18n/zh.ts +++ b/packages/app/src/i18n/zh.ts @@ -510,6 +510,7 @@ export const dict = { "session.todo.title": "待办事项", "session.todo.collapse": "折叠", "session.todo.expand": "展开", + "session.new.title": "构建任何东西", "session.new.worktree.main": "主分支", "session.new.worktree.mainWithBranch": "主分支({{branch}})", "session.new.worktree.create": "创建新的 worktree", diff --git a/packages/app/src/i18n/zht.ts b/packages/app/src/i18n/zht.ts index 70421dfe10..a75e8ef47a 100644 --- a/packages/app/src/i18n/zht.ts +++ b/packages/app/src/i18n/zht.ts @@ -507,6 +507,7 @@ export const dict = { "session.todo.collapse": "折疊", "session.todo.expand": "展開", + "session.new.title": "建構任何東西", "session.new.worktree.main": "主分支", "session.new.worktree.mainWithBranch": "主分支 ({{branch}})", "session.new.worktree.create": "建立新的 worktree", diff --git a/packages/app/src/index.css b/packages/app/src/index.css index 4af87bca63..9e231e2d28 100644 --- a/packages/app/src/index.css +++ b/packages/app/src/index.css @@ -1 +1,29 @@ @import "@opencode-ai/ui/styles/tailwind"; + +@layer components { + [data-component="getting-started"] { + container-type: inline-size; + container-name: getting-started; + } + + [data-component="getting-started-actions"] { + display: flex; + flex-direction: column; + gap: 0.75rem; /* gap-3 */ + } + + [data-component="getting-started-actions"] > [data-component="button"] { + width: 100%; + } + + @container getting-started (min-width: 17rem) { + [data-component="getting-started-actions"] { + flex-direction: row; + align-items: center; + } + + [data-component="getting-started-actions"] > [data-component="button"] { + width: auto; + } + } +} diff --git a/packages/app/src/pages/directory-layout.tsx b/packages/app/src/pages/directory-layout.tsx index 71b52180f2..fdf321f2dc 100644 --- a/packages/app/src/pages/directory-layout.tsx +++ b/packages/app/src/pages/directory-layout.tsx @@ -1,26 +1,27 @@ -import { createEffect, createMemo, Show, type ParentProps } from "solid-js" +import { batch, createEffect, createMemo, Show, type ParentProps } from "solid-js" import { createStore } from "solid-js/store" -import { useNavigate, useParams } from "@solidjs/router" +import { useLocation, useNavigate, useParams } from "@solidjs/router" import { SDKProvider } from "@/context/sdk" import { SyncProvider, useSync } from "@/context/sync" import { LocalProvider } from "@/context/local" +import { useGlobalSDK } from "@/context/global-sdk" import { DataProvider } from "@opencode-ai/ui/context" +import { base64Encode } from "@opencode-ai/util/encode" import { decode64 } from "@/utils/base64" import { showToast } from "@opencode-ai/ui/toast" import { useLanguage } from "@/context/language" - function DirectoryDataProvider(props: ParentProps<{ directory: string }>) { - const params = useParams() const navigate = useNavigate() const sync = useSync() + const slug = createMemo(() => base64Encode(props.directory)) return ( navigate(`/${params.dir}/session/${sessionID}`)} - onSessionHref={(sessionID: string) => `/${params.dir}/session/${sessionID}`} + onNavigateToSession={(sessionID: string) => navigate(`/${slug()}/session/${sessionID}`)} + onSessionHref={(sessionID: string) => `/${slug()}/session/${sessionID}`} > {props.children} @@ -30,31 +31,63 @@ function DirectoryDataProvider(props: ParentProps<{ directory: string }>) { export default function Layout(props: ParentProps) { const params = useParams() const navigate = useNavigate() + const location = useLocation() const language = useLanguage() - const [store, setStore] = createStore({ invalid: "" }) - const directory = createMemo(() => { - return decode64(params.dir) ?? "" - }) + const globalSDK = useGlobalSDK() + const directory = createMemo(() => decode64(params.dir) ?? "") + const [state, setState] = createStore({ invalid: "", resolved: "" }) createEffect(() => { if (!params.dir) return - if (directory()) return - if (store.invalid === params.dir) return - setStore("invalid", params.dir) - showToast({ - variant: "error", - title: language.t("common.requestFailed"), - description: language.t("directory.error.invalidUrl"), - }) - navigate("/", { replace: true }) + const raw = directory() + if (!raw) { + if (state.invalid === params.dir) return + setState("invalid", params.dir) + showToast({ + variant: "error", + title: language.t("common.requestFailed"), + description: language.t("directory.error.invalidUrl"), + }) + navigate("/", { replace: true }) + return + } + + const current = params.dir + globalSDK + .createClient({ + directory: raw, + throwOnError: true, + }) + .path.get() + .then((x) => { + if (params.dir !== current) return + const next = x.data?.directory ?? raw + batch(() => { + setState("invalid", "") + setState("resolved", next) + }) + if (next === raw) return + const path = location.pathname.slice(current.length + 1) + navigate(`/${base64Encode(next)}${path}${location.search}${location.hash}`, { replace: true }) + }) + .catch(() => { + if (params.dir !== current) return + batch(() => { + setState("invalid", "") + setState("resolved", raw) + }) + }) }) + return ( - - - - {props.children} - - + + {(resolved) => ( + + + {props.children} + + + )} ) } diff --git a/packages/app/src/pages/layout.tsx b/packages/app/src/pages/layout.tsx index bd0315efbf..70114623e3 100644 --- a/packages/app/src/pages/layout.tsx +++ b/packages/app/src/pages/layout.tsx @@ -10,9 +10,8 @@ import { ParentProps, Show, untrack, - type JSX, } from "solid-js" -import { A, useNavigate, useParams } from "@solidjs/router" +import { useNavigate, useParams } from "@solidjs/router" import { useLayout, LocalProject } from "@/context/layout" import { useGlobalSync } from "@/context/global-sync" import { Persist, persisted } from "@/utils/persist" @@ -20,7 +19,6 @@ import { base64Encode } from "@opencode-ai/util/encode" import { decode64 } from "@/utils/base64" import { ResizeHandle } from "@opencode-ai/ui/resize-handle" import { Button } from "@opencode-ai/ui/button" -import { Icon } from "@opencode-ai/ui/icon" import { IconButton } from "@opencode-ai/ui/icon-button" import { Tooltip } from "@opencode-ai/ui/tooltip" import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu" @@ -59,7 +57,6 @@ import { Titlebar } from "@/components/titlebar" import { useServer } from "@/context/server" import { useLanguage, type Locale } from "@/context/language" import { - childMapByParent, displayName, effectiveWorkspaceOrder, errorMessage, @@ -96,6 +93,7 @@ export default function Layout(props: ParentProps) { workspaceName: {} as Record, workspaceBranchName: {} as Record>, workspaceExpanded: {} as Record, + gettingStartedDismissed: false, }), ) @@ -157,6 +155,8 @@ export default function Layout(props: ParentProps) { const isBusy = (directory: string) => !!state.busyWorkspaces[workspaceKey(directory)] const navLeave = { current: undefined as number | undefined } const [sortNow, setSortNow] = createSignal(Date.now()) + const [sizing, setSizing] = createSignal(false) + let sizet: number | undefined let sortNowInterval: ReturnType | undefined const sortNowTimeout = setTimeout( () => { @@ -169,7 +169,7 @@ export default function Layout(props: ParentProps) { const aim = createAim({ enabled: () => !layout.sidebar.opened(), active: () => state.hoverProject, - el: () => state.nav, + el: () => state.nav?.querySelector("[data-component='sidebar-rail']") ?? state.nav, onActivate: (directory) => { globalSync.child(directory) setState("hoverProject", directory) @@ -181,9 +181,23 @@ export default function Layout(props: ParentProps) { if (navLeave.current !== undefined) clearTimeout(navLeave.current) clearTimeout(sortNowTimeout) if (sortNowInterval) clearInterval(sortNowInterval) + if (sizet !== undefined) clearTimeout(sizet) + if (peekt !== undefined) clearTimeout(peekt) aim.reset() }) + onMount(() => { + const stop = () => setSizing(false) + window.addEventListener("pointerup", stop) + window.addEventListener("pointercancel", stop) + window.addEventListener("blur", stop) + onCleanup(() => { + window.removeEventListener("pointerup", stop) + window.removeEventListener("pointercancel", stop) + window.removeEventListener("blur", stop) + }) + }) + const sidebarHovering = createMemo(() => !layout.sidebar.opened() && state.hoverProject !== undefined) const sidebarExpanded = createMemo(() => layout.sidebar.opened() || sidebarHovering()) const setHoverProject = (value: string | undefined) => { @@ -194,12 +208,54 @@ export default function Layout(props: ParentProps) { const clearHoverProjectSoon = () => queueMicrotask(() => setHoverProject(undefined)) const setHoverSession = (id: string | undefined) => setState("hoverSession", id) + const disarm = () => { + if (navLeave.current === undefined) return + clearTimeout(navLeave.current) + navLeave.current = undefined + } + + const arm = () => { + if (layout.sidebar.opened()) return + if (state.hoverProject === undefined) return + disarm() + navLeave.current = window.setTimeout(() => { + navLeave.current = undefined + setHoverProject(undefined) + setState("hoverSession", undefined) + }, 300) + } + + const [peek, setPeek] = createSignal(undefined) + const [peeked, setPeeked] = createSignal(false) + let peekt: number | undefined + const hoverProjectData = createMemo(() => { const id = state.hoverProject if (!id) return return layout.projects.list().find((project) => project.worktree === id) }) + createEffect(() => { + const p = hoverProjectData() + if (p) { + if (peekt !== undefined) { + clearTimeout(peekt) + peekt = undefined + } + setPeek(p) + setPeeked(true) + return + } + + setPeeked(false) + if (peek() === undefined) return + if (peekt !== undefined) clearTimeout(peekt) + peekt = window.setTimeout(() => { + peekt = undefined + setPeek(undefined) + }, 180) + }) + createEffect(() => { if (!layout.sidebar.opened()) return setHoverProject(undefined) @@ -1125,6 +1181,12 @@ export default function Layout(props: ParentProps) { } const openSession = async (target: { directory: string; id: string }) => { if (!canOpen(target.directory)) return false + const [data] = globalSync.child(target.directory, { bootstrap: false }) + if (data.session.some((item) => item.id === target.id)) { + setStore("lastProjectSession", root, { directory: target.directory, id: target.id, at: Date.now() }) + navigateWithSidebarReset(`/${base64Encode(target.directory)}/session/${target.id}`) + return true + } const resolved = await globalSDK.client.session .get({ sessionID: target.id }) .then((x) => x.data) @@ -1815,7 +1877,8 @@ export default function Layout(props: ParentProps) { setHoverSession, } - const SidebarPanel = (panelProps: { project: LocalProject | undefined; mobile?: boolean }) => { + const SidebarPanel = (panelProps: { project: LocalProject | undefined; mobile?: boolean; merged?: boolean }) => { + const merged = createMemo(() => panelProps.mobile || (panelProps.merged ?? layout.sidebar.opened())) const projectName = createMemo(() => { const project = panelProps.project if (!project) return "" @@ -1841,12 +1904,19 @@ export default function Layout(props: ParentProps) { return (
- + {(p) => ( <>
@@ -1855,7 +1925,7 @@ export default function Layout(props: ParentProps) { renameProject(p, next)} + onSave={(next) => renameProject(p(), next)} class="text-14-medium text-text-strong truncate" displayClass="text-14-medium text-text-strong truncate" stopPropagation @@ -1864,7 +1934,7 @@ export default function Layout(props: ParentProps) { - {p.worktree.replace(homedir(), "~")} + {p().worktree.replace(homedir(), "~")}
@@ -1883,33 +1953,33 @@ export default function Layout(props: ParentProps) { icon="dot-grid" variant="ghost" data-action="project-menu" - data-project={base64Encode(p.worktree)} + data-project={base64Encode(p().worktree)} class="shrink-0 size-6 rounded-md data-[expanded]:bg-surface-base-active" classList={{ "opacity-0 group-hover/project:opacity-100 data-[expanded]:opacity-100": !panelProps.mobile, }} aria-label={language.t("common.moreOptions")} /> - + - showEditProjectDialog(p)}> + showEditProjectDialog(p())}> {language.t("common.edit")} toggleProjectWorkspaces(p)} + data-project={base64Encode(p().worktree)} + disabled={p().vcs !== "git" && !layout.sidebar.workspaces(p().worktree)()} + onSelect={() => toggleProjectWorkspaces(p())} > - {layout.sidebar.workspaces(p.worktree)() + {layout.sidebar.workspaces(p().worktree)() ? language.t("sidebar.workspaces.disable") : language.t("sidebar.workspaces.enable")} @@ -1920,8 +1990,8 @@ export default function Layout(props: ParentProps) { closeProject(p.worktree)} + data-project={base64Encode(p().worktree)} + onSelect={() => closeProject(p().worktree)} > {language.t("common.close")} @@ -1941,7 +2011,7 @@ export default function Layout(props: ParentProps) { size="large" icon="plus-small" class="w-full" - onClick={() => navigateWithSidebarReset(`/${base64Encode(p.worktree)}/session`)} + onClick={() => navigateWithSidebarReset(`/${base64Encode(p().worktree)}/session`)} > {language.t("command.session.new")} @@ -1949,7 +2019,7 @@ export default function Layout(props: ParentProps) {
@@ -1959,7 +2029,7 @@ export default function Layout(props: ParentProps) { > <>
-
@@ -1984,7 +2054,7 @@ export default function Layout(props: ParentProps) { @@ -2009,25 +2079,31 @@ export default function Layout(props: ParentProps) {
0 && providers.paid().length === 0), + hidden: store.gettingStartedDismissed || !(providers.all().length > 0 && providers.paid().length === 0), }} > -
-
-
{language.t("sidebar.gettingStarted.title")}
-
{language.t("sidebar.gettingStarted.line1")}
-
{language.t("sidebar.gettingStarted.line2")}
+
+
+
+
{language.t("sidebar.gettingStarted.title")}
+
+ {language.t("sidebar.gettingStarted.line1")} +
+
+ {language.t("sidebar.gettingStarted.line2")} +
+
+
+ + +
-
@@ -2037,33 +2113,27 @@ export default function Layout(props: ParentProps) { return (
-
+
+ + diff --git a/packages/app/src/pages/layout/sidebar-items.tsx b/packages/app/src/pages/layout/sidebar-items.tsx index e991d8225d..8dc03755e4 100644 --- a/packages/app/src/pages/layout/sidebar-items.tsx +++ b/packages/app/src/pages/layout/sidebar-items.tsx @@ -163,7 +163,6 @@ const SessionHoverPreview = (props: { gutter={16} shift={-2} trigger={props.trigger} - mount={!props.mobile ? props.nav() : undefined} open={props.hoverSession() === props.session.id} onOpenChange={(open) => props.setHoverSession(open ? props.session.id : undefined)} > diff --git a/packages/app/src/pages/layout/sidebar-project.tsx b/packages/app/src/pages/layout/sidebar-project.tsx index 3c3652e38f..fb66dcc975 100644 --- a/packages/app/src/pages/layout/sidebar-project.tsx +++ b/packages/app/src/pages/layout/sidebar-project.tsx @@ -5,8 +5,6 @@ import { Button } from "@opencode-ai/ui/button" import { ContextMenu } from "@opencode-ai/ui/context-menu" import { HoverCard } from "@opencode-ai/ui/hover-card" import { Icon } from "@opencode-ai/ui/icon" -import { IconButton } from "@opencode-ai/ui/icon-button" -import { Tooltip } from "@opencode-ai/ui/tooltip" import { createSortable } from "@thisbeyond/solid-dnd" import { useLayout, type LocalProject } from "@/context/layout" import { useGlobalSync } from "@/context/global-sync" @@ -137,7 +135,7 @@ const ProjectTile = (props: { > - + props.showEditProjectDialog(props.project)}> {props.language.t("common.edit")} @@ -194,21 +192,6 @@ const ProjectPreviewPanel = (props: {
{displayName(props.project)}
- - { - event.stopPropagation() - props.setOpen(false) - props.ctx.closeProject(props.project.worktree) - }} - /> -
{props.language.t("sidebar.project.recentSessions")}
diff --git a/packages/app/src/pages/layout/sidebar-shell.tsx b/packages/app/src/pages/layout/sidebar-shell.tsx index d813ef3e11..d3070e3749 100644 --- a/packages/app/src/pages/layout/sidebar-shell.tsx +++ b/packages/app/src/pages/layout/sidebar-shell.tsx @@ -1,4 +1,4 @@ -import { createMemo, For, Show, type Accessor, type JSX } from "solid-js" +import { createEffect, createMemo, For, Show, type Accessor, type JSX } from "solid-js" import { DragDropProvider, DragDropSensors, @@ -35,10 +35,22 @@ export const SidebarContent = (props: { }): JSX.Element => { const expanded = createMemo(() => sidebarExpanded(props.mobile, props.opened())) const placement = () => (props.mobile ? "bottom" : "right") + let panel: HTMLDivElement | undefined + + createEffect(() => { + const el = panel + if (!el) return + if (expanded()) { + el.removeAttribute("inert") + return + } + el.setAttribute("inert", "") + }) return ( -
+
@@ -100,7 +112,15 @@ export const SidebarContent = (props: {
- {props.renderPanel()} +
{ + panel = el + }} + classList={{ "flex h-full min-h-0 min-w-0 overflow-hidden": true, "pointer-events-none": !expanded() }} + aria-hidden={!expanded()} + > + {props.renderPanel()} +
) } diff --git a/packages/app/src/pages/layout/sidebar-workspace.tsx b/packages/app/src/pages/layout/sidebar-workspace.tsx index 43d99cf895..c317b9c5ef 100644 --- a/packages/app/src/pages/layout/sidebar-workspace.tsx +++ b/packages/app/src/pages/layout/sidebar-workspace.tsx @@ -182,7 +182,7 @@ const WorkspaceActions = (props: { aria-label={props.language.t("common.moreOptions")} /> - + { if (!props.pendingRename()) return @@ -249,7 +249,7 @@ const WorkspaceSessionList = (props: { loadMore: () => Promise language: ReturnType }): JSX.Element => ( -
@@ -968,23 +1038,6 @@ export default function Page() { tabs().setActive(next) }) - createEffect( - on( - () => layout.fileTree.opened(), - (opened, prev) => { - if (prev === undefined) return - if (!isDesktop()) return - - if (opened) { - const active = tabs().active() - const tab = active === "review" || (!active && hasReview()) ? "changes" : "all" - layout.fileTree.setTab(tab) - } - }, - { defer: true }, - ), - ) - createEffect(() => { const id = params.id if (!id) return @@ -1045,7 +1098,7 @@ export default function Page() { const updateScrollState = (el: HTMLDivElement) => { const max = el.scrollHeight - el.clientHeight const overflow = max > 1 - const bottom = !overflow || el.scrollTop >= max - 2 + const bottom = !overflow || Math.abs(el.scrollTop) <= 2 || !autoScroll.userScrolled() if (ui.scroll.overflow === overflow && ui.scroll.bottom === bottom) return setUi("scroll", { overflow, bottom }) @@ -1068,7 +1121,7 @@ export default function Page() { const resumeScroll = () => { setStore("messageId", undefined) - autoScroll.forceScrollToBottom() + autoScroll.smoothScrollToBottom() clearMessageHash() const el = scroller @@ -1136,13 +1189,11 @@ export default function Page() { const el = scroller const delta = next - dockHeight - const stick = el - ? !autoScroll.userScrolled() || el.scrollHeight - el.clientHeight - el.scrollTop < 10 + Math.max(0, delta) - : false + const stick = el ? Math.abs(el.scrollTop) < 10 + Math.max(0, delta) : false dockHeight = next - if (stick) autoScroll.forceScrollToBottom() + if (stick) autoScroll.smoothScrollToBottom() if (el) scheduleScrollState(el) scrollSpy.markDirty() @@ -1193,9 +1244,9 @@ export default function Page() { {/* Session panel */}
{ content = el @@ -1291,17 +1343,27 @@ export default function Page() { /> - +
size.start()}> + { + size.touch() + layout.session.resize(width) + }} + /> +
- +
diff --git a/packages/app/src/pages/session/composer/session-composer-region.tsx b/packages/app/src/pages/session/composer/session-composer-region.tsx index 93ea3d465c..18a02993b6 100644 --- a/packages/app/src/pages/session/composer/session-composer-region.tsx +++ b/packages/app/src/pages/session/composer/session-composer-region.tsx @@ -140,7 +140,7 @@ export function SessionComposerRegion(props: {
diff --git a/packages/app/src/pages/session/file-tabs.tsx b/packages/app/src/pages/session/file-tabs.tsx index 77643789d0..07df4305f0 100644 --- a/packages/app/src/pages/session/file-tabs.tsx +++ b/packages/app/src/pages/session/file-tabs.tsx @@ -446,9 +446,9 @@ export function FileTabContent(props: { tab: string }) { ) return ( - + { scroll = el restoreScroll() diff --git a/packages/app/src/pages/session/helpers.ts b/packages/app/src/pages/session/helpers.ts index 60b26cdf47..be9656900d 100644 --- a/packages/app/src/pages/session/helpers.ts +++ b/packages/app/src/pages/session/helpers.ts @@ -1,4 +1,5 @@ -import { batch } from "solid-js" +import { batch, createEffect, on, onCleanup, onMount, type Accessor } from "solid-js" +import { createStore } from "solid-js/store" export const focusTerminalById = (id: string) => { const wrapper = document.getElementById(`terminal-wrapper-${id}`) @@ -69,3 +70,104 @@ export const getTabReorderIndex = (tabs: readonly string[], from: string, to: st if (fromIndex === -1 || toIndex === -1 || fromIndex === toIndex) return undefined return toIndex } + +export const createSizing = () => { + const [state, setState] = createStore({ active: false }) + let t: number | undefined + + const stop = () => { + if (t !== undefined) { + clearTimeout(t) + t = undefined + } + setState("active", false) + } + + const start = () => { + if (t !== undefined) { + clearTimeout(t) + t = undefined + } + setState("active", true) + } + + onMount(() => { + window.addEventListener("pointerup", stop) + window.addEventListener("pointercancel", stop) + window.addEventListener("blur", stop) + onCleanup(() => { + window.removeEventListener("pointerup", stop) + window.removeEventListener("pointercancel", stop) + window.removeEventListener("blur", stop) + }) + }) + + onCleanup(() => { + if (t !== undefined) clearTimeout(t) + }) + + return { + active: () => state.active, + start, + touch() { + start() + t = window.setTimeout(stop, 120) + }, + } +} + +export type Sizing = ReturnType + +export const createPresence = (open: Accessor, wait = 200) => { + const [state, setState] = createStore({ + show: open(), + open: open(), + }) + let frame: number | undefined + let t: number | undefined + + const clear = () => { + if (frame !== undefined) { + cancelAnimationFrame(frame) + frame = undefined + } + if (t !== undefined) { + clearTimeout(t) + t = undefined + } + } + + createEffect( + on(open, (next) => { + clear() + + if (next) { + if (state.show) { + setState("open", true) + return + } + + setState({ show: true, open: false }) + frame = requestAnimationFrame(() => { + frame = undefined + setState("open", true) + }) + return + } + + if (!state.show) return + setState("open", false) + t = window.setTimeout(() => { + t = undefined + setState("show", false) + }, wait) + }), + ) + + onCleanup(clear) + + return { + show: () => state.show, + open: () => state.open, + } +} diff --git a/packages/app/src/pages/session/message-timeline.tsx b/packages/app/src/pages/session/message-timeline.tsx index f320a2ebbf..e93ca11a36 100644 --- a/packages/app/src/pages/session/message-timeline.tsx +++ b/packages/app/src/pages/session/message-timeline.tsx @@ -1,27 +1,31 @@ -import { For, createEffect, createMemo, on, onCleanup, Show, Index, type JSX } from "solid-js" -import { createStore, produce } from "solid-js/store" -import { useNavigate, useParams } from "@solidjs/router" +import { + For, + Index, + createEffect, + createMemo, + createSignal, + on, + onCleanup, + Show, + startTransition, + type JSX, +} from "solid-js" +import { createStore } from "solid-js/store" +import { useParams } from "@solidjs/router" import { Button } from "@opencode-ai/ui/button" import { FileIcon } from "@opencode-ai/ui/file-icon" import { Icon } from "@opencode-ai/ui/icon" -import { IconButton } from "@opencode-ai/ui/icon-button" -import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu" -import { Dialog } from "@opencode-ai/ui/dialog" -import { InlineInput } from "@opencode-ai/ui/inline-input" import { SessionTurn } from "@opencode-ai/ui/session-turn" import { ScrollView } from "@opencode-ai/ui/scroll-view" import type { AssistantMessage, Message as MessageType, Part, TextPart, UserMessage } from "@opencode-ai/sdk/v2" -import { showToast } from "@opencode-ai/ui/toast" import { Binary } from "@opencode-ai/util/binary" import { getFilename } from "@opencode-ai/util/path" import { shouldMarkBoundaryGesture, normalizeWheelDelta } from "@/pages/session/message-gesture" -import { SessionContextUsage } from "@/components/session-context-usage" -import { useDialog } from "@opencode-ai/ui/context/dialog" import { useLanguage } from "@/context/language" import { useSettings } from "@/context/settings" -import { useSDK } from "@/context/sdk" import { useSync } from "@/context/sync" import { parseCommentNote, readCommentMetadata } from "@/utils/comment-note" +import { SessionTimelineHeader } from "@/pages/session/session-timeline-header" type MessageComment = { path: string @@ -33,7 +37,9 @@ type MessageComment = { } const emptyMessages: MessageType[] = [] -const idle = { type: "idle" as const } + +const isDefaultSessionTitle = (title?: string) => + !!title && /^(New session - |Child session - )\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/.test(title) const messageComments = (parts: Part[]): MessageComment[] => parts.flatMap((part) => { @@ -110,6 +116,8 @@ function createTimelineStaging(input: TimelineStageInput) { completedSession: "", count: 0, }) + const [readySession, setReadySession] = createSignal("") + let active = "" const stagedCount = createMemo(() => { const total = input.messages().length @@ -134,23 +142,46 @@ function createTimelineStaging(input: TimelineStageInput) { cancelAnimationFrame(frame) frame = undefined } + const scheduleReady = (sessionKey: string) => { + if (input.sessionKey() !== sessionKey) return + if (readySession() === sessionKey) return + setReadySession(sessionKey) + } createEffect( on( () => [input.sessionKey(), input.turnStart() > 0, input.messages().length] as const, ([sessionKey, isWindowed, total]) => { + const switched = active !== sessionKey + if (switched) { + active = sessionKey + setReadySession("") + } + + const staging = state.activeSession === sessionKey && state.completedSession !== sessionKey + const shouldStage = isWindowed && total > input.config.init && state.completedSession !== sessionKey + + if (staging && !switched && shouldStage && frame !== undefined) return + cancel() - const shouldStage = - isWindowed && - total > input.config.init && - state.completedSession !== sessionKey && - state.activeSession !== sessionKey + + if (shouldStage) setReadySession("") if (!shouldStage) { - setState({ activeSession: "", count: total }) + setState({ + activeSession: "", + completedSession: isWindowed ? sessionKey : state.completedSession, + count: total, + }) + if (total <= 0) { + setReadySession("") + return + } + if (readySession() !== sessionKey) scheduleReady(sessionKey) return } let count = Math.min(total, input.config.init) + if (staging) count = Math.min(total, Math.max(count, state.count)) setState({ activeSession: sessionKey, count }) const step = () => { @@ -160,10 +191,11 @@ function createTimelineStaging(input: TimelineStageInput) { } const currentTotal = input.messages().length count = Math.min(currentTotal, count + input.config.batch) - setState("count", count) + startTransition(() => setState("count", count)) if (count >= currentTotal) { setState({ completedSession: sessionKey, activeSession: "" }) frame = undefined + scheduleReady(sessionKey) return } frame = requestAnimationFrame(step) @@ -177,9 +209,12 @@ function createTimelineStaging(input: TimelineStageInput) { const key = input.sessionKey() return state.activeSession === key && state.completedSession !== key }) + const ready = createMemo(() => readySession() === input.sessionKey()) - onCleanup(cancel) - return { messages: stagedUserMessages, isStaging } + onCleanup(() => { + cancel() + }) + return { messages: stagedUserMessages, isStaging, ready } } export function MessageTimeline(props: { @@ -196,6 +231,7 @@ export function MessageTimeline(props: { onScrollSpyScroll: () => void onTurnBackfillScroll: () => void onAutoScrollInteraction: (event: MouseEvent) => void + onPreserveScrollAnchor: (target: HTMLElement) => void centered: boolean setContentRef: (el: HTMLDivElement) => void turnStart: number @@ -210,14 +246,19 @@ export function MessageTimeline(props: { let touchGesture: number | undefined const params = useParams() - const navigate = useNavigate() - const sdk = useSDK() const sync = useSync() const settings = useSettings() - const dialog = useDialog() const language = useLanguage() - const rendered = createMemo(() => props.renderedUserMessages.map((message) => message.id)) + const trigger = (target: EventTarget | null) => { + const next = + target instanceof Element + ? target.closest('[data-slot="collapsible-trigger"], [data-slot="accordion-trigger"], [data-scroll-preserve]') + : undefined + if (!(next instanceof HTMLElement)) return + return next + } + const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`) const sessionID = createMemo(() => params.id) const sessionMessages = createMemo(() => { @@ -230,28 +271,20 @@ export function MessageTimeline(props: { (item): item is AssistantMessage => item.role === "assistant" && typeof item.time.completed !== "number", ), ) - const sessionStatus = createMemo(() => { - const id = sessionID() - if (!id) return idle - return sync.data.session_status[id] ?? idle - }) + const sessionStatus = createMemo(() => sync.data.session_status[sessionID() ?? ""]?.type ?? "idle") const activeMessageID = createMemo(() => { - const parentID = pending()?.parentID - if (parentID) { - const messages = sessionMessages() - const result = Binary.search(messages, parentID, (message) => message.id) - const message = result.found ? messages[result.index] : messages.find((item) => item.id === parentID) - if (message && message.role === "user") return message.id + const messages = sessionMessages() + const message = pending() + if (message?.parentID) { + const result = Binary.search(messages, message.parentID, (item) => item.id) + const parent = result.found ? messages[result.index] : messages.find((item) => item.id === message.parentID) + if (parent?.role === "user") return parent.id } - const status = sessionStatus() - if (status.type !== "idle") { - const messages = sessionMessages() - for (let i = messages.length - 1; i >= 0; i--) { - if (messages[i].role === "user") return messages[i].id - } + if (sessionStatus() === "idle") return undefined + for (let i = messages.length - 1; i >= 0; i--) { + if (messages[i].role === "user") return messages[i].id } - return undefined }) const info = createMemo(() => { @@ -259,9 +292,19 @@ export function MessageTimeline(props: { if (!id) return return sync.session.get(id) }) - const titleValue = createMemo(() => info()?.title) + const titleValue = createMemo(() => { + const title = info()?.title + if (!title) return + if (isDefaultSessionTitle(title)) return language.t("command.session.new") + return title + }) + const defaultTitle = createMemo(() => isDefaultSessionTitle(info()?.title)) + const headerTitle = createMemo( + () => titleValue() ?? (props.renderedUserMessages.length ? language.t("command.session.new") : undefined), + ) + const placeholderTitle = createMemo(() => defaultTitle() || (!info()?.title && props.renderedUserMessages.length > 0)) const parentID = createMemo(() => info()?.parentID) - const showHeader = createMemo(() => !!(titleValue() || parentID())) + const showHeader = createMemo(() => !!(headerTitle() || parentID())) const stageCfg = { init: 1, batch: 3 } const staging = createTimelineStaging({ sessionKey, @@ -269,212 +312,7 @@ export function MessageTimeline(props: { messages: () => props.renderedUserMessages, config: stageCfg, }) - - const [title, setTitle] = createStore({ - draft: "", - editing: false, - saving: false, - menuOpen: false, - pendingRename: false, - }) - let titleRef: HTMLInputElement | undefined - - const errorMessage = (err: unknown) => { - if (err && typeof err === "object" && "data" in err) { - const data = (err as { data?: { message?: string } }).data - if (data?.message) return data.message - } - if (err instanceof Error) return err.message - return language.t("common.requestFailed") - } - - createEffect( - on( - sessionKey, - () => setTitle({ draft: "", editing: false, saving: false, menuOpen: false, pendingRename: false }), - { defer: true }, - ), - ) - - const openTitleEditor = () => { - if (!sessionID()) return - setTitle({ editing: true, draft: titleValue() ?? "" }) - requestAnimationFrame(() => { - titleRef?.focus() - titleRef?.select() - }) - } - - const closeTitleEditor = () => { - if (title.saving) return - setTitle({ editing: false, saving: false }) - } - - const saveTitleEditor = async () => { - const id = sessionID() - if (!id) return - if (title.saving) return - - const next = title.draft.trim() - if (!next || next === (titleValue() ?? "")) { - setTitle({ editing: false, saving: false }) - return - } - - setTitle("saving", true) - await sdk.client.session - .update({ sessionID: id, title: next }) - .then(() => { - sync.set( - produce((draft) => { - const index = draft.session.findIndex((s) => s.id === id) - if (index !== -1) draft.session[index].title = next - }), - ) - setTitle({ editing: false, saving: false }) - }) - .catch((err) => { - setTitle("saving", false) - showToast({ - title: language.t("common.requestFailed"), - description: errorMessage(err), - }) - }) - } - - const navigateAfterSessionRemoval = (sessionID: string, parentID?: string, nextSessionID?: string) => { - if (params.id !== sessionID) return - if (parentID) { - navigate(`/${params.dir}/session/${parentID}`) - return - } - if (nextSessionID) { - navigate(`/${params.dir}/session/${nextSessionID}`) - return - } - navigate(`/${params.dir}/session`) - } - - const archiveSession = async (sessionID: string) => { - const session = sync.session.get(sessionID) - if (!session) return - - const sessions = sync.data.session ?? [] - const index = sessions.findIndex((s) => s.id === sessionID) - const nextSession = index === -1 ? undefined : (sessions[index + 1] ?? sessions[index - 1]) - - await sdk.client.session - .update({ sessionID, time: { archived: Date.now() } }) - .then(() => { - sync.set( - produce((draft) => { - const index = draft.session.findIndex((s) => s.id === sessionID) - if (index !== -1) draft.session.splice(index, 1) - }), - ) - navigateAfterSessionRemoval(sessionID, session.parentID, nextSession?.id) - }) - .catch((err) => { - showToast({ - title: language.t("common.requestFailed"), - description: errorMessage(err), - }) - }) - } - - const deleteSession = async (sessionID: string) => { - const session = sync.session.get(sessionID) - if (!session) return false - - const sessions = (sync.data.session ?? []).filter((s) => !s.parentID && !s.time?.archived) - const index = sessions.findIndex((s) => s.id === sessionID) - const nextSession = index === -1 ? undefined : (sessions[index + 1] ?? sessions[index - 1]) - - const result = await sdk.client.session - .delete({ sessionID }) - .then((x) => x.data) - .catch((err) => { - showToast({ - title: language.t("session.delete.failed.title"), - description: errorMessage(err), - }) - return false - }) - - if (!result) return false - - sync.set( - produce((draft) => { - const removed = new Set([sessionID]) - - const byParent = new Map() - for (const item of draft.session) { - const parentID = item.parentID - if (!parentID) continue - const existing = byParent.get(parentID) - if (existing) { - existing.push(item.id) - continue - } - byParent.set(parentID, [item.id]) - } - - const stack = [sessionID] - while (stack.length) { - const parentID = stack.pop() - if (!parentID) continue - - const children = byParent.get(parentID) - if (!children) continue - - for (const child of children) { - if (removed.has(child)) continue - removed.add(child) - stack.push(child) - } - } - - draft.session = draft.session.filter((s) => !removed.has(s.id)) - }), - ) - - navigateAfterSessionRemoval(sessionID, session.parentID, nextSession?.id) - return true - } - - const navigateParent = () => { - const id = parentID() - if (!id) return - navigate(`/${params.dir}/session/${id}`) - } - - function DialogDeleteSession(props: { sessionID: string }) { - const name = createMemo(() => sync.session.get(props.sessionID)?.title ?? language.t("command.session.new")) - const handleDelete = async () => { - await deleteSession(props.sessionID) - dialog.close() - } - - return ( - -
-
- - {language.t("session.delete.confirm", { name: name() })} - -
-
- - -
-
-
- ) - } + const rendered = createMemo(() => staging.messages().map((message) => message.id)) return (
+ { const root = e.currentTarget @@ -532,9 +381,18 @@ export function MessageTimeline(props: { touchGesture = undefined }} onPointerDown={(e) => { + const next = trigger(e.target) + if (next) props.onPreserveScrollAnchor(next) + if (e.target !== e.currentTarget) return props.onMarkScrollGesture(e.currentTarget) }} + onKeyDown={(e) => { + if (e.key !== "Enter" && e.key !== " ") return + const next = trigger(e.target) + if (!next) return + props.onPreserveScrollAnchor(next) + }} onScroll={(e) => { props.onScheduleScrollState(e.currentTarget) props.onTurnBackfillScroll() @@ -543,134 +401,24 @@ export function MessageTimeline(props: { props.onMarkScrollGesture(e.currentTarget) if (props.isDesktop) props.onScrollSpyScroll() }} - onClick={props.onAutoScrollInteraction} + onClick={(e) => { + props.onAutoScrollInteraction(e) + }} class="relative min-w-0 w-full h-full" style={{ - "--session-title-height": showHeader() ? "40px" : "0px", + "--session-title-height": showHeader() ? "72px" : "0px", "--sticky-accordion-top": showHeader() ? "48px" : "0px", }} > -
- -
-
-
- - - - - - {titleValue()} - - } - > - { - titleRef = el - }} - value={title.draft} - disabled={title.saving} - class="text-14-medium text-text-strong grow-1 min-w-0 pl-2 rounded-[6px]" - style={{ "--inline-input-shadow": "var(--shadow-xs-border-select)" }} - onInput={(event) => setTitle("draft", event.currentTarget.value)} - onKeyDown={(event) => { - event.stopPropagation() - if (event.key === "Enter") { - event.preventDefault() - void saveTitleEditor() - return - } - if (event.key === "Escape") { - event.preventDefault() - closeTitleEditor() - } - }} - onBlur={closeTitleEditor} - /> - - -
- - {(id) => ( -
- - setTitle("menuOpen", open)} - > - - - { - if (!title.pendingRename) return - event.preventDefault() - setTitle("pendingRename", false) - openTitleEditor() - }} - > - { - setTitle("pendingRename", true) - setTitle("menuOpen", false) - }} - > - {language.t("common.rename")} - - void archiveSession(id)}> - {language.t("common.archive")} - - - dialog.show(() => )} - > - {language.t("common.delete")} - - - - -
- )} -
-
-
-
- +
{(messageID) => { + // Capture at creation time: animate only messages added after the + // timeline finishes its initial backfill staging, plus the first + // turn while a brand new session is still using its default title. + const isNew = + staging.ready() || + (defaultTitle() && + sessionStatus() !== "idle" && + props.renderedUserMessages.length === 1 && + messageID === props.renderedUserMessages[0]?.id) const active = createMemo(() => activeMessageID() === messageID) const queued = createMemo(() => { if (active()) return false @@ -700,7 +457,10 @@ export function MessageTimeline(props: { return false }) const comments = createMemo(() => messageComments(sync.data.part[messageID] ?? []), [], { - equals: (a, b) => JSON.stringify(a) === JSON.stringify(b), + equals: (a, b) => { + if (a.length !== b.length) return false + return a.every((x, i) => x.path === b[i].path && x.comment === b[i].comment) + }, }) const commentCount = createMemo(() => comments().length) return ( @@ -713,7 +473,7 @@ export function MessageTimeline(props: { }} classList={{ "min-w-0 w-full max-w-full": true, - "md:max-w-200 2xl:max-w-[1000px]": props.centered, + "md:max-w-[500px] 2xl:max-w-[700px]": props.centered, }} > 0}> @@ -757,7 +517,7 @@ export function MessageTimeline(props: { messageID={messageID} active={active()} queued={queued()} - status={active() ? sessionStatus() : undefined} + animate={isNew || active()} showReasoningSummaries={settings.general.showReasoningSummaries()} shellToolDefaultOpen={settings.general.shellToolPartsExpanded()} editToolDefaultOpen={settings.general.editToolPartsExpanded()} diff --git a/packages/app/src/pages/session/session-model-helpers.test.ts b/packages/app/src/pages/session/session-model-helpers.test.ts new file mode 100644 index 0000000000..5f554dcd36 --- /dev/null +++ b/packages/app/src/pages/session/session-model-helpers.test.ts @@ -0,0 +1,158 @@ +import { describe, expect, test } from "bun:test" +import type { UserMessage } from "@opencode-ai/sdk/v2" +import { resetSessionModel, syncSessionModel } from "./session-model-helpers" + +const message = (input?: Partial>) => + ({ + id: "msg", + sessionID: "session", + role: "user", + time: { created: 1 }, + agent: input?.agent ?? "build", + model: input?.model ?? { providerID: "anthropic", modelID: "claude-sonnet-4" }, + variant: input?.variant, + }) as UserMessage + +describe("syncSessionModel", () => { + test("restores the last message model and variant", () => { + const calls: unknown[] = [] + + syncSessionModel( + { + agent: { + current() { + return undefined + }, + set(value) { + calls.push(["agent", value]) + }, + }, + model: { + set(value) { + calls.push(["model", value]) + }, + current() { + return { id: "claude-sonnet-4", provider: { id: "anthropic" } } + }, + variant: { + set(value) { + calls.push(["variant", value]) + }, + }, + }, + }, + message({ variant: "high" }), + ) + + expect(calls).toEqual([ + ["agent", "build"], + ["model", { providerID: "anthropic", modelID: "claude-sonnet-4" }], + ["variant", "high"], + ]) + }) + + test("skips variant when the model falls back", () => { + const calls: unknown[] = [] + + syncSessionModel( + { + agent: { + current() { + return undefined + }, + set(value) { + calls.push(["agent", value]) + }, + }, + model: { + set(value) { + calls.push(["model", value]) + }, + current() { + return { id: "gpt-5", provider: { id: "openai" } } + }, + variant: { + set(value) { + calls.push(["variant", value]) + }, + }, + }, + }, + message({ variant: "high" }), + ) + + expect(calls).toEqual([ + ["agent", "build"], + ["model", { providerID: "anthropic", modelID: "claude-sonnet-4" }], + ]) + }) +}) + +describe("resetSessionModel", () => { + test("restores the current agent defaults", () => { + const calls: unknown[] = [] + + resetSessionModel({ + agent: { + current() { + return { + model: { providerID: "anthropic", modelID: "claude-sonnet-4" }, + variant: "high", + } + }, + set() {}, + }, + model: { + set(value) { + calls.push(["model", value]) + }, + current() { + return undefined + }, + variant: { + set(value) { + calls.push(["variant", value]) + }, + }, + }, + }) + + expect(calls).toEqual([ + ["model", { providerID: "anthropic", modelID: "claude-sonnet-4" }], + ["variant", "high"], + ]) + }) + + test("clears the variant when the agent has none", () => { + const calls: unknown[] = [] + + resetSessionModel({ + agent: { + current() { + return { + model: { providerID: "anthropic", modelID: "claude-sonnet-4" }, + } + }, + set() {}, + }, + model: { + set(value) { + calls.push(["model", value]) + }, + current() { + return undefined + }, + variant: { + set(value) { + calls.push(["variant", value]) + }, + }, + }, + }) + + expect(calls).toEqual([ + ["model", { providerID: "anthropic", modelID: "claude-sonnet-4" }], + ["variant", undefined], + ]) + }) +}) diff --git a/packages/app/src/pages/session/session-model-helpers.ts b/packages/app/src/pages/session/session-model-helpers.ts new file mode 100644 index 0000000000..7600f16d5c --- /dev/null +++ b/packages/app/src/pages/session/session-model-helpers.ts @@ -0,0 +1,48 @@ +import type { UserMessage } from "@opencode-ai/sdk/v2" +import { batch } from "solid-js" + +type Local = { + agent: { + current(): + | { + model?: UserMessage["model"] + variant?: string + } + | undefined + set(name: string | undefined): void + } + model: { + set(model: UserMessage["model"] | undefined): void + current(): + | { + id: string + provider: { id: string } + } + | undefined + variant: { + set(value: string | undefined): void + } + } +} + +export const resetSessionModel = (local: Local) => { + const agent = local.agent.current() + if (!agent) return + batch(() => { + local.model.set(agent.model) + local.model.variant.set(agent.variant) + }) +} + +export const syncSessionModel = (local: Local, msg: UserMessage) => { + batch(() => { + local.agent.set(msg.agent) + local.model.set(msg.model) + }) + + const model = local.model.current() + if (!model) return + if (model.provider.id !== msg.model.providerID) return + if (model.id !== msg.model.modelID) return + local.model.variant.set(msg.variant) +} diff --git a/packages/app/src/pages/session/session-side-panel.tsx b/packages/app/src/pages/session/session-side-panel.tsx index 55c1607a09..a5e067c6f0 100644 --- a/packages/app/src/pages/session/session-side-panel.tsx +++ b/packages/app/src/pages/session/session-side-panel.tsx @@ -23,7 +23,7 @@ import { useLayout } from "@/context/layout" import { useSync } from "@/context/sync" import { createFileTabListSync } from "@/pages/session/file-tab-scroll" import { FileTabContent } from "@/pages/session/file-tabs" -import { createOpenSessionFileTab, getTabReorderIndex } from "@/pages/session/helpers" +import { createOpenSessionFileTab, getTabReorderIndex, type Sizing } from "@/pages/session/helpers" import { StickyAddButton } from "@/pages/session/review-tab" import { setSessionHandoff } from "@/pages/session/handoff" @@ -31,6 +31,7 @@ export function SessionSidePanel(props: { reviewPanel: () => JSX.Element activeDiff?: string focusReviewDiff: (path: string) => void + size: Sizing }) { const params = useParams() const layout = useLayout() @@ -46,8 +47,15 @@ export function SessionSidePanel(props: { const view = createMemo(() => layout.view(sessionKey)) const reviewOpen = createMemo(() => isDesktop() && view().reviewPanel.opened()) - const open = createMemo(() => isDesktop() && (view().reviewPanel.opened() || layout.fileTree.opened())) + const fileOpen = createMemo(() => isDesktop() && layout.fileTree.opened()) + const open = createMemo(() => reviewOpen() || fileOpen()) const reviewTab = createMemo(() => isDesktop()) + const panelWidth = createMemo(() => { + if (!open()) return "0px" + if (reviewOpen()) return `calc(100% - ${layout.session.width()}px)` + return `${layout.fileTree.width()}px` + }) + const treeWidth = createMemo(() => (fileOpen() ? `${layout.fileTree.width()}px` : "0px")) const info = createMemo(() => (params.id ? sync.session.get(params.id) : undefined)) const diffs = createMemo(() => (params.id ? (sync.data.session_diff[params.id] ?? []) : [])) @@ -60,6 +68,12 @@ export function SessionSidePanel(props: { return sync.data.session_diff[id] !== undefined }) + const reviewEmptyKey = createMemo(() => { + if (sync.project && !sync.project.vcs) return "session.review.noVcs" + if (sync.data.config.snapshot === false) return "session.review.noSnapshot" + return "session.review.noChanges" + }) + const diffFiles = createMemo(() => diffs().map((d) => d.file)) const kinds = createMemo(() => { const merge = (a: "add" | "del" | "mix" | undefined, b: "add" | "del" | "mix") => { @@ -87,6 +101,21 @@ export function SessionSidePanel(props: { return out }) + const empty = (msg: string) => ( +
+
+
+
{msg}
+
+
+ ) + + const nofiles = createMemo(() => { + const state = file.tree.state("") + if (!state?.loaded) return false + return file.tree.children("").length === 0 + }) + const normalizeTab = (tab: string) => { if (!tab.startsWith("file://")) return tab return file.tab(tab) @@ -145,17 +174,8 @@ export function SessionSidePanel(props: { const [store, setStore] = createStore({ activeDraggable: undefined as string | undefined, - fileTreeScrolled: false, }) - let changesEl: HTMLDivElement | undefined - let allEl: HTMLDivElement | undefined - - const syncFileTreeScrolled = (el?: HTMLDivElement) => { - const next = (el?.scrollTop ?? 0) > 0 - setStore("fileTreeScrolled", (current) => (current === next ? current : next)) - } - const handleDragStart = (event: unknown) => { const id = getDraggableId(event) if (!id) return @@ -176,11 +196,6 @@ export function SessionSidePanel(props: { setStore("activeDraggable", undefined) } - createEffect(() => { - if (!layout.fileTree.opened()) return - syncFileTreeScrolled(fileTreeTab() === "changes" ? changesEl : allEl) - }) - createEffect(() => { if (!file.ready()) return @@ -203,151 +218,172 @@ export function SessionSidePanel(props: { }) return ( - + ) diff --git a/packages/app/src/pages/session/session-timeline-header.tsx b/packages/app/src/pages/session/session-timeline-header.tsx new file mode 100644 index 0000000000..d10fe1a27e --- /dev/null +++ b/packages/app/src/pages/session/session-timeline-header.tsx @@ -0,0 +1,522 @@ +import { createEffect, createMemo, on, onCleanup, Show } from "solid-js" +import { createStore, produce } from "solid-js/store" +import { useNavigate, useParams } from "@solidjs/router" +import { Button } from "@opencode-ai/ui/button" +import { IconButton } from "@opencode-ai/ui/icon-button" +import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu" +import { Dialog } from "@opencode-ai/ui/dialog" +import { prefersReducedMotion } from "@opencode-ai/ui/hooks" +import { InlineInput } from "@opencode-ai/ui/inline-input" +import { animate, type AnimationPlaybackControls, clearFadeStyles, FAST_SPRING } from "@opencode-ai/ui/motion" +import { showToast } from "@opencode-ai/ui/toast" +import { errorMessage } from "@/pages/layout/helpers" +import { SessionContextUsage } from "@/components/session-context-usage" +import { useDialog } from "@opencode-ai/ui/context/dialog" +import { useLanguage } from "@/context/language" +import { useSDK } from "@/context/sdk" +import { useSync } from "@/context/sync" + +export function SessionTimelineHeader(props: { + centered: boolean + showHeader: () => boolean + sessionKey: () => string + sessionID: () => string | undefined + parentID: () => string | undefined + titleValue: () => string | undefined + headerTitle: () => string | undefined + placeholderTitle: () => boolean +}) { + const navigate = useNavigate() + const params = useParams() + const sdk = useSDK() + const sync = useSync() + const dialog = useDialog() + const language = useLanguage() + const reduce = prefersReducedMotion + + const [title, setTitle] = createStore({ + draft: "", + editing: false, + saving: false, + menuOpen: false, + pendingRename: false, + }) + const [headerText, setHeaderText] = createStore({ + session: props.sessionKey(), + value: props.headerTitle(), + prev: undefined as string | undefined, + muted: props.placeholderTitle(), + prevMuted: false, + }) + let headerAnim: AnimationPlaybackControls | undefined + let enterAnim: AnimationPlaybackControls | undefined + let leaveAnim: AnimationPlaybackControls | undefined + let titleRef: HTMLInputElement | undefined + let headerRef: HTMLDivElement | undefined + let enterRef: HTMLSpanElement | undefined + let leaveRef: HTMLSpanElement | undefined + + const clearHeaderAnim = () => { + headerAnim?.stop() + headerAnim = undefined + } + + const animateHeader = () => { + const el = headerRef + if (!el) return + + clearHeaderAnim() + if (!headerText.muted || reduce()) { + el.style.opacity = "1" + return + } + + headerAnim = animate(el, { opacity: [0, 1] }, { type: "spring", visualDuration: 1.0, bounce: 0 }) + headerAnim.finished.then(() => { + if (headerRef !== el) return + clearFadeStyles(el) + }) + } + + const clearTitleAnims = () => { + enterAnim?.stop() + enterAnim = undefined + leaveAnim?.stop() + leaveAnim = undefined + } + + const settleTitleEnter = () => { + if (enterRef) clearFadeStyles(enterRef) + } + + const hideLeave = () => { + if (!leaveRef) return + leaveRef.style.opacity = "0" + leaveRef.style.filter = "" + leaveRef.style.transform = "" + } + + const animateEnterSpan = () => { + if (!enterRef) return + if (reduce()) { + settleTitleEnter() + return + } + enterAnim = animate( + enterRef, + { opacity: [0, 1], filter: ["blur(2px)", "blur(0px)"], transform: ["translateY(-2px)", "translateY(0)"] }, + FAST_SPRING, + ) + enterAnim.finished.then(() => settleTitleEnter()) + } + + const crossfadeTitle = (nextTitle: string, nextMuted: boolean) => { + clearTitleAnims() + setHeaderText({ prev: headerText.value, prevMuted: headerText.muted }) + setHeaderText({ value: nextTitle, muted: nextMuted }) + + if (reduce()) { + setHeaderText({ prev: undefined, prevMuted: false }) + hideLeave() + settleTitleEnter() + return + } + + if (leaveRef) { + leaveAnim = animate( + leaveRef, + { opacity: [1, 0], filter: ["blur(0px)", "blur(2px)"], transform: ["translateY(0)", "translateY(2px)"] }, + FAST_SPRING, + ) + leaveAnim.finished.then(() => { + setHeaderText({ prev: undefined, prevMuted: false }) + hideLeave() + }) + } + + animateEnterSpan() + } + + const fadeInTitle = (nextTitle: string, nextMuted: boolean) => { + clearTitleAnims() + setHeaderText({ value: nextTitle, muted: nextMuted, prev: undefined, prevMuted: false }) + animateEnterSpan() + } + + const snapTitle = (nextTitle: string | undefined, nextMuted: boolean) => { + clearTitleAnims() + setHeaderText({ value: nextTitle, muted: nextMuted, prev: undefined, prevMuted: false }) + settleTitleEnter() + } + + createEffect( + on(props.showHeader, (show, prev) => { + if (!show) { + clearHeaderAnim() + return + } + if (show === prev) return + animateHeader() + }), + ) + + createEffect( + on( + () => [props.sessionKey(), props.headerTitle(), props.placeholderTitle()] as const, + ([nextSession, nextTitle, nextMuted]) => { + if (nextSession !== headerText.session) { + setHeaderText("session", nextSession) + if (nextTitle && nextMuted) { + fadeInTitle(nextTitle, nextMuted) + return + } + snapTitle(nextTitle, nextMuted) + return + } + if (nextTitle === headerText.value && nextMuted === headerText.muted) return + if (!nextTitle) { + snapTitle(undefined, false) + return + } + if (!headerText.value) { + fadeInTitle(nextTitle, nextMuted) + return + } + if (title.saving || title.editing) { + snapTitle(nextTitle, nextMuted) + return + } + crossfadeTitle(nextTitle, nextMuted) + }, + ), + ) + + onCleanup(() => { + clearHeaderAnim() + clearTitleAnims() + }) + + const toastError = (err: unknown) => errorMessage(err, language.t("common.requestFailed")) + + createEffect( + on( + props.sessionKey, + () => setTitle({ draft: "", editing: false, saving: false, menuOpen: false, pendingRename: false }), + { defer: true }, + ), + ) + + const openTitleEditor = () => { + if (!props.sessionID()) return + setTitle({ editing: true, draft: props.titleValue() ?? "" }) + requestAnimationFrame(() => { + titleRef?.focus() + titleRef?.select() + }) + } + + const closeTitleEditor = () => { + if (title.saving) return + setTitle({ editing: false, saving: false }) + } + + const saveTitleEditor = async () => { + const id = props.sessionID() + if (!id) return + if (title.saving) return + + const next = title.draft.trim() + if (!next || next === (props.titleValue() ?? "")) { + setTitle({ editing: false, saving: false }) + return + } + + setTitle("saving", true) + await sdk.client.session + .update({ sessionID: id, title: next }) + .then(() => { + sync.set( + produce((draft) => { + const index = draft.session.findIndex((session) => session.id === id) + if (index !== -1) draft.session[index].title = next + }), + ) + setTitle({ editing: false, saving: false }) + }) + .catch((err) => { + setTitle("saving", false) + showToast({ + title: language.t("common.requestFailed"), + description: toastError(err), + }) + }) + } + + const navigateAfterSessionRemoval = (sessionID: string, parentID?: string, nextSessionID?: string) => { + if (params.id !== sessionID) return + if (parentID) { + navigate(`/${params.dir}/session/${parentID}`) + return + } + if (nextSessionID) { + navigate(`/${params.dir}/session/${nextSessionID}`) + return + } + navigate(`/${params.dir}/session`) + } + + const archiveSession = async (sessionID: string) => { + const session = sync.session.get(sessionID) + if (!session) return + + const sessions = sync.data.session ?? [] + const index = sessions.findIndex((item) => item.id === sessionID) + const nextSession = index === -1 ? undefined : (sessions[index + 1] ?? sessions[index - 1]) + + await sdk.client.session + .update({ sessionID, time: { archived: Date.now() } }) + .then(() => { + sync.set( + produce((draft) => { + const index = draft.session.findIndex((item) => item.id === sessionID) + if (index !== -1) draft.session.splice(index, 1) + }), + ) + navigateAfterSessionRemoval(sessionID, session.parentID, nextSession?.id) + }) + .catch((err) => { + showToast({ + title: language.t("common.requestFailed"), + description: toastError(err), + }) + }) + } + + const deleteSession = async (sessionID: string) => { + const session = sync.session.get(sessionID) + if (!session) return false + + const sessions = (sync.data.session ?? []).filter((item) => !item.parentID && !item.time?.archived) + const index = sessions.findIndex((item) => item.id === sessionID) + const nextSession = index === -1 ? undefined : (sessions[index + 1] ?? sessions[index - 1]) + + const result = await sdk.client.session + .delete({ sessionID }) + .then((x) => x.data) + .catch((err) => { + showToast({ + title: language.t("session.delete.failed.title"), + description: toastError(err), + }) + return false + }) + + if (!result) return false + + sync.set( + produce((draft) => { + const removed = new Set([sessionID]) + const byParent = new Map() + + for (const item of draft.session) { + const parentID = item.parentID + if (!parentID) continue + + const existing = byParent.get(parentID) + if (existing) { + existing.push(item.id) + continue + } + byParent.set(parentID, [item.id]) + } + + const stack = [sessionID] + while (stack.length) { + const parentID = stack.pop() + if (!parentID) continue + + const children = byParent.get(parentID) + if (!children) continue + + for (const child of children) { + if (removed.has(child)) continue + removed.add(child) + stack.push(child) + } + } + + draft.session = draft.session.filter((item) => !removed.has(item.id)) + }), + ) + + navigateAfterSessionRemoval(sessionID, session.parentID, nextSession?.id) + return true + } + + const navigateParent = () => { + const id = props.parentID() + if (!id) return + navigate(`/${params.dir}/session/${id}`) + } + + function DialogDeleteSession(input: { sessionID: string }) { + const name = createMemo(() => sync.session.get(input.sessionID)?.title ?? language.t("command.session.new")) + + const handleDelete = async () => { + await deleteSession(input.sessionID) + dialog.close() + } + + return ( + +
+
+ + {language.t("session.delete.confirm", { name: name() })} + +
+
+ + +
+
+
+ ) + } + + return ( + +
{ + headerRef = el + el.style.opacity = "0" + }} + class="pointer-events-none absolute inset-x-0 top-0 z-30" + > +
+
+
+ +
+ +
+
+ + + + + {headerText.value} + + + {headerText.prev} + + + + } + > + { + titleRef = el + }} + value={title.draft} + disabled={title.saving} + class="text-14-medium text-text-strong grow-1 min-w-0 rounded-[6px]" + style={{ "--inline-input-shadow": "var(--shadow-xs-border-select)" }} + onInput={(event) => setTitle("draft", event.currentTarget.value)} + onKeyDown={(event) => { + event.stopPropagation() + if (event.key === "Enter") { + event.preventDefault() + void saveTitleEditor() + return + } + if (event.key === "Escape") { + event.preventDefault() + closeTitleEditor() + } + }} + onBlur={closeTitleEditor} + /> + + +
+ + {(id) => ( +
+ + setTitle("menuOpen", open)} + > + + + { + if (!title.pendingRename) return + event.preventDefault() + setTitle("pendingRename", false) + openTitleEditor() + }} + > + { + setTitle("pendingRename", true) + setTitle("menuOpen", false) + }} + > + {language.t("common.rename")} + + void archiveSession(id())}> + {language.t("common.archive")} + + + dialog.show(() => )}> + {language.t("common.delete")} + + + + +
+ )} +
+
+
+
+
+ ) +} diff --git a/packages/app/src/pages/session/terminal-panel.tsx b/packages/app/src/pages/session/terminal-panel.tsx index c8bfc14053..d5eac2322b 100644 --- a/packages/app/src/pages/session/terminal-panel.tsx +++ b/packages/app/src/pages/session/terminal-panel.tsx @@ -17,7 +17,7 @@ import { useLanguage } from "@/context/language" import { useLayout } from "@/context/layout" import { useTerminal, type LocalPTY } from "@/context/terminal" import { terminalTabLabel } from "@/pages/session/terminal-label" -import { focusTerminalById } from "@/pages/session/helpers" +import { createPresence, createSizing, focusTerminalById } from "@/pages/session/helpers" import { getTerminalHandoff, setTerminalHandoff } from "@/pages/session/handoff" export function TerminalPanel() { @@ -33,8 +33,11 @@ export function TerminalPanel() { const opened = createMemo(() => view().terminal.opened()) const open = createMemo(() => isDesktop() && opened()) + const panel = createPresence(open) + const size = createSizing() const height = createMemo(() => layout.terminal.height()) const close = () => view().terminal.close() + let root: HTMLDivElement | undefined const [store, setStore] = createStore({ autoCreated: false, @@ -67,7 +70,7 @@ export function TerminalPanel() { on( () => terminal.active(), (activeId) => { - if (!activeId || !open()) return + if (!activeId || !panel.open()) return if (document.activeElement instanceof HTMLElement) { document.activeElement.blur() } @@ -76,6 +79,14 @@ export function TerminalPanel() { ), ) + createEffect(() => { + if (panel.open()) return + const active = document.activeElement + if (!(active instanceof HTMLElement)) return + if (!root?.contains(active)) return + active.blur() + }) + createEffect(() => { const dir = params.dir if (!dir) return @@ -133,120 +144,142 @@ export function TerminalPanel() { } return ( - +
- - -
- - {(title) => ( -
- {title} -
- )} -
-
-
- {language.t("common.loading")} - {language.t("common.loading.ellipsis")} +
+
size.start()}> + { + size.touch() + layout.terminal.resize(next) + }} + onCollapse={close} + /> +
+ +
+ + {(title) => ( +
+ {title} +
+ )} +
+
+
+ {language.t("common.loading")} + {language.t("common.loading.ellipsis")} +
+
+
+ {language.t("terminal.loading")}
-
{language.t("terminal.loading")}
-
- } - > - - - -
- terminal.open(id)} - class="!h-auto !flex-none" - > - - - - {(id) => ( - - {(pty) => } - - )} - - -
- - - -
-
-
-
- - {(id) => ( - - {(pty) => ( -
- terminal.clone(id)} /> + + + +
+ terminal.open(id)} + class="!h-auto !flex-none" + > + + + + {(id) => ( + + {(pty) => } + + )} + + +
+ + + +
+
+
+
+ + {(id) => ( + + {(pty) => ( +
+ terminal.clone(id)} + /> +
+ )} +
+ )} +
+
+
+ + + {(draggedId) => ( + + {(t) => ( +
+ {terminalTabLabel({ + title: t().title, + titleNumber: t().titleNumber, + t: language.t as (key: string, vars?: Record) => string, + })}
)}
)}
-
-
- - - {(draggedId) => ( - - {(t) => ( -
- {terminalTabLabel({ - title: t.title, - titleNumber: t.titleNumber, - t: language.t as (key: string, vars?: Record) => string, - })} -
- )} -
- )} -
-
- - + + + +
) diff --git a/packages/app/src/pages/session/use-session-commands.tsx b/packages/app/src/pages/session/use-session-commands.tsx index 461351878b..b8ddeda823 100644 --- a/packages/app/src/pages/session/use-session-commands.tsx +++ b/packages/app/src/pages/session/use-session-commands.tsx @@ -261,24 +261,35 @@ export const useSessionCommands = (actions: SessionCommandContext) => { }), ]) + const isAutoAcceptActive = () => { + const sessionID = params.id + if (sessionID) return permission.isAutoAccepting(sessionID, sdk.directory) + return permission.isAutoAcceptingDirectory(sdk.directory) + } + const permissionCommands = createMemo(() => [ permissionsCommand({ id: "permissions.autoaccept", - title: - params.id && permission.isAutoAccepting(params.id, sdk.directory) - ? language.t("command.permissions.autoaccept.disable") - : language.t("command.permissions.autoaccept.enable"), + title: isAutoAcceptActive() + ? language.t("command.permissions.autoaccept.disable") + : language.t("command.permissions.autoaccept.enable"), keybind: "mod+shift+a", - disabled: !params.id || !permission.permissionsEnabled(), + disabled: false, onSelect: () => { const sessionID = params.id - if (!sessionID) return - permission.toggleAutoAccept(sessionID, sdk.directory) + if (sessionID) { + permission.toggleAutoAccept(sessionID, sdk.directory) + } else { + permission.toggleAutoAcceptDirectory(sdk.directory) + } + const active = sessionID + ? permission.isAutoAccepting(sessionID, sdk.directory) + : permission.isAutoAcceptingDirectory(sdk.directory) showToast({ - title: permission.isAutoAccepting(sessionID, sdk.directory) + title: active ? language.t("toast.permissions.autoaccept.on.title") : language.t("toast.permissions.autoaccept.off.title"), - description: permission.isAutoAccepting(sessionID, sdk.directory) + description: active ? language.t("toast.permissions.autoaccept.on.description") : language.t("toast.permissions.autoaccept.off.description"), }) diff --git a/packages/app/src/pages/session/use-session-hash-scroll.ts b/packages/app/src/pages/session/use-session-hash-scroll.ts index 20e88a3ea3..278a1ba6e5 100644 --- a/packages/app/src/pages/session/use-session-hash-scroll.ts +++ b/packages/app/src/pages/session/use-session-hash-scroll.ts @@ -1,6 +1,5 @@ import type { UserMessage } from "@opencode-ai/sdk/v2" -import { useLocation, useNavigate } from "@solidjs/router" -import { createEffect, createMemo, onMount } from "solid-js" +import { createEffect, createMemo, onCleanup, onMount } from "solid-js" import { messageIdFromHash } from "./message-id-from-hash" export { messageIdFromHash } from "./message-id-from-hash" @@ -16,7 +15,7 @@ export const useSessionHashScroll = (input: { setPendingMessage: (value: string | undefined) => void setActiveMessage: (message: UserMessage | undefined) => void setTurnStart: (value: number) => void - autoScroll: { pause: () => void; forceScrollToBottom: () => void } + autoScroll: { pause: () => void; snapToBottom: () => void } scroller: () => HTMLDivElement | undefined anchor: (id: string) => string scheduleScrollState: (el: HTMLDivElement) => void @@ -27,18 +26,13 @@ export const useSessionHashScroll = (input: { const messageIndex = createMemo(() => new Map(visibleUserMessages().map((m, i) => [m.id, i]))) let pendingKey = "" - const location = useLocation() - const navigate = useNavigate() - const clearMessageHash = () => { - if (!location.hash) return - navigate(location.pathname + location.search, { replace: true }) + if (!window.location.hash) return + window.history.replaceState(null, "", window.location.pathname + window.location.search) } const updateHash = (id: string) => { - navigate(location.pathname + location.search + `#${input.anchor(id)}`, { - replace: true, - }) + window.history.replaceState(null, "", `${window.location.pathname}${window.location.search}#${input.anchor(id)}`) } const scrollToElement = (el: HTMLElement, behavior: ScrollBehavior) => { @@ -47,15 +41,15 @@ export const useSessionHashScroll = (input: { const a = el.getBoundingClientRect() const b = root.getBoundingClientRect() - const sticky = root.querySelector("[data-session-title]") - const inset = sticky instanceof HTMLElement ? sticky.offsetHeight : 0 - const top = Math.max(0, a.top - b.top + root.scrollTop - inset) + const title = parseFloat(getComputedStyle(root).getPropertyValue("--session-title-height")) + const inset = Number.isNaN(title) ? 0 : title + // With column-reverse, scrollTop is negative — don't clamp to 0 + const top = a.top - b.top + root.scrollTop - inset root.scrollTo({ top, behavior }) return true } const scrollToMessage = (message: UserMessage, behavior: ScrollBehavior = "smooth") => { - console.log({ message, behavior }) if (input.currentMessageId() !== message.id) input.setActiveMessage(message) const index = messageIndex().get(message.id) ?? -1 @@ -103,9 +97,9 @@ export const useSessionHashScroll = (input: { } const applyHash = (behavior: ScrollBehavior) => { - const hash = location.hash.slice(1) + const hash = window.location.hash.slice(1) if (!hash) { - input.autoScroll.forceScrollToBottom() + input.autoScroll.snapToBottom() const el = input.scroller() if (el) input.scheduleScrollState(el) return @@ -129,13 +123,26 @@ export const useSessionHashScroll = (input: { return } - input.autoScroll.forceScrollToBottom() + input.autoScroll.snapToBottom() const el = input.scroller() if (el) input.scheduleScrollState(el) } + onMount(() => { + if (typeof window !== "undefined" && "scrollRestoration" in window.history) { + window.history.scrollRestoration = "manual" + } + + const handler = () => { + if (!input.sessionID() || !input.messagesReady()) return + requestAnimationFrame(() => applyHash("auto")) + } + + window.addEventListener("hashchange", handler) + onCleanup(() => window.removeEventListener("hashchange", handler)) + }) + createEffect(() => { - location.hash if (!input.sessionID() || !input.messagesReady()) return requestAnimationFrame(() => applyHash("auto")) }) @@ -159,7 +166,6 @@ export const useSessionHashScroll = (input: { } } - if (!targetId) targetId = messageIdFromHash(location.hash) if (!targetId) return if (input.currentMessageId() === targetId) return @@ -171,12 +177,6 @@ export const useSessionHashScroll = (input: { requestAnimationFrame(() => scrollToMessage(msg, "auto")) }) - onMount(() => { - if (typeof window !== "undefined" && "scrollRestoration" in window.history) { - window.history.scrollRestoration = "manual" - } - }) - return { clearMessageHash, scrollToMessage, diff --git a/packages/console/app/package.json b/packages/console/app/package.json index 269b005a86..0032a24319 100644 --- a/packages/console/app/package.json +++ b/packages/console/app/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/console-app", - "version": "1.2.19", + "version": "1.2.21", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/console/app/src/config.ts b/packages/console/app/src/config.ts index e64d364620..19e331c39a 100644 --- a/packages/console/app/src/config.ts +++ b/packages/console/app/src/config.ts @@ -9,8 +9,8 @@ export const config = { github: { repoUrl: "https://github.com/anomalyco/opencode", starsFormatted: { - compact: "100K", - full: "100,000", + compact: "120K", + full: "120,000", }, }, @@ -22,8 +22,8 @@ export const config = { // Static stats (used on landing page) stats: { - contributors: "700", - commits: "9,000", - monthlyUsers: "2.5M", + contributors: "800", + commits: "10,000", + monthlyUsers: "5M", }, } as const diff --git a/packages/console/app/src/i18n/ar.ts b/packages/console/app/src/i18n/ar.ts index 89fd39b931..86d51226a6 100644 --- a/packages/console/app/src/i18n/ar.ts +++ b/packages/console/app/src/i18n/ar.ts @@ -480,7 +480,6 @@ export const dict = { "workspace.cost.deletedSuffix": "(محذوف)", "workspace.cost.empty": "لا توجد بيانات استخدام متاحة للفترة المحددة.", "workspace.cost.subscriptionShort": "اشتراك", - "workspace.cost.liteShort": "lite", "workspace.keys.title": "مفاتيح API", "workspace.keys.subtitle": "إدارة مفاتيح API الخاصة بك للوصول إلى خدمات opencode.", diff --git a/packages/console/app/src/i18n/br.ts b/packages/console/app/src/i18n/br.ts index d7d9191729..f14a69c85c 100644 --- a/packages/console/app/src/i18n/br.ts +++ b/packages/console/app/src/i18n/br.ts @@ -488,7 +488,6 @@ export const dict = { "workspace.cost.deletedSuffix": "(excluído)", "workspace.cost.empty": "Nenhum dado de uso disponível para o período selecionado.", "workspace.cost.subscriptionShort": "ass", - "workspace.cost.liteShort": "lite", "workspace.keys.title": "Chaves de API", "workspace.keys.subtitle": "Gerencie suas chaves de API para acessar os serviços opencode.", diff --git a/packages/console/app/src/i18n/da.ts b/packages/console/app/src/i18n/da.ts index 919f9c646a..775f029fba 100644 --- a/packages/console/app/src/i18n/da.ts +++ b/packages/console/app/src/i18n/da.ts @@ -484,7 +484,6 @@ export const dict = { "workspace.cost.deletedSuffix": "(slettet)", "workspace.cost.empty": "Ingen brugsdata tilgængelige for den valgte periode.", "workspace.cost.subscriptionShort": "sub", - "workspace.cost.liteShort": "lite", "workspace.keys.title": "API-nøgler", "workspace.keys.subtitle": "Administrer dine API-nøgler for at få adgang til opencode-tjenester.", diff --git a/packages/console/app/src/i18n/de.ts b/packages/console/app/src/i18n/de.ts index 082d66bbe3..2d9be14ff7 100644 --- a/packages/console/app/src/i18n/de.ts +++ b/packages/console/app/src/i18n/de.ts @@ -487,7 +487,6 @@ export const dict = { "workspace.cost.deletedSuffix": "(gelöscht)", "workspace.cost.empty": "Keine Nutzungsdaten für den gewählten Zeitraum verfügbar.", "workspace.cost.subscriptionShort": "Abo", - "workspace.cost.liteShort": "lite", "workspace.keys.title": "API Keys", "workspace.keys.subtitle": "Verwalte deine API Keys für den Zugriff auf OpenCode-Dienste.", diff --git a/packages/console/app/src/i18n/en.ts b/packages/console/app/src/i18n/en.ts index 19e1cdefdb..2a279757b3 100644 --- a/packages/console/app/src/i18n/en.ts +++ b/packages/console/app/src/i18n/en.ts @@ -480,7 +480,6 @@ export const dict = { "workspace.cost.deletedSuffix": "(deleted)", "workspace.cost.empty": "No usage data available for the selected period.", "workspace.cost.subscriptionShort": "sub", - "workspace.cost.liteShort": "lite", "workspace.keys.title": "API Keys", "workspace.keys.subtitle": "Manage your API keys for accessing opencode services.", diff --git a/packages/console/app/src/i18n/es.ts b/packages/console/app/src/i18n/es.ts index c1bfdeeb77..25b8d37d7c 100644 --- a/packages/console/app/src/i18n/es.ts +++ b/packages/console/app/src/i18n/es.ts @@ -489,7 +489,6 @@ export const dict = { "workspace.cost.deletedSuffix": "(eliminado)", "workspace.cost.empty": "No hay datos de uso disponibles para el periodo seleccionado.", "workspace.cost.subscriptionShort": "sub", - "workspace.cost.liteShort": "lite", "workspace.keys.title": "Claves API", "workspace.keys.subtitle": "Gestiona tus claves API para acceder a los servicios de opencode.", diff --git a/packages/console/app/src/i18n/fr.ts b/packages/console/app/src/i18n/fr.ts index 6d8134afbe..ddf33c0ec2 100644 --- a/packages/console/app/src/i18n/fr.ts +++ b/packages/console/app/src/i18n/fr.ts @@ -490,7 +490,6 @@ export const dict = { "workspace.cost.deletedSuffix": "(supprimé)", "workspace.cost.empty": "Aucune donnée d'utilisation disponible pour la période sélectionnée.", "workspace.cost.subscriptionShort": "abo", - "workspace.cost.liteShort": "lite", "workspace.keys.title": "Clés API", "workspace.keys.subtitle": "Gérez vos clés API pour accéder aux services OpenCode.", diff --git a/packages/console/app/src/i18n/it.ts b/packages/console/app/src/i18n/it.ts index 66a66dc17e..770efde453 100644 --- a/packages/console/app/src/i18n/it.ts +++ b/packages/console/app/src/i18n/it.ts @@ -487,7 +487,6 @@ export const dict = { "workspace.cost.deletedSuffix": "(eliminato)", "workspace.cost.empty": "Nessun dato di utilizzo disponibile per il periodo selezionato.", "workspace.cost.subscriptionShort": "sub", - "workspace.cost.liteShort": "lite", "workspace.keys.title": "Chiavi API", "workspace.keys.subtitle": "Gestisci le tue chiavi API per accedere ai servizi opencode.", diff --git a/packages/console/app/src/i18n/ja.ts b/packages/console/app/src/i18n/ja.ts index d43105a70a..f2786ba8d8 100644 --- a/packages/console/app/src/i18n/ja.ts +++ b/packages/console/app/src/i18n/ja.ts @@ -486,7 +486,6 @@ export const dict = { "workspace.cost.deletedSuffix": "(削除済み)", "workspace.cost.empty": "選択した期間の使用状況データはありません。", "workspace.cost.subscriptionShort": "サブ", - "workspace.cost.liteShort": "lite", "workspace.keys.title": "APIキー", "workspace.keys.subtitle": "OpenCodeサービスにアクセスするためのAPIキーを管理します。", diff --git a/packages/console/app/src/i18n/ko.ts b/packages/console/app/src/i18n/ko.ts index c2271e9585..169b56c0a3 100644 --- a/packages/console/app/src/i18n/ko.ts +++ b/packages/console/app/src/i18n/ko.ts @@ -480,7 +480,6 @@ export const dict = { "workspace.cost.deletedSuffix": "(삭제됨)", "workspace.cost.empty": "선택한 기간에 사용 데이터가 없습니다.", "workspace.cost.subscriptionShort": "구독", - "workspace.cost.liteShort": "lite", "workspace.keys.title": "API 키", "workspace.keys.subtitle": "OpenCode 서비스 액세스를 위한 API 키를 관리하세요.", diff --git a/packages/console/app/src/i18n/no.ts b/packages/console/app/src/i18n/no.ts index 9f8585e241..0b6e76e0c3 100644 --- a/packages/console/app/src/i18n/no.ts +++ b/packages/console/app/src/i18n/no.ts @@ -485,7 +485,6 @@ export const dict = { "workspace.cost.deletedSuffix": "(slettet)", "workspace.cost.empty": "Ingen bruksdata tilgjengelig for den valgte perioden.", "workspace.cost.subscriptionShort": "sub", - "workspace.cost.liteShort": "lite", "workspace.keys.title": "API-nøkler", "workspace.keys.subtitle": "Administrer API-nøklene dine for å få tilgang til opencode-tjenester.", diff --git a/packages/console/app/src/i18n/pl.ts b/packages/console/app/src/i18n/pl.ts index bcc4618a62..b46280ae15 100644 --- a/packages/console/app/src/i18n/pl.ts +++ b/packages/console/app/src/i18n/pl.ts @@ -486,7 +486,6 @@ export const dict = { "workspace.cost.deletedSuffix": "(usunięte)", "workspace.cost.empty": "Brak danych o użyciu dla wybranego okresu.", "workspace.cost.subscriptionShort": "sub", - "workspace.cost.liteShort": "lite", "workspace.keys.title": "Klucze API", "workspace.keys.subtitle": "Zarządzaj kluczami API do usług opencode.", diff --git a/packages/console/app/src/i18n/ru.ts b/packages/console/app/src/i18n/ru.ts index 5ac9a7ab5f..801c8fc7d4 100644 --- a/packages/console/app/src/i18n/ru.ts +++ b/packages/console/app/src/i18n/ru.ts @@ -492,7 +492,6 @@ export const dict = { "workspace.cost.deletedSuffix": "(удалено)", "workspace.cost.empty": "Нет данных об использовании за выбранный период.", "workspace.cost.subscriptionShort": "подписка", - "workspace.cost.liteShort": "lite", "workspace.keys.title": "API Ключи", "workspace.keys.subtitle": "Управляйте вашими API ключами для доступа к сервисам opencode.", diff --git a/packages/console/app/src/i18n/th.ts b/packages/console/app/src/i18n/th.ts index b442597f18..d9d7d03d1f 100644 --- a/packages/console/app/src/i18n/th.ts +++ b/packages/console/app/src/i18n/th.ts @@ -483,7 +483,6 @@ export const dict = { "workspace.cost.deletedSuffix": "(ลบแล้ว)", "workspace.cost.empty": "ไม่มีข้อมูลการใช้งานในช่วงเวลาที่เลือก", "workspace.cost.subscriptionShort": "sub", - "workspace.cost.liteShort": "lite", "workspace.keys.title": "API Keys", "workspace.keys.subtitle": "จัดการ API keys ของคุณสำหรับการเข้าถึงบริการ OpenCode", diff --git a/packages/console/app/src/i18n/tr.ts b/packages/console/app/src/i18n/tr.ts index 12e88ca12d..e28afe2b06 100644 --- a/packages/console/app/src/i18n/tr.ts +++ b/packages/console/app/src/i18n/tr.ts @@ -488,7 +488,6 @@ export const dict = { "workspace.cost.deletedSuffix": "(silindi)", "workspace.cost.empty": "Seçilen döneme ait kullanım verisi yok.", "workspace.cost.subscriptionShort": "abonelik", - "workspace.cost.liteShort": "lite", "workspace.keys.title": "API Anahtarları", "workspace.keys.subtitle": "opencode hizmetlerine erişim için API anahtarlarınızı yönetin.", diff --git a/packages/console/app/src/i18n/zh.ts b/packages/console/app/src/i18n/zh.ts index d358d166ea..87ba1b2450 100644 --- a/packages/console/app/src/i18n/zh.ts +++ b/packages/console/app/src/i18n/zh.ts @@ -463,7 +463,6 @@ export const dict = { "workspace.cost.deletedSuffix": "(已删除)", "workspace.cost.empty": "所选期间无可用使用数据。", "workspace.cost.subscriptionShort": "订阅", - "workspace.cost.liteShort": "lite", "workspace.keys.title": "API 密钥", "workspace.keys.subtitle": "管理访问 OpenCode 服务的 API 密钥。", diff --git a/packages/console/app/src/i18n/zht.ts b/packages/console/app/src/i18n/zht.ts index 71488405a8..b3f1db0124 100644 --- a/packages/console/app/src/i18n/zht.ts +++ b/packages/console/app/src/i18n/zht.ts @@ -464,7 +464,6 @@ export const dict = { "workspace.cost.deletedSuffix": "(已刪除)", "workspace.cost.empty": "所選期間沒有可用的使用資料。", "workspace.cost.subscriptionShort": "訂", - "workspace.cost.liteShort": "lite", "workspace.keys.title": "API 金鑰", "workspace.keys.subtitle": "管理你的 API 金鑰以存取 OpenCode 服務。", diff --git a/packages/console/app/src/routes/legal/privacy-policy/index.tsx b/packages/console/app/src/routes/legal/privacy-policy/index.tsx index b1b210455a..42bb71aa39 100644 --- a/packages/console/app/src/routes/legal/privacy-policy/index.tsx +++ b/packages/console/app/src/routes/legal/privacy-policy/index.tsx @@ -21,7 +21,7 @@ export default function PrivacyPolicy() {

Privacy Policy

-

Effective date: Dec 16, 2025

+

Effective date: Mar 6, 2026

At OpenCode, we take your privacy seriously. Please read this Privacy Policy to learn how we treat your @@ -30,7 +30,10 @@ export default function PrivacyPolicy() { By using or accessing our Services in any manner, you acknowledge that you accept the practices and policies outlined below, and you hereby consent that we will collect, use and disclose your information as described in this Privacy Policy. - + {" "} + For clarity, our open source software that is not provided to you on a hosted basis is subject to the + open source license and terms set forth on the applicable repository where you access such open source + software, and such license and terms will exclusively govern your use of such open source software.

@@ -382,9 +385,7 @@ export default function PrivacyPolicy() {

Parties You Authorize, Access or Authenticate

-
    -
  • Home buyers
  • -
+

Parties You Authorize, Access or Authenticate.

Legal Obligations

@@ -1502,6 +1503,7 @@ export default function PrivacyPolicy() { Email: contact@anoma.ly

  • Phone: +1 415 794-0209
  • +
  • Address: 2443 Fillmore St #380-6343, San Francisco, CA 94115, United States
  • diff --git a/packages/console/app/src/routes/legal/terms-of-service/index.tsx b/packages/console/app/src/routes/legal/terms-of-service/index.tsx index f770aa7a06..55a9fd42f1 100644 --- a/packages/console/app/src/routes/legal/terms-of-service/index.tsx +++ b/packages/console/app/src/routes/legal/terms-of-service/index.tsx @@ -21,12 +21,12 @@ export default function TermsOfService() {

    Terms of Use

    -

    Effective date: Dec 16, 2025

    +

    Effective date: Mar 6, 2026

    - Welcome to OpenCode. Please read on to learn the rules and restrictions that govern your use of OpenCode - (the "Services"). If you have any questions, comments, or concerns regarding these terms or the - Services, please contact us at: + Welcome to OpenCode. Please read on to learn the rules and restrictions that govern your use of + OpenCode's website, inference product and hosted software offering (the "Services"). If you have + any questions, comments, or concerns regarding these terms or the Services, please contact us at:

    @@ -44,7 +44,10 @@ export default function TermsOfService() { and/or conditions ("Additional Terms"), which are incorporated herein by reference, and you understand and agree that by using or participating in any such Services, you agree to also comply with these Additional Terms. - + {" "} + For clarity, our open source software that is not provided to you on a hosted basis is subject to the + open source license and terms set forth on the applicable repository where you access such open source + software, and such license and terms will exclusively govern your use of such open source software.

    @@ -460,10 +463,10 @@ export default function TermsOfService() {

    Opt-out

    You have the right to opt out of the provisions of this Section by sending written notice of your - decision to opt out to the following address: [ADDRESS], [CITY], Canada [ZIP CODE] postmarked within - thirty (30) days of first accepting these Terms. You must include (i) your name and residence address, - (ii) the email address and/or telephone number associated with your account, and (iii) a clear statement - that you want to opt out of these Terms' arbitration agreement. + decision to opt out to the following address: 2443 Fillmore St #380-6343, San Francisco, CA 94115, + United States postmarked within thirty (30) days of first accepting these Terms. You must include (i) + your name and residence address, (ii) the email address and/or telephone number associated with your + account, and (iii) a clear statement that you want to opt out of these Terms' arbitration agreement.

    Exclusive Venue

    diff --git a/packages/console/app/src/routes/workspace/[id]/graph-section.tsx b/packages/console/app/src/routes/workspace/[id]/graph-section.tsx index 56a31cdd06..bb4b4f4cfd 100644 --- a/packages/console/app/src/routes/workspace/[id]/graph-section.tsx +++ b/packages/console/app/src/routes/workspace/[id]/graph-section.tsx @@ -218,7 +218,7 @@ export function GraphSection() { const colorTextSecondary = styles.getPropertyValue("--color-text-secondary").trim() const colorBorder = styles.getPropertyValue("--color-border").trim() const subSuffix = ` (${i18n.t("workspace.cost.subscriptionShort")})` - const liteSuffix = ` (${i18n.t("workspace.cost.liteShort")})` + const liteSuffix = " (go)" const dailyDataRegular = new Map>() const dailyDataSub = new Map>() diff --git a/packages/console/core/package.json b/packages/console/core/package.json index 199b5d9bd6..79de75cfbc 100644 --- a/packages/console/core/package.json +++ b/packages/console/core/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode-ai/console-core", - "version": "1.2.19", + "version": "1.2.21", "private": true, "type": "module", "license": "MIT", diff --git a/packages/console/function/package.json b/packages/console/function/package.json index e771aae844..0e4589cc2c 100644 --- a/packages/console/function/package.json +++ b/packages/console/function/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/console-function", - "version": "1.2.19", + "version": "1.2.21", "$schema": "https://json.schemastore.org/package.json", "private": true, "type": "module", diff --git a/packages/console/mail/package.json b/packages/console/mail/package.json index ef8d7c5994..4e28f18c0d 100644 --- a/packages/console/mail/package.json +++ b/packages/console/mail/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/console-mail", - "version": "1.2.19", + "version": "1.2.21", "dependencies": { "@jsx-email/all": "2.2.3", "@jsx-email/cli": "1.4.3", diff --git a/packages/desktop-electron/package.json b/packages/desktop-electron/package.json index e683d185bc..7eca0e0417 100644 --- a/packages/desktop-electron/package.json +++ b/packages/desktop-electron/package.json @@ -1,7 +1,7 @@ { "name": "@opencode-ai/desktop-electron", "private": true, - "version": "1.2.19", + "version": "1.2.21", "type": "module", "license": "MIT", "homepage": "https://opencode.ai", diff --git a/packages/desktop-electron/src/renderer/i18n/index.ts b/packages/desktop-electron/src/renderer/i18n/index.ts index 81158ad244..be87f94f91 100644 --- a/packages/desktop-electron/src/renderer/i18n/index.ts +++ b/packages/desktop-electron/src/renderer/i18n/index.ts @@ -76,6 +76,7 @@ function detectLocale(): Locale { const languages = navigator.languages?.length ? navigator.languages : [navigator.language] for (const language of languages) { if (!language) continue + if (language.toLowerCase().startsWith("en")) return "en" if (language.toLowerCase().startsWith("zh")) { if (language.toLowerCase().includes("hant")) return "zht" return "zh" diff --git a/packages/desktop/package.json b/packages/desktop/package.json index 10e6df26b1..13b3bfed6b 100644 --- a/packages/desktop/package.json +++ b/packages/desktop/package.json @@ -1,7 +1,7 @@ { "name": "@opencode-ai/desktop", "private": true, - "version": "1.2.19", + "version": "1.2.21", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/desktop/src/i18n/index.ts b/packages/desktop/src/i18n/index.ts index 7b1ebfe696..e1c1e63d97 100644 --- a/packages/desktop/src/i18n/index.ts +++ b/packages/desktop/src/i18n/index.ts @@ -77,6 +77,7 @@ function detectLocale(): Locale { const languages = navigator.languages?.length ? navigator.languages : [navigator.language] for (const language of languages) { if (!language) continue + if (language.toLowerCase().startsWith("en")) return "en" if (language.toLowerCase().startsWith("zh")) { if (language.toLowerCase().includes("hant")) return "zht" return "zh" diff --git a/packages/enterprise/package.json b/packages/enterprise/package.json index ef56add880..0479f42eb4 100644 --- a/packages/enterprise/package.json +++ b/packages/enterprise/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/enterprise", - "version": "1.2.19", + "version": "1.2.21", "private": true, "type": "module", "license": "MIT", diff --git a/packages/enterprise/src/core/share.ts b/packages/enterprise/src/core/share.ts index d7f5c8b8d5..c6291b75d2 100644 --- a/packages/enterprise/src/core/share.ts +++ b/packages/enterprise/src/core/share.ts @@ -1,10 +1,8 @@ import { FileDiff, Message, Model, Part, Session } from "@opencode-ai/sdk/v2" import { fn } from "@opencode-ai/util/fn" import { iife } from "@opencode-ai/util/iife" -import { Identifier } from "@opencode-ai/util/identifier" import z from "zod" import { Storage } from "./storage" -import { Binary } from "@opencode-ai/util/binary" export namespace Share { export const Info = z.object({ @@ -38,6 +36,81 @@ export namespace Share { ]) export type Data = z.infer + type Snapshot = { + data: Data[] + } + + type Compaction = { + event?: string + data: Data[] + } + + function key(item: Data) { + switch (item.type) { + case "session": + return "session" + case "message": + return `message/${item.data.id}` + case "part": + return `part/${item.data.messageID}/${item.data.id}` + case "session_diff": + return "session_diff" + case "model": + return "model" + } + } + + function merge(...items: Data[][]) { + const map = new Map() + for (const list of items) { + for (const item of list) { + map.set(key(item), item) + } + } + return Array.from(map.entries()) + .sort(([a], [b]) => a.localeCompare(b)) + .map(([, item]) => item) + } + + async function readSnapshot(shareID: string) { + return (await Storage.read(["share_snapshot", shareID]))?.data + } + + async function writeSnapshot(shareID: string, data: Data[]) { + await Storage.write(["share_snapshot", shareID], { data }) + } + + async function legacy(shareID: string) { + const compaction: Compaction = (await Storage.read(["share_compaction", shareID])) ?? { + data: [], + event: undefined, + } + const list = await Storage.list({ + prefix: ["share_event", shareID], + before: compaction.event, + }).then((x) => x.toReversed()) + if (list.length === 0) { + if (compaction.data.length > 0) await writeSnapshot(shareID, compaction.data) + return compaction.data + } + + const next = merge( + compaction.data, + await Promise.all(list.map(async (event) => await Storage.read(event))).then((x) => + x.flatMap((item) => item ?? []), + ), + ) + + await Promise.all([ + Storage.write(["share_compaction", shareID], { + event: list.at(-1)?.at(-1), + data: next, + }), + writeSnapshot(shareID, next), + ]) + return next + } + export const create = fn(z.object({ sessionID: z.string() }), async (body) => { const isTest = process.env.NODE_ENV === "test" || body.sessionID.startsWith("test_") const info: Info = { @@ -47,7 +120,7 @@ export namespace Share { } const exists = await get(info.id) if (exists) throw new Errors.AlreadyExists(info.id) - await Storage.write(["share", info.id], info) + await Promise.all([Storage.write(["share", info.id], info), writeSnapshot(info.id, [])]) return info }) @@ -60,8 +133,13 @@ export namespace Share { if (!share) throw new Errors.NotFound(body.id) if (share.secret !== body.secret) throw new Errors.InvalidSecret(body.id) await Storage.remove(["share", body.id]) - const list = await Storage.list({ prefix: ["share_data", body.id] }) - for (const item of list) { + const groups = await Promise.all([ + Storage.list({ prefix: ["share_snapshot", body.id] }), + Storage.list({ prefix: ["share_compaction", body.id] }), + Storage.list({ prefix: ["share_event", body.id] }), + Storage.list({ prefix: ["share_data", body.id] }), + ]) + for (const item of groups.flat()) { await Storage.remove(item) } }) @@ -75,59 +153,13 @@ export namespace Share { const share = await get(input.share.id) if (!share) throw new Errors.NotFound(input.share.id) if (share.secret !== input.share.secret) throw new Errors.InvalidSecret(input.share.id) - await Storage.write(["share_event", input.share.id, Identifier.descending()], input.data) + const data = (await readSnapshot(input.share.id)) ?? (await legacy(input.share.id)) + await writeSnapshot(input.share.id, merge(data, input.data)) }, ) - type Compaction = { - event?: string - data: Data[] - } - export async function data(shareID: string) { - console.log("reading compaction") - const compaction: Compaction = (await Storage.read(["share_compaction", shareID])) ?? { - data: [], - event: undefined, - } - console.log("reading pending events") - const list = await Storage.list({ - prefix: ["share_event", shareID], - before: compaction.event, - }).then((x) => x.toReversed()) - - console.log("compacting", list.length) - - if (list.length > 0) { - const data = await Promise.all(list.map(async (event) => await Storage.read(event))).then((x) => x.flat()) - for (const item of data) { - if (!item) continue - const key = (item: Data) => { - switch (item.type) { - case "session": - return "session" - case "message": - return `message/${item.data.id}` - case "part": - return `${item.data.messageID}/${item.data.id}` - case "session_diff": - return "session_diff" - case "model": - return "model" - } - } - const id = key(item) - const result = Binary.search(compaction.data, id, key) - if (result.found) { - compaction.data[result.index] = item - } else { - compaction.data.splice(result.index, 0, item) - } - } - compaction.event = list.at(-1)?.at(-1) - await Storage.write(["share_compaction", shareID], compaction) - } - return compaction.data + return (await readSnapshot(shareID)) ?? legacy(shareID) } export const syncOld = fn( diff --git a/packages/enterprise/src/routes/api/[...path].ts b/packages/enterprise/src/routes/api/[...path].ts index e77c00de92..f97788bd03 100644 --- a/packages/enterprise/src/routes/api/[...path].ts +++ b/packages/enterprise/src/routes/api/[...path].ts @@ -108,6 +108,7 @@ app validator("param", z.object({ shareID: z.string() })), async (c) => { const { shareID } = c.req.valid("param") + c.header("Cache-Control", "public, max-age=30, s-maxage=300, stale-while-revalidate=86400") return c.json(await Share.data(shareID)) }, ) diff --git a/packages/enterprise/src/routes/share/[shareID].tsx b/packages/enterprise/src/routes/share/[shareID].tsx index 007b4c268d..e755ea75a1 100644 --- a/packages/enterprise/src/routes/share/[shareID].tsx +++ b/packages/enterprise/src/routes/share/[shareID].tsx @@ -5,12 +5,11 @@ import { DataProvider } from "@opencode-ai/ui/context" import { FileComponentProvider } from "@opencode-ai/ui/context/file" import { WorkerPoolProvider } from "@opencode-ai/ui/context/worker-pool" import { createAsync, query, useParams } from "@solidjs/router" -import { createEffect, createMemo, ErrorBoundary, For, Match, Show, Switch } from "solid-js" +import { createMemo, createSignal, ErrorBoundary, For, Match, Show, Switch } from "solid-js" import { Share } from "~/core/share" import { Logo, Mark } from "@opencode-ai/ui/logo" import { IconButton } from "@opencode-ai/ui/icon-button" import { ProviderIcon } from "@opencode-ai/ui/provider-icon" -import { createDefaultOptions } from "@opencode-ai/ui/pierre" import { iife } from "@opencode-ai/util/iife" import { Binary } from "@opencode-ai/util/binary" import { NamedError } from "@opencode-ai/util/error" @@ -20,11 +19,11 @@ import z from "zod" import NotFound from "../[...404]" import { Tabs } from "@opencode-ai/ui/tabs" import { MessageNav } from "@opencode-ai/ui/message-nav" -import { preloadMultiFileDiff, PreloadMultiFileDiffResult } from "@pierre/diffs/ssr" import { FileSSR } from "@opencode-ai/ui/file-ssr" import { clientOnly } from "@solidjs/start" import { Meta, Title } from "@solidjs/meta" import { Base64 } from "js-base64" +import { getRequestEvent } from "solid-js/web" const ClientOnlyWorkerPoolProvider = clientOnly(() => import("@opencode-ai/ui/pierre/worker").then((m) => ({ @@ -54,12 +53,6 @@ const getData = query(async (shareID) => { session_diff: { [sessionID: string]: FileDiff[] } - session_diff_preload: { - [sessionID: string]: PreloadMultiFileDiffResult[] - } - session_diff_preload_split: { - [sessionID: string]: PreloadMultiFileDiffResult[] - } session_status: { [sessionID: string]: SessionStatus } @@ -79,12 +72,6 @@ const getData = query(async (shareID) => { session_diff: { [share.sessionID]: [], }, - session_diff_preload: { - [share.sessionID]: [], - }, - session_diff_preload_split: { - [share.sessionID]: [], - }, session_status: { [share.sessionID]: { type: "idle", @@ -101,28 +88,6 @@ const getData = query(async (shareID) => { break case "session_diff": result.session_diff[share.sessionID] = item.data - await Promise.all([ - Promise.all( - item.data.map(async (diff) => - preloadMultiFileDiff({ - oldFile: { name: diff.file, contents: diff.before }, - newFile: { name: diff.file, contents: diff.after }, - options: createDefaultOptions("unified"), - // annotations, - }), - ), - ).then((r) => (result.session_diff_preload[share.sessionID] = r)), - Promise.all( - item.data.map(async (diff) => - preloadMultiFileDiff({ - oldFile: { name: diff.file, contents: diff.before }, - newFile: { name: diff.file, contents: diff.after }, - options: createDefaultOptions("split"), - // annotations, - }), - ), - ).then((r) => (result.session_diff_preload_split[share.sessionID] = r)), - ]) break case "message": result.message[item.data.sessionID] = result.message[item.data.sessionID] ?? [] @@ -143,17 +108,15 @@ const getData = query(async (shareID) => { }, "getShareData") export default function () { + getRequestEvent()?.response.headers.set( + "Cache-Control", + "public, max-age=30, s-maxage=300, stale-while-revalidate=86400", + ) + const params = useParams() const data = createAsync(async () => { if (!params.shareID) throw new Error("Missing shareID") - const now = Date.now() - const data = getData(params.shareID) - console.log("getData", Date.now() - now) - return data - }) - - createEffect(() => { - console.log(data()) + return getData(params.shareID) }) return ( @@ -241,22 +204,8 @@ export default function () { const provider = createMemo(() => activeMessage()?.model?.providerID) const modelID = createMemo(() => activeMessage()?.model?.modelID) const model = createMemo(() => data().model[data().sessionID]?.find((m) => m.id === modelID())) - const diffs = createMemo(() => { - const diffs = data().session_diff[data().sessionID] ?? [] - const preloaded = data().session_diff_preload[data().sessionID] ?? [] - return diffs.map((diff) => ({ - ...diff, - preloaded: preloaded.find((d) => d.newFile.name === diff.file), - })) - }) - const splitDiffs = createMemo(() => { - const diffs = data().session_diff[data().sessionID] ?? [] - const preloaded = data().session_diff_preload_split[data().sessionID] ?? [] - return diffs.map((diff) => ({ - ...diff, - preloaded: preloaded.find((d) => d.newFile.name === diff.file), - })) - }) + const diffs = createMemo(() => data().session_diff[data().sessionID] ?? []) + const [diffStyle, setDiffStyle] = createSignal<"unified" | "split">("unified") const title = () => (
    @@ -380,18 +329,9 @@ export default function () { 0}>
    -
    } /> @@ -1528,7 +1436,8 @@ ToolRegistry.register({ }) return ( - - + ) }, }) @@ -1554,7 +1463,8 @@ ToolRegistry.register({ }) return ( - - + ) }, }) @@ -1574,15 +1484,20 @@ ToolRegistry.register({ render(props) { const data = useData() const i18n = useI18n() - const location = useLocation() const childSessionId = () => props.metadata.sessionId as string | undefined - const title = createMemo(() => i18n.t("ui.tool.agent", { type: props.input.subagent_type || props.tool })) + const type = createMemo(() => { + const raw = props.input.subagent_type + if (typeof raw !== "string" || !raw) return undefined + return raw[0]!.toUpperCase() + raw.slice(1) + }) + const title = createMemo(() => agentTitle(i18n, type())) const description = createMemo(() => { const value = props.input.description if (typeof value === "string") return value return undefined }) - const running = createMemo(() => props.status === "pending" || props.status === "running") + const running = createMemo(() => busy(props.status)) + const reveal = useToolReveal(running, () => props.reveal !== false) const href = createMemo(() => { const sessionId = childSessionId() @@ -1591,36 +1506,49 @@ ToolRegistry.register({ const direct = data.sessionHref?.(sessionId) if (direct) return direct - const path = location.pathname + if (typeof window === "undefined") return + const path = window.location.pathname const idx = path.indexOf("/session") if (idx === -1) return return `${path.slice(0, idx)}/session/${sessionId}` }) - const titleContent = () => + const handleLinkClick = (e: MouseEvent) => { + const sessionId = childSessionId() + const url = href() + if (!sessionId || !url) return + + e.stopPropagation() + + if (e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return + + const nav = data.navigateToSession + if (!nav || typeof window === "undefined") return + + e.preventDefault() + const before = window.location.pathname + window.location.search + window.location.hash + nav(sessionId) + setTimeout(() => { + const after = window.location.pathname + window.location.search + window.location.hash + if (after === before) window.location.assign(url) + }, 50) + } const trigger = () => (
    - - {titleContent()} + + {(url) => ( - e.stopPropagation()} - > - {description()} - + )} - {description()} + @@ -1628,7 +1556,7 @@ ToolRegistry.register({
    ) - return + return }, }) @@ -1636,13 +1564,26 @@ ToolRegistry.register({ name: "bash", render(props) { const i18n = useI18n() - const pending = () => props.status === "pending" || props.status === "running" - const sawPending = pending() - const text = createMemo(() => { - const cmd = props.input.command ?? props.metadata.command ?? "" - const out = stripAnsi(props.output || props.metadata.output || "") - return `$ ${cmd}${out ? "\n\n" + out : ""}` + const pending = () => busy(props.status) + const reveal = useToolReveal(pending, () => props.reveal !== false) + const subtitle = () => props.input.description ?? props.metadata.description + const cmd = createMemo(() => { + const value = props.input.command ?? props.metadata.command + if (typeof value === "string") return value + return "" }) + const output = createMemo(() => { + if (typeof props.output === "string") return props.output + if (typeof props.metadata.output === "string") return props.metadata.output + return "" + }) + const command = createMemo(() => `$ ${cmd()}`) + const result = createMemo(() => stripAnsi(output())) + const text = createMemo(() => { + const value = result() + return `${command()}${value ? "\n\n" + value : ""}` + }) + const hasOutput = createMemo(() => result().length > 0) const [copied, setCopied] = createSignal(false) const handleCopy = async () => { @@ -1654,18 +1595,20 @@ ToolRegistry.register({ } return ( -
    - - - + {(text) => }
    } @@ -1693,7 +1636,7 @@ ToolRegistry.register({
    - + ) }, }) @@ -1706,10 +1649,12 @@ ToolRegistry.register({ const diagnostics = createMemo(() => getDiagnostics(props.metadata.diagnostics, props.input.filePath)) const path = createMemo(() => props.metadata?.filediff?.file || props.input.filePath || "") const filename = () => getFilename(props.input.filePath ?? "") - const pending = () => props.status === "pending" || props.status === "running" + const pending = () => busy(props.status) + const reveal = useToolReveal(pending, () => props.reveal !== false) return (
    - - - {filename()} + + {(name) => ( + + )}
    - -
    - {getDirectory(props.input.filePath!)} -
    -
    -
    -
    - - -
    } @@ -1742,7 +1684,9 @@ ToolRegistry.register({ {(diff) => }
    + + {(diff) => } + } >
    @@ -1762,7 +1706,7 @@ ToolRegistry.register({ - +
    ) }, @@ -1776,10 +1720,12 @@ ToolRegistry.register({ const diagnostics = createMemo(() => getDiagnostics(props.metadata.diagnostics, props.input.filePath)) const path = createMemo(() => props.input.filePath || "") const filename = () => getFilename(props.input.filePath ?? "") - const pending = () => props.status === "pending" || props.status === "running" + const pending = () => busy(props.status) + const reveal = useToolReveal(pending, () => props.reveal !== false) return (
    - - - {filename()} + + {(name) => ( + + )}
    - -
    - {getDirectory(props.input.filePath!)} -
    -
    -
    {/* */}
    } > @@ -1821,7 +1767,7 @@ ToolRegistry.register({ - +
    ) }, @@ -1845,7 +1791,8 @@ ToolRegistry.register({ const i18n = useI18n() const fileComponent = useFileComponent() const files = createMemo(() => (props.metadata.files ?? []) as ApplyPatchFile[]) - const pending = createMemo(() => props.status === "pending" || props.status === "running") + const pending = createMemo(() => busy(props.status)) + const reveal = useToolReveal(pending, () => props.reveal !== false) const single = createMemo(() => { const list = files() if (list.length !== 1) return @@ -1853,7 +1800,6 @@ ToolRegistry.register({ }) const [expanded, setExpanded] = createSignal([]) let seeded = false - createEffect(() => { const list = files() if (list.length === 0) return @@ -1861,7 +1807,6 @@ ToolRegistry.register({ seeded = true setExpanded(list.filter((f) => f.type !== "delete").map((f) => f.filePath)) }) - const subtitle = createMemo(() => { const count = files().length if (count === 0) return "" @@ -1869,24 +1814,44 @@ ToolRegistry.register({ }) return ( - - +
    + +
    +
    + + + + + {(file) => ( + + )} + + {(text) => } +
    +
    +
    + } + > + 0}> setExpanded(Array.isArray(value) ? value : value ? [value] : [])} > @@ -1894,13 +1859,11 @@ ToolRegistry.register({ {(file) => { const active = createMemo(() => expanded().includes(file.filePath)) const [visible, setVisible] = createSignal(false) - createEffect(() => { if (!active()) { setVisible(false) return } - requestAnimationFrame(() => { if (!active()) return setVisible(true) @@ -1965,41 +1928,9 @@ ToolRegistry.register({ -
    -
    - } - > - {(file) => ( -
    - -
    -
    - - - - - {getFilename(file().relativePath)} - -
    - -
    - {getDirectory(file().relativePath)} -
    -
    -
    -
    - - - -
    -
    - } - > + } + > + {(file) => ( - + } @@ -2034,10 +1968,10 @@ ToolRegistry.register({ />
    - -
    - )} - + )} + + +
    ) }, }) @@ -2055,6 +1989,7 @@ ToolRegistry.register({ return [] }) + const pending = createMemo(() => busy(props.status)) const subtitle = createMemo(() => { const list = todos() @@ -2063,14 +1998,19 @@ ToolRegistry.register({ }) return ( - + } >
    @@ -2088,7 +2028,7 @@ ToolRegistry.register({
    -
    + ) }, }) @@ -2100,6 +2040,7 @@ ToolRegistry.register({ const questions = createMemo(() => (props.input.questions ?? []) as QuestionInfo[]) const answers = createMemo(() => (props.metadata.answers ?? []) as QuestionAnswer[]) const completed = createMemo(() => answers().length > 0) + const pending = createMemo(() => busy(props.status)) const subtitle = createMemo(() => { const count = questions().length @@ -2109,14 +2050,19 @@ ToolRegistry.register({ }) return ( - + } >
    @@ -2133,7 +2079,7 @@ ToolRegistry.register({
    -
    + ) }, }) @@ -2141,21 +2087,28 @@ ToolRegistry.register({ ToolRegistry.register({ name: "skill", render(props) { - const title = createMemo(() => props.input.name || "skill") - const running = createMemo(() => props.status === "pending" || props.status === "running") - - const titleContent = () => - - const trigger = () => ( -
    -
    - - {titleContent()} - -
    -
    + const i18n = useI18n() + const pending = createMemo(() => busy(props.status)) + const name = createMemo(() => { + const value = props.input.name || props.metadata.name + if (typeof value === "string") return value + }) + return ( + + } + animate + /> ) - - return }, }) diff --git a/packages/ui/src/components/motion-spring.tsx b/packages/ui/src/components/motion-spring.tsx index a5104a1a3e..5deefcfa61 100644 --- a/packages/ui/src/components/motion-spring.tsx +++ b/packages/ui/src/components/motion-spring.tsx @@ -1,8 +1,9 @@ import { attachSpring, motionValue } from "motion" import type { SpringOptions } from "motion" import { createEffect, createSignal, onCleanup } from "solid-js" +import { prefersReducedMotion } from "../hooks/use-reduced-motion" -type Opt = Partial> +type Opt = Pick const eq = (a: Opt | undefined, b: Opt | undefined) => a?.visualDuration === b?.visualDuration && a?.bounce === b?.bounce && @@ -13,24 +14,41 @@ const eq = (a: Opt | undefined, b: Opt | undefined) => export function useSpring(target: () => number, options?: Opt | (() => Opt)) { const read = () => (typeof options === "function" ? options() : options) + const reduce = prefersReducedMotion const [value, setValue] = createSignal(target()) const source = motionValue(value()) const spring = motionValue(value()) let config = read() - let stop = attachSpring(spring, source, config) - let off = spring.on("change", (next: number) => setValue(next)) + let reduced = reduce() + let stop = reduced ? () => {} : attachSpring(spring, source, config) + let off = spring.on("change", (next) => setValue(next)) createEffect(() => { - source.set(target()) + const next = target() + if (reduced) { + source.set(next) + spring.set(next) + setValue(next) + return + } + source.set(next) }) createEffect(() => { - if (!options) return const next = read() - if (eq(config, next)) return + const skip = reduce() + if (eq(config, next) && reduced === skip) return config = next + reduced = skip stop() - stop = attachSpring(spring, source, next) + stop = skip ? () => {} : attachSpring(spring, source, next) + if (skip) { + const value = target() + source.set(value) + spring.set(value) + setValue(value) + return + } setValue(spring.get()) }) diff --git a/packages/ui/src/components/motion.tsx b/packages/ui/src/components/motion.tsx new file mode 100644 index 0000000000..6cdf01c731 --- /dev/null +++ b/packages/ui/src/components/motion.tsx @@ -0,0 +1,77 @@ +import { followValue } from "motion" +import type { MotionValue } from "motion" + +export { animate, springValue } from "motion" +export type { AnimationPlaybackControls } from "motion" + +/** + * Like `springValue` but preserves getters on the config object. + * `springValue` spreads config at creation, snapshotting getter values. + * This passes the config through to `followValue` intact, so getters + * on `visualDuration` etc. fire on every `.set()` call. + */ +export function tunableSpringValue(initial: T, config: SpringConfig): MotionValue { + return followValue(initial, config as any) +} + +let _growDuration = 0.5 +let _collapsibleDuration = 0.3 + +export const GROW_SPRING = { + type: "spring" as const, + get visualDuration() { + return _growDuration + }, + bounce: 0, +} + +export const COLLAPSIBLE_SPRING = { + type: "spring" as const, + get visualDuration() { + return _collapsibleDuration + }, + bounce: 0, +} + +export const setGrowDuration = (v: number) => { + _growDuration = v +} +export const setCollapsibleDuration = (v: number) => { + _collapsibleDuration = v +} +export const getGrowDuration = () => _growDuration +export const getCollapsibleDuration = () => _collapsibleDuration + +export type SpringConfig = { type: "spring"; visualDuration: number; bounce: number } + +export const FAST_SPRING = { + type: "spring" as const, + visualDuration: 0.35, + bounce: 0, +} + +export const GLOW_SPRING = { + type: "spring" as const, + visualDuration: 0.4, + bounce: 0.15, +} + +export const WIPE_MASK = + "linear-gradient(to right, rgba(0,0,0,1) 0%, rgba(0,0,0,1) 45%, rgba(0,0,0,0) 60%, rgba(0,0,0,0) 100%)" + +export const clearMaskStyles = (el: HTMLElement) => { + el.style.maskImage = "" + el.style.webkitMaskImage = "" + el.style.maskSize = "" + el.style.webkitMaskSize = "" + el.style.maskRepeat = "" + el.style.webkitMaskRepeat = "" + el.style.maskPosition = "" + el.style.webkitMaskPosition = "" +} + +export const clearFadeStyles = (el: HTMLElement) => { + el.style.opacity = "" + el.style.filter = "" + el.style.transform = "" +} diff --git a/packages/ui/src/components/rolling-results.css b/packages/ui/src/components/rolling-results.css new file mode 100644 index 0000000000..200b2a97e9 --- /dev/null +++ b/packages/ui/src/components/rolling-results.css @@ -0,0 +1,92 @@ +[data-component="rolling-results"] { + --rolling-results-row-height: 22px; + --rolling-results-fixed-height: var(--rolling-results-row-height); + --rolling-results-fixed-gap: 0px; + --rolling-results-row-gap: 0px; + + display: block; + width: 100%; + min-width: 0; + + [data-slot="rolling-results-viewport"] { + position: relative; + min-width: 0; + height: 0; + overflow: clip; + } + + &[data-overflowing="true"]:not([data-scrollable="true"]) [data-slot="rolling-results-window"] { + mask-image: linear-gradient( + to bottom, + transparent 0%, + black var(--rolling-results-fade), + black calc(100% - calc(var(--rolling-results-fade) * 0.5)), + transparent 100% + ); + -webkit-mask-image: linear-gradient( + to bottom, + transparent 0%, + black var(--rolling-results-fade), + black calc(100% - calc(var(--rolling-results-fade) * 0.5)), + transparent 100% + ); + } + + [data-slot="rolling-results-fixed"] { + min-width: 0; + height: var(--rolling-results-fixed-height); + min-height: var(--rolling-results-fixed-height); + display: flex; + align-items: center; + } + + [data-slot="rolling-results-window"] { + min-width: 0; + margin-top: var(--rolling-results-fixed-gap); + height: calc(100% - var(--rolling-results-fixed-height) - var(--rolling-results-fixed-gap)); + overflow: clip; + } + + &[data-scrollable="true"] [data-slot="rolling-results-window"] { + scrollbar-width: none; + -ms-overflow-style: none; + + &::-webkit-scrollbar { + display: none; + } + } + + &[data-scrollable="true"] [data-slot="rolling-results-track"] { + transform: none !important; + will-change: auto; + } + + [data-slot="rolling-results-body"] { + min-width: 0; + } + + [data-slot="rolling-results-track"] { + display: flex; + min-width: 0; + flex-direction: column; + gap: var(--rolling-results-row-gap); + will-change: transform; + } + + [data-slot="rolling-results-row"], + [data-slot="rolling-results-empty"] { + min-width: 0; + height: var(--rolling-results-row-height); + min-height: var(--rolling-results-row-height); + display: flex; + align-items: center; + } + + [data-slot="rolling-results-row"] { + color: var(--text-base); + } + + [data-slot="rolling-results-empty"] { + color: var(--text-weaker); + } +} diff --git a/packages/ui/src/components/rolling-results.tsx b/packages/ui/src/components/rolling-results.tsx new file mode 100644 index 0000000000..d2f30105e5 --- /dev/null +++ b/packages/ui/src/components/rolling-results.tsx @@ -0,0 +1,326 @@ +import { For, Show, batch, createEffect, createMemo, createSignal, on, onCleanup, onMount, type JSX } from "solid-js" +import { animate, clearMaskStyles, GROW_SPRING, type AnimationPlaybackControls, type SpringConfig } from "./motion" +import { prefersReducedMotion } from "../hooks/use-reduced-motion" + +export type RollingResultsProps = { + items: T[] + render: (item: T, index: number) => JSX.Element + fixed?: JSX.Element + getKey?: (item: T, index: number) => string + rows?: number + rowHeight?: number + fixedHeight?: number + rowGap?: number + open?: boolean + scrollable?: boolean + spring?: SpringConfig + animate?: boolean + class?: string + empty?: JSX.Element + noFadeOnCollapse?: boolean +} + +export function RollingResults(props: RollingResultsProps) { + let view: HTMLDivElement | undefined + let track: HTMLDivElement | undefined + let windowEl: HTMLDivElement | undefined + let shift: AnimationPlaybackControls | undefined + let resize: AnimationPlaybackControls | undefined + let edgeFade: AnimationPlaybackControls | undefined + + const reducedMotion = prefersReducedMotion + + const rows = createMemo(() => Math.max(1, Math.round(props.rows ?? 3))) + const rowHeight = createMemo(() => Math.max(16, Math.round(props.rowHeight ?? 22))) + const fixedHeight = createMemo(() => Math.max(0, Math.round(props.fixedHeight ?? rowHeight()))) + const rowGap = createMemo(() => Math.max(0, Math.round(props.rowGap ?? 0))) + const fixed = createMemo(() => props.fixed !== undefined) + const list = createMemo(() => props.items ?? []) + const count = createMemo(() => list().length) + + // scrollReady is the internal "transition complete" state. + // It only becomes true after props.scrollable is true AND the offset animation has settled. + const [scrollReady, setScrollReady] = createSignal(false) + + const backstop = createMemo(() => Math.max(rows() * 2, 12)) + const rendered = createMemo(() => { + const items = list() + if (scrollReady()) return items + const max = backstop() + return items.length > max ? items.slice(-max) : items + }) + const skipped = createMemo(() => { + if (scrollReady()) return 0 + return count() - rendered().length + }) + const open = createMemo(() => props.open !== false) + const active = createMemo(() => (props.animate !== false || props.spring !== undefined) && !reducedMotion()) + const noFade = () => props.noFadeOnCollapse === true + const overflowing = createMemo(() => count() > rows()) + const shown = createMemo(() => Math.min(rows(), count())) + const step = createMemo(() => rowHeight() + rowGap()) + const offset = createMemo(() => Math.max(0, count() - shown()) * step()) + const body = createMemo(() => { + if (shown() > 0) { + return shown() * rowHeight() + Math.max(0, shown() - 1) * rowGap() + } + if (props.empty === undefined) return 0 + return rowHeight() + }) + const gap = createMemo(() => { + if (!fixed()) return 0 + if (body() <= 0) return 0 + return rowGap() + }) + const height = createMemo(() => { + if (!open()) return 0 + if (!fixed()) return body() + return fixedHeight() + gap() + body() + }) + + const key = (item: T, index: number) => { + const value = props.getKey + if (value) return value(item, index) + return String(index) + } + + const setTrack = (value: number) => { + if (!track) return + track.style.transform = `translateY(${-Math.round(value)}px)` + } + + const setView = (value: number) => { + if (!view) return + view.style.height = `${Math.max(0, Math.round(value))}px` + } + + onMount(() => { + setTrack(offset()) + }) + + // Original WAAPI offset animation — untouched rolling behavior. + createEffect( + on( + offset, + (next) => { + if (!track) return + if (scrollReady()) return + if (props.scrollable) return + if (!active()) { + shift?.stop() + shift = undefined + setTrack(next) + return + } + shift?.stop() + const anim = animate(track, { transform: `translateY(${-next}px)` }, props.spring ?? GROW_SPRING) + shift = anim + anim.finished + .catch(() => {}) + .finally(() => { + if (shift !== anim) return + setTrack(next) + shift = undefined + }) + }, + { defer: true }, + ), + ) + + // Scrollable transition: wait for the offset animation to finish, + // then batch all DOM changes in one synchronous pass. + createEffect( + on( + () => props.scrollable === true, + (isScrollable) => { + if (!isScrollable) { + setScrollReady(false) + if (windowEl) { + windowEl.style.overflowY = "" + windowEl.style.maskImage = "" + windowEl.style.webkitMaskImage = "" + } + return + } + // Wait for the current offset animation to settle (if any). + const done = shift?.finished ?? Promise.resolve() + done + .catch(() => {}) + .then(() => { + if (props.scrollable !== true) return + + // Batch the signal update — Solid updates the DOM synchronously: + // rendered() returns all items, skipped() returns 0, padding-top removed, + // data-scrollable becomes "true". + batch(() => setScrollReady(true)) + + // Now the DOM has all items. Safe to switch layout strategy. + // CSS handles `transform: none !important` on [data-scrollable="true"]. + if (windowEl) { + windowEl.style.overflowY = "auto" + windowEl.scrollTop = windowEl.scrollHeight + } + updateScrollMask() + }) + }, + ), + ) + + // Auto-scroll to bottom when new items arrive in scrollable mode + const [userScrolled, setUserScrolled] = createSignal(false) + + const updateScrollMask = () => { + if (!windowEl) return + if (!scrollReady()) { + windowEl.style.maskImage = "" + windowEl.style.webkitMaskImage = "" + return + } + const { scrollTop, scrollHeight, clientHeight } = windowEl + const atBottom = scrollHeight - scrollTop - clientHeight < 8 + // Top fade is always present in scrollable mode (matches rolling mode appearance). + // Bottom fade only when not scrolled to the end. + const mask = atBottom + ? "linear-gradient(to bottom, transparent 0, black 8px)" + : "linear-gradient(to bottom, transparent 0, black 8px, black calc(100% - 8px), transparent 100%)" + windowEl.style.maskImage = mask + windowEl.style.webkitMaskImage = mask + } + + createEffect(() => { + if (!scrollReady()) { + setUserScrolled(false) + return + } + const _n = count() + const scrolled = userScrolled() + if (scrolled) return + if (windowEl) { + windowEl.scrollTop = windowEl.scrollHeight + updateScrollMask() + } + }) + + const onWindowScroll = () => { + if (!windowEl || !scrollReady()) return + const atBottom = windowEl.scrollHeight - windowEl.scrollTop - windowEl.clientHeight < 8 + setUserScrolled(!atBottom) + updateScrollMask() + } + + const EDGE_MASK = "linear-gradient(to top, transparent 0%, black 8px)" + const applyEdge = () => { + if (!view) return + edgeFade?.stop() + edgeFade = undefined + view.style.maskImage = EDGE_MASK + view.style.webkitMaskImage = EDGE_MASK + view.style.maskSize = "100% 100%" + view.style.maskRepeat = "no-repeat" + } + const clearEdge = () => { + if (!view) return + if (!active()) { + clearMaskStyles(view) + return + } + edgeFade?.stop() + const anim = animate(view, { maskSize: "100% 200%" }, props.spring ?? GROW_SPRING) + edgeFade = anim + anim.finished + .catch(() => {}) + .then(() => { + if (edgeFade !== anim || !view) return + clearMaskStyles(view) + edgeFade = undefined + }) + } + + createEffect( + on(height, (next, prev) => { + if (!view) return + if (!active()) { + resize?.stop() + resize = undefined + setView(next) + view.style.opacity = "" + clearEdge() + return + } + const collapsing = next === 0 && prev !== undefined && prev > 0 + const expanding = prev === 0 && next > 0 + resize?.stop() + view.style.opacity = "" + applyEdge() + const spring = props.spring ?? GROW_SPRING + const anim = collapsing + ? animate(view, noFade() ? { height: `${next}px` } : { height: `${next}px`, opacity: 0 }, spring) + : expanding + ? animate(view, noFade() ? { height: `${next}px` } : { height: `${next}px`, opacity: [0, 1] }, spring) + : animate(view, { height: `${next}px` }, spring) + resize = anim + anim.finished + .catch(() => {}) + .finally(() => { + view.style.opacity = "" + if (resize !== anim) return + setView(next) + resize = undefined + clearEdge() + }) + }), + ) + + onCleanup(() => { + shift?.stop() + resize?.stop() + edgeFade?.stop() + shift = undefined + resize = undefined + edgeFade = undefined + }) + + return ( +
    +
    + +
    {props.fixed}
    +
    +
    +
    + +
    {props.empty}
    +
    +
    + + {(item, index) => ( +
    + {props.render(item, index())} +
    + )} +
    +
    +
    +
    +
    +
    + ) +} diff --git a/packages/ui/src/components/scroll-view.css b/packages/ui/src/components/scroll-view.css index f6a49e241c..a8574cc9f7 100644 --- a/packages/ui/src/components/scroll-view.css +++ b/packages/ui/src/components/scroll-view.css @@ -9,6 +9,13 @@ overflow-y: auto; scrollbar-width: none; outline: none; + display: block; + overflow-anchor: none; +} + +.scroll-view__viewport[data-reverse="true"] { + display: flex; + flex-direction: column-reverse; } .scroll-view__viewport::-webkit-scrollbar { @@ -45,18 +52,6 @@ background-color: var(--border-strong-base); } -.dark .scroll-view__thumb::after, -[data-theme="dark"] .scroll-view__thumb::after { - background-color: var(--border-weak-base); -} - -.dark .scroll-view__thumb:hover::after, -[data-theme="dark"] .scroll-view__thumb:hover::after, -.dark .scroll-view__thumb[data-dragging="true"]::after, -[data-theme="dark"] .scroll-view__thumb[data-dragging="true"]::after { - background-color: var(--border-strong-base); -} - .scroll-view__thumb[data-visible="true"] { opacity: 1; } diff --git a/packages/ui/src/components/scroll-view.tsx b/packages/ui/src/components/scroll-view.tsx index 52ed39a465..a8d3cf0f84 100644 --- a/packages/ui/src/components/scroll-view.tsx +++ b/packages/ui/src/components/scroll-view.tsx @@ -1,17 +1,18 @@ -import { createSignal, onCleanup, onMount, splitProps, type ComponentProps, Show, mergeProps } from "solid-js" +import { createSignal, onCleanup, onMount, splitProps, type ComponentProps, Show } from "solid-js" +import { animate, type AnimationPlaybackControls } from "motion" import { useI18n } from "../context/i18n" +import { FAST_SPRING } from "./motion" export interface ScrollViewProps extends ComponentProps<"div"> { viewportRef?: (el: HTMLDivElement) => void - orientation?: "vertical" | "horizontal" // currently only vertical is fully implemented for thumb + reverse?: boolean } export function ScrollView(props: ScrollViewProps) { const i18n = useI18n() - const merged = mergeProps({ orientation: "vertical" }, props) const [local, events, rest] = splitProps( - merged, - ["class", "children", "viewportRef", "orientation", "style"], + props, + ["class", "children", "viewportRef", "style", "reverse"], [ "onScroll", "onWheel", @@ -25,9 +26,9 @@ export function ScrollView(props: ScrollViewProps) { ], ) - let rootRef!: HTMLDivElement let viewportRef!: HTMLDivElement let thumbRef!: HTMLDivElement + let anim: AnimationPlaybackControls | undefined const [isHovered, setIsHovered] = createSignal(false) const [isDragging, setIsDragging] = createSignal(false) @@ -36,6 +37,8 @@ export function ScrollView(props: ScrollViewProps) { const [thumbTop, setThumbTop] = createSignal(0) const [showThumb, setShowThumb] = createSignal(false) + const reverse = () => local.reverse === true + const updateThumb = () => { if (!viewportRef) return const { scrollTop, scrollHeight, clientHeight } = viewportRef @@ -57,9 +60,13 @@ export function ScrollView(props: ScrollViewProps) { const maxScrollTop = scrollHeight - clientHeight const maxThumbTop = trackHeight - height - const top = maxScrollTop > 0 ? (scrollTop / maxScrollTop) * maxThumbTop : 0 + const top = (() => { + if (maxScrollTop <= 0) return 0 + if (!reverse()) return (scrollTop / maxScrollTop) * maxThumbTop + return ((maxScrollTop + scrollTop) / maxScrollTop) * maxThumbTop + })() - // Ensure thumb stays within bounds (shouldn't be necessary due to math above, but good for safety) + // Ensure thumb stays within bounds const boundedTop = trackPadding + Math.max(0, Math.min(top, maxThumbTop)) setThumbHeight(height) @@ -82,6 +89,7 @@ export function ScrollView(props: ScrollViewProps) { } onCleanup(() => { + stop() observer.disconnect() }) @@ -123,6 +131,31 @@ export function ScrollView(props: ScrollViewProps) { thumbRef.addEventListener("pointerup", onPointerUp) } + const stop = () => { + if (!anim) return + anim.stop() + anim = undefined + } + + const limit = (top: number) => { + const max = viewportRef.scrollHeight - viewportRef.clientHeight + if (reverse()) return Math.max(-max, Math.min(0, top)) + return Math.max(0, Math.min(max, top)) + } + + const glide = (top: number) => { + stop() + anim = animate(viewportRef.scrollTop, limit(top), { + ...FAST_SPRING, + onUpdate: (v) => { + viewportRef.scrollTop = v + }, + onComplete: () => { + anim = undefined + }, + }) + } + // Keybinds implementation // We ensure the viewport has a tabindex so it can receive focus // We can also explicitly catch PageUp/Down if we want smooth scroll or specific behavior, @@ -147,11 +180,11 @@ export function ScrollView(props: ScrollViewProps) { break case "Home": e.preventDefault() - viewportRef.scrollTo({ top: 0, behavior: "smooth" }) + glide(reverse() ? -(viewportRef.scrollHeight - viewportRef.clientHeight) : 0) break case "End": e.preventDefault() - viewportRef.scrollTo({ top: viewportRef.scrollHeight, behavior: "smooth" }) + glide(reverse() ? 0 : viewportRef.scrollHeight - viewportRef.clientHeight) break case "ArrowUp": e.preventDefault() @@ -166,7 +199,6 @@ export function ScrollView(props: ScrollViewProps) { return (
    setIsHovered(true)} @@ -177,16 +209,26 @@ export function ScrollView(props: ScrollViewProps) {
    { updateThumb() if (typeof events.onScroll === "function") events.onScroll(e as any) }} - onWheel={events.onWheel as any} - onTouchStart={events.onTouchStart as any} + onWheel={(e) => { + if (e.deltaY) stop() + if (typeof events.onWheel === "function") events.onWheel(e as any) + }} + onTouchStart={(e) => { + stop() + if (typeof events.onTouchStart === "function") events.onTouchStart(e as any) + }} onTouchMove={events.onTouchMove as any} onTouchEnd={events.onTouchEnd as any} onTouchCancel={events.onTouchCancel as any} - onPointerDown={events.onPointerDown as any} + onPointerDown={(e) => { + stop() + if (typeof events.onPointerDown === "function") events.onPointerDown(e as any) + }} onClick={events.onClick as any} tabIndex={0} role="region" diff --git a/packages/ui/src/components/session-review.tsx b/packages/ui/src/components/session-review.tsx index ad9e5b2c33..62c70e8647 100644 --- a/packages/ui/src/components/session-review.tsx +++ b/packages/ui/src/components/session-review.tsx @@ -145,7 +145,7 @@ export const SessionReview = (props: SessionReviewProps) => { const searchHandles = new Map() const readyFiles = new Set() const [store, setStore] = createStore<{ open: string[]; force: Record }>({ - open: props.diffs.length > 10 ? [] : props.diffs.map((d) => d.file), + open: [], force: {}, }) @@ -554,7 +554,9 @@ export const SessionReview = (props: SessionReviewProps) => { return (
    -
    {props.title ?? i18n.t("ui.sessionReview.title")}
    +
    + {props.title === undefined ? i18n.t("ui.sessionReview.title") : props.title} +
    [data-component="text-shimmer"] { + flex: 0 0 auto; + white-space: nowrap; + } + } + + [data-slot="session-turn-handoff-wrap"] { + width: 100%; + min-width: 0; + overflow: visible; + } + + [data-slot="session-turn-handoff"] { + width: 100%; + min-width: 0; + min-height: 37px; + position: relative; + } + + [data-slot="session-turn-thinking"] { + position: absolute; + inset: 0; + will-change: opacity, filter; + transition: + opacity 180ms ease-out, + filter 180ms ease-out, + transform 180ms ease-out; + } + + [data-slot="session-turn-thinking"][data-visible="false"] { + opacity: 0; + filter: blur(2px); + transform: translateY(1px); + pointer-events: none; + } + + [data-slot="session-turn-thinking"][data-visible="true"] { + opacity: 1; + filter: blur(0px); + transform: translateY(0px); + } + + [data-slot="session-turn-meta"] { + position: absolute; + inset: 0; + min-height: 37px; + display: flex; + align-items: center; + justify-content: flex-start; + gap: 10px; + opacity: 0; + pointer-events: none; + transition: opacity 0.15s ease; + } + + [data-slot="session-turn-meta"][data-interrupted] { + gap: 12px; + } + + [data-slot="session-turn-meta"] [data-component="tooltip-trigger"] { + display: inline-flex; + width: fit-content; + } + + [data-slot="session-turn-message-container"]:hover [data-slot="session-turn-meta"][data-visible="true"], + [data-slot="session-turn-message-container"]:focus-within [data-slot="session-turn-meta"][data-visible="true"] { + opacity: 1; + pointer-events: auto; + } + + [data-slot="session-turn-meta-label"] { + user-select: none; + min-width: 0; + overflow: clip; + white-space: nowrap; + text-overflow: ellipsis; } [data-component="text-reveal"].session-turn-thinking-heading { flex: 1 1 auto; min-width: 0; + overflow: clip; + white-space: nowrap; + line-height: inherit; color: var(--text-weaker); font-weight: var(--font-weight-regular); + + [data-slot="text-reveal-track"], + [data-slot="text-reveal-entering"], + [data-slot="text-reveal-leaving"] { + min-height: 0; + line-height: inherit; + } } .error-card { @@ -84,7 +180,7 @@ display: flex; flex-direction: column; align-self: stretch; - gap: 12px; + gap: 0px; > :first-child > [data-component="markdown"]:first-child { margin-top: 0; @@ -109,6 +205,7 @@ [data-component="session-turn-diffs-trigger"] { width: 100%; + height: 36px; display: flex; align-items: center; justify-content: flex-start; @@ -118,7 +215,7 @@ [data-slot="session-turn-diffs-title"] { display: inline-flex; - align-items: baseline; + align-items: center; gap: 8px; } @@ -133,9 +230,10 @@ [data-slot="session-turn-diffs-count"] { color: var(--text-base); font-family: var(--font-family-sans); + font-variant-numeric: tabular-nums; font-size: var(--font-size-base); font-weight: var(--font-weight-regular); - line-height: var(--line-height-x-large); + line-height: var(--line-height-large); } [data-slot="session-turn-diffs-meta"] { @@ -171,8 +269,10 @@ [data-slot="session-turn-diff-path"] { display: flex; - flex-grow: 1; min-width: 0; + align-items: baseline; + overflow: clip; + white-space: nowrap; font-family: var(--font-family-sans); font-size: var(--font-size-small); @@ -180,16 +280,22 @@ } [data-slot="session-turn-diff-directory"] { - color: var(--text-base); - overflow: hidden; - text-overflow: ellipsis; + flex: 1 1 auto; + color: var(--text-weak); + min-width: 0; + overflow: clip; white-space: nowrap; direction: rtl; + unicode-bidi: plaintext; text-align: left; } [data-slot="session-turn-diff-filename"] { flex-shrink: 0; + max-width: 100%; + min-width: 0; + overflow: clip; + white-space: nowrap; color: var(--text-strong); font-weight: var(--font-weight-medium); } diff --git a/packages/ui/src/components/session-turn.tsx b/packages/ui/src/components/session-turn.tsx index a8a41b8ef4..f1aee802ec 100644 --- a/packages/ui/src/components/session-turn.tsx +++ b/packages/ui/src/components/session-turn.tsx @@ -3,23 +3,27 @@ import type { SessionStatus } from "@opencode-ai/sdk/v2" import { useData } from "../context" import { useFileComponent } from "../context/file" +import { same } from "@opencode-ai/util/array" import { Binary } from "@opencode-ai/util/binary" import { getDirectory, getFilename } from "@opencode-ai/util/path" -import { createEffect, createMemo, createSignal, For, on, ParentProps, Show } from "solid-js" +import { createEffect, createMemo, createSignal, For, on, onCleanup, ParentProps, Show } from "solid-js" import { Dynamic } from "solid-js/web" -import { AssistantParts, Message, Part, PART_MAPPING } from "./message-part" +import { GrowBox } from "./grow-box" +import { AssistantParts, UserMessageDisplay, Part, PART_MAPPING } from "./message-part" import { Card } from "./card" import { Accordion } from "./accordion" import { StickyAccordionHeader } from "./sticky-accordion-header" import { Collapsible } from "./collapsible" import { DiffChanges } from "./diff-changes" import { Icon } from "./icon" +import { IconButton } from "./icon-button" import { TextShimmer } from "./text-shimmer" -import { SessionRetry } from "./session-retry" import { TextReveal } from "./text-reveal" +import { list } from "./text-utils" +import { SessionRetry } from "./session-retry" +import { Tooltip } from "./tooltip" import { createAutoScroll } from "../hooks" import { useI18n } from "../context/i18n" - function record(value: unknown): value is Record { return !!value && typeof value === "object" && !Array.isArray(value) } @@ -73,18 +77,12 @@ function unwrap(message: string) { return message } -function same(a: readonly T[], b: readonly T[]) { - if (a === b) return true - if (a.length !== b.length) return false - return a.every((x, i) => x === b[i]) -} - -function list(value: T[] | undefined | null, fallback: T[]) { - if (Array.isArray(value)) return value - return fallback -} - const hidden = new Set(["todowrite", "todoread"]) +const emptyMessages: MessageType[] = [] +const emptyAssistant: AssistantMessage[] = [] +const emptyDiffs: FileDiff[] = [] +const idle: SessionStatus = { type: "idle" as const } +const handoffHoldMs = 120 function partState(part: PartType, showReasoningSummaries: boolean) { if (part.type === "tool") { @@ -141,6 +139,7 @@ export function SessionTurn( props: ParentProps<{ sessionID: string messageID: string + animate?: boolean showReasoningSummaries?: boolean shellToolDefaultOpen?: boolean editToolDefaultOpen?: boolean @@ -159,11 +158,7 @@ export function SessionTurn( const i18n = useI18n() const fileComponent = useFileComponent() - const emptyMessages: MessageType[] = [] const emptyParts: PartType[] = [] - const emptyAssistant: AssistantMessage[] = [] - const emptyDiffs: FileDiff[] = [] - const idle = { type: "idle" as const } const allMessages = createMemo(() => list(data.store.message?.[props.sessionID], emptyMessages)) @@ -191,42 +186,8 @@ export function SessionTurn( return msg }) - const pending = createMemo(() => { - if (typeof props.active === "boolean" && typeof props.queued === "boolean") return - const messages = allMessages() ?? emptyMessages - return messages.findLast( - (item): item is AssistantMessage => item.role === "assistant" && typeof item.time.completed !== "number", - ) - }) - - const pendingUser = createMemo(() => { - const item = pending() - if (!item?.parentID) return - const messages = allMessages() ?? emptyMessages - const result = Binary.search(messages, item.parentID, (m) => m.id) - const msg = result.found ? messages[result.index] : messages.find((m) => m.id === item.parentID) - if (!msg || msg.role !== "user") return - return msg - }) - - const active = createMemo(() => { - if (typeof props.active === "boolean") return props.active - const msg = message() - const parent = pendingUser() - if (!msg || !parent) return false - return parent.id === msg.id - }) - - const queued = createMemo(() => { - if (typeof props.queued === "boolean") return props.queued - const id = message()?.id - if (!id) return false - if (!pendingUser()) return false - const item = pending() - if (!item) return false - return id > item.id - }) - + const active = createMemo(() => props.active ?? false) + const queued = createMemo(() => props.queued ?? false) const parts = createMemo(() => { const msg = message() if (!msg) return emptyParts @@ -289,7 +250,7 @@ export function SessionTurn( const error = createMemo( () => assistantMessages().find((m) => m.error && m.error.name !== "MessageAbortedError")?.error, ) - const showAssistantCopyPartID = createMemo(() => { + const assistantCopyPart = createMemo(() => { const messages = assistantMessages() for (let i = messages.length - 1; i >= 0; i--) { @@ -299,13 +260,18 @@ export function SessionTurn( const parts = list(data.store.part?.[message.id], emptyParts) for (let j = parts.length - 1; j >= 0; j--) { const part = parts[j] - if (!part || part.type !== "text" || !part.text?.trim()) continue - return part.id + if (!part || part.type !== "text") continue + const text = part.text?.trim() + if (!text) continue + return { + id: part.id, + text, + message, + } } } - - return undefined }) + const assistantCopyPartID = createMemo(() => assistantCopyPart()?.id ?? null) const errorText = createMemo(() => { const msg = error()?.data?.message if (typeof msg === "string") return unwrap(msg) @@ -313,18 +279,14 @@ export function SessionTurn( return unwrap(String(msg)) }) - const status = createMemo(() => { - if (props.status !== undefined) return props.status - if (typeof props.active === "boolean" && !props.active) return idle - return data.store.session_status[props.sessionID] ?? idle + const status = createMemo(() => data.store.session_status[props.sessionID] ?? idle) + const working = createMemo(() => { + if (status().type === "idle") return false + if (!message()) return false + return active() }) - const working = createMemo(() => status().type !== "idle" && active()) const showReasoningSummaries = createMemo(() => props.showReasoningSummaries ?? true) - - const assistantCopyPartID = createMemo(() => { - if (working()) return null - return showAssistantCopyPartID() ?? null - }) + const showDiffSummary = createMemo(() => edited() > 0 && !working()) const turnDurationMs = createMemo(() => { const start = message()?.time.created if (typeof start !== "number") return undefined @@ -364,13 +326,109 @@ export function SessionTurn( .filter((text): text is string => !!text) .at(-1), ) - const showThinking = createMemo(() => { + const thinking = createMemo(() => { if (!working() || !!error()) return false if (queued()) return false if (status().type === "retry") return false if (showReasoningSummaries()) return assistantVisible() === 0 return true }) + const hasAssistant = createMemo(() => assistantMessages().length > 0) + const animateEnabled = createMemo(() => props.animate !== false) + const [live, setLive] = createSignal(false) + const thinkingOpen = createMemo(() => thinking() && (live() || !animateEnabled())) + const metaOpen = createMemo(() => !working() && !!assistantCopyPart()) + const duration = createMemo(() => { + const ms = turnDurationMs() + if (typeof ms !== "number" || ms < 0) return "" + + const total = Math.round(ms / 1000) + if (total < 60) return `${total}s` + + const minutes = Math.floor(total / 60) + const seconds = total % 60 + return `${minutes}m ${seconds}s` + }) + const meta = createMemo(() => { + const item = assistantCopyPart() + if (!item) return "" + + const agent = item.message.agent ? item.message.agent[0]?.toUpperCase() + item.message.agent.slice(1) : "" + const model = item.message.modelID + ? (data.store.provider?.all?.find((provider) => provider.id === item.message.providerID)?.models?.[ + item.message.modelID + ]?.name ?? item.message.modelID) + : "" + return [agent, model, duration()].filter((value) => !!value).join("\u00A0\u00B7\u00A0") + }) + const [copied, setCopied] = createSignal(false) + const [handoffHold, setHandoffHold] = createSignal(false) + const thinkingVisible = createMemo(() => thinkingOpen() || handoffHold()) + const handoffOpen = createMemo(() => thinkingVisible() || metaOpen()) + const lane = createMemo(() => hasAssistant() || handoffOpen()) + + let liveFrame: number | undefined + let copiedTimer: ReturnType | undefined + let handoffTimer: ReturnType | undefined + + const copyAssistant = async () => { + const text = assistantCopyPart()?.text + if (!text) return + + await navigator.clipboard.writeText(text) + setCopied(true) + if (copiedTimer !== undefined) clearTimeout(copiedTimer) + copiedTimer = setTimeout(() => { + copiedTimer = undefined + setCopied(false) + }, 2000) + } + + createEffect( + on( + () => [animateEnabled(), working()] as const, + ([enabled, isWorking]) => { + if (liveFrame !== undefined) { + cancelAnimationFrame(liveFrame) + liveFrame = undefined + } + if (!enabled || !isWorking || live()) return + liveFrame = requestAnimationFrame(() => { + liveFrame = undefined + setLive(true) + }) + }, + ), + ) + + createEffect( + on( + () => [thinkingOpen(), metaOpen()] as const, + ([thinkingNow, metaNow]) => { + if (handoffTimer !== undefined) { + clearTimeout(handoffTimer) + handoffTimer = undefined + } + + if (thinkingNow) { + setHandoffHold(true) + return + } + + if (metaNow) { + setHandoffHold(false) + return + } + + if (!handoffHold()) return + handoffTimer = setTimeout(() => { + handoffTimer = undefined + setHandoffHold(false) + }, handoffHoldMs) + }, + { defer: true }, + ), + ) const autoScroll = createAutoScroll({ working, @@ -378,6 +436,119 @@ export function SessionTurn( overflowAnchor: "dynamic", }) + onCleanup(() => { + if (liveFrame !== undefined) cancelAnimationFrame(liveFrame) + if (copiedTimer !== undefined) clearTimeout(copiedTimer) + if (handoffTimer !== undefined) clearTimeout(handoffTimer) + }) + + const turnDiffSummary = () => ( +
    + + +
    +
    + {i18n.t("ui.sessionReview.change.modified")} + + {edited()} {i18n.t(edited() === 1 ? "ui.common.file.one" : "ui.common.file.other")} + +
    + + +
    +
    +
    +
    + + +
    + setExpanded(Array.isArray(value) ? value : value ? [value] : [])} + > + + {(diff) => { + const active = createMemo(() => expanded().includes(diff.file)) + const [visible, setVisible] = createSignal(false) + + createEffect( + on( + active, + (value) => { + if (!value) { + setVisible(false) + return + } + + requestAnimationFrame(() => { + if (!active()) return + setVisible(true) + }) + }, + { defer: true }, + ), + ) + + return ( + + + +
    + + + {`\u202A${getDirectory(diff.file)}\u202C`} + + {getFilename(diff.file)} + +
    + + + + + + +
    +
    +
    +
    + + +
    + +
    +
    +
    +
    + ) + }} +
    +
    +
    +
    +
    +
    +
    + ) + + const divider = (label: string) => ( +
    +
    + + + {label} + + +
    +
    + ) + return (
    - +
    {(part) => ( -
    - -
    + +
    + +
    +
    )}
    - 0}> -
    - -
    -
    - -
    - - - + +
    + - -
    -
    - - 0 && !working()}> -
    - - -
    -
    - - {i18n.t("ui.sessionReview.change.modified")} - - - {edited()} {i18n.t(edited() === 1 ? "ui.common.file.one" : "ui.common.file.other")} - -
    - - -
    -
    -
    -
    - - -
    - setExpanded(Array.isArray(value) ? value : value ? [value] : [])} +
    +
    + +
    +
    + + +
    + +
    + + event.preventDefault()} + onClick={() => void copyAssistant()} + aria-label={copied() ? i18n.t("ui.message.copied") : i18n.t("ui.message.copyResponse")} + /> + + + - - {(diff) => { - const active = createMemo(() => expanded().includes(diff.file)) - const [visible, setVisible] = createSignal(false) - - createEffect( - on( - active, - (value) => { - if (!value) { - setVisible(false) - return - } - - requestAnimationFrame(() => { - if (!active()) return - setVisible(true) - }) - }, - { defer: true }, - ), - ) - - return ( - - - -
    - - - - {`\u202A${getDirectory(diff.file)}\u202C`} - - - - {getFilename(diff.file)} - - -
    - - - - - - -
    -
    -
    -
    - - -
    - -
    -
    -
    -
    - ) - }} -
    - -
    -
    - - -
    - + {meta()} + + +
    +
    +
    + +
    + + {divider(i18n.t("ui.message.interrupted"))} + + + + {turnDiffSummary()} + {errorText()} diff --git a/packages/ui/src/components/shell-rolling-results.tsx b/packages/ui/src/components/shell-rolling-results.tsx new file mode 100644 index 0000000000..6a3b7b02cc --- /dev/null +++ b/packages/ui/src/components/shell-rolling-results.tsx @@ -0,0 +1,310 @@ +import { createEffect, createMemo, createSignal, onCleanup, onMount, Show } from "solid-js" +import stripAnsi from "strip-ansi" +import type { ToolPart } from "@opencode-ai/sdk/v2" +import { prefersReducedMotion } from "../hooks/use-reduced-motion" +import { useI18n } from "../context/i18n" +import { RollingResults } from "./rolling-results" +import { Icon } from "./icon" +import { IconButton } from "./icon-button" +import { TextShimmer } from "./text-shimmer" +import { Tooltip } from "./tooltip" +import { GROW_SPRING } from "./motion" +import { useSpring } from "./motion-spring" +import { + busy, + createThrottledValue, + hold, + updateScrollMask, + useCollapsible, + useRowWipe, + useToolFade, +} from "./tool-utils" + +function ShellRollingSubtitle(props: { text: string; animate?: boolean }) { + let ref: HTMLSpanElement | undefined + useToolFade(() => ref, { wipe: true, animate: props.animate }) + + return ( + + {props.text} + + ) +} + +function firstLine(text: string) { + return text + .split(/\r\n|\n|\r/g) + .map((item) => item.trim()) + .find((item) => item.length > 0) +} + +function shellRows(output: string) { + const rows: { id: string; text: string }[] = [] + const lines = output + .split(/\r\n|\n|\r/g) + .map((item) => item.trimEnd()) + .filter((item) => item.length > 0) + const start = Math.max(0, lines.length - 80) + for (let i = start; i < lines.length; i++) { + rows.push({ id: `line:${i}`, text: lines[i]! }) + } + + return rows +} + +function ShellRollingCommand(props: { text: string; animate?: boolean }) { + let ref: HTMLSpanElement | undefined + useToolFade(() => ref, { wipe: true, animate: props.animate }) + + return ( +
    + + $ {props.text} + +
    + ) +} + +function ShellExpanded(props: { cmd: string; out: string; open: boolean }) { + const i18n = useI18n() + const rows = 10 + const rowHeight = 22 + const max = rows * rowHeight + + let contentRef: HTMLDivElement | undefined + let bodyRef: HTMLDivElement | undefined + let scrollRef: HTMLDivElement | undefined + let topRef: HTMLDivElement | undefined + const [copied, setCopied] = createSignal(false) + const [cap, setCap] = createSignal(max) + + const updateMask = () => { + if (scrollRef) updateScrollMask(scrollRef) + } + + const resize = () => { + const top = Math.ceil(topRef?.getBoundingClientRect().height ?? 0) + setCap(Math.max(rowHeight * 2, max - top - (props.out ? 1 : 0))) + } + + const measure = () => { + resize() + return Math.ceil(bodyRef?.getBoundingClientRect().height ?? 0) + } + + onMount(() => { + resize() + if (!topRef) return + const obs = new ResizeObserver(resize) + obs.observe(topRef) + onCleanup(() => obs.disconnect()) + }) + + createEffect(() => { + props.cmd + props.out + queueMicrotask(() => { + resize() + updateMask() + }) + }) + + useCollapsible({ + content: () => contentRef, + body: () => bodyRef, + open: () => props.open, + measure, + onOpen: updateMask, + }) + + const handleCopy = async (e: MouseEvent) => { + e.stopPropagation() + const cmd = props.cmd ? `$ ${props.cmd}` : "" + const text = `${cmd}${props.out ? `${cmd ? "\n\n" : ""}${props.out}` : ""}` + if (!text) return + await navigator.clipboard.writeText(text) + setCopied(true) + setTimeout(() => setCopied(false), 2000) + } + + return ( +
    +
    +
    +
    +
    + $ + {props.cmd} +
    +
    + + e.preventDefault()} + onClick={handleCopy} + aria-label={copied() ? i18n.t("ui.message.copied") : i18n.t("ui.message.copy")} + /> + +
    +
    + + <> +
    +
    +
    +                  {props.out}
    +                
    +
    + + +
    +
    +
    + ) +} + +export function ShellRollingResults(props: { part: ToolPart; animate?: boolean }) { + const i18n = useI18n() + const wiped = new Set() + const [mounted, setMounted] = createSignal(false) + const [userToggled, setUserToggled] = createSignal(false) + const [userOpen, setUserOpen] = createSignal(false) + onMount(() => setMounted(true)) + const state = createMemo(() => props.part.state as Record) + const pending = createMemo(() => busy(props.part.state.status)) + const autoOpen = hold(pending, 2000) + const effectiveOpen = createMemo(() => { + if (pending()) return true + if (userToggled()) return userOpen() + return autoOpen() + }) + const expanded = createMemo(() => !pending() && !autoOpen() && userToggled() && userOpen()) + const previewOpen = createMemo(() => effectiveOpen() && !expanded()) + const command = createMemo(() => { + const value = state().input?.command ?? state().metadata?.command + if (typeof value === "string") return value + return "" + }) + const subtitle = createMemo(() => { + const value = state().input?.description ?? state().metadata?.description + if (typeof value === "string" && value.trim().length > 0) return value + return firstLine(command()) ?? "" + }) + const output = createMemo(() => { + const value = state().output ?? state().metadata?.output + if (typeof value === "string") return value + return "" + }) + const reduce = prefersReducedMotion + const skip = () => reduce() || props.animate === false + const opacity = useSpring(() => (mounted() ? 1 : 0), GROW_SPRING) + const blur = useSpring(() => (mounted() ? 0 : 2), GROW_SPRING) + const previewOpacity = useSpring(() => (previewOpen() ? 1 : 0), GROW_SPRING) + const previewBlur = useSpring(() => (previewOpen() ? 0 : 2), GROW_SPRING) + const headerHeight = useSpring(() => (mounted() ? 37 : 0), GROW_SPRING) + let headerClipRef: HTMLDivElement | undefined + const handleHeaderClick = () => { + if (pending()) return + const el = headerClipRef + const viewport = el?.closest(".scroll-view__viewport") as HTMLElement | null + const beforeY = el?.getBoundingClientRect().top ?? 0 + setUserToggled(true) + setUserOpen((prev) => !prev) + if (viewport && el) { + requestAnimationFrame(() => { + const afterY = el.getBoundingClientRect().top + const delta = afterY - beforeY + if (delta !== 0) viewport.scrollTop += delta + }) + } + } + const line = createMemo(() => firstLine(command())) + const fixed = createMemo(() => { + const value = line() + if (!value) return + return + }) + const text = createThrottledValue(() => stripAnsi(output())) + const rows = createMemo(() => shellRows(text())) + + return ( +
    +
    +
    + + + + {(text) => } + + + + + + + +
    +
    +
    + row.id} + render={(row) => { + const [textRef, setTextRef] = createSignal() + useRowWipe({ + id: () => row.id, + text: () => row.text, + ref: textRef, + seen: wiped, + }) + return ( +
    + + {row.text} + +
    + ) + }} + /> +
    + +
    + ) +} diff --git a/packages/ui/src/components/shell-submessage.css b/packages/ui/src/components/shell-submessage.css index f72ba3fc75..9f19c2d152 100644 --- a/packages/ui/src/components/shell-submessage.css +++ b/packages/ui/src/components/shell-submessage.css @@ -1,23 +1,13 @@ [data-component="shell-submessage"] { min-width: 0; max-width: 100%; - display: inline-flex; - align-items: baseline; + display: inline-block; vertical-align: baseline; } -[data-component="shell-submessage"] [data-slot="shell-submessage-width"] { - min-width: 0; - max-width: 100%; - display: inline-flex; - align-items: baseline; - overflow: hidden; -} - [data-component="shell-submessage"] [data-slot="shell-submessage-value"] { display: inline-block; vertical-align: baseline; min-width: 0; - line-height: inherit; white-space: nowrap; } diff --git a/packages/ui/src/components/tabs.css b/packages/ui/src/components/tabs.css index 51917489e2..036533c10f 100644 --- a/packages/ui/src/components/tabs.css +++ b/packages/ui/src/components/tabs.css @@ -146,7 +146,7 @@ --tabs-review-fade: 16px; gap: var(--tabs-review-gap); background-color: var(--background-stronger); - border-bottom: 1px solid var(--border-weak-base); + border-bottom: 1px solid var(--border-weaker-base); &::after { display: none; @@ -407,11 +407,7 @@ align-items: center; background-color: var(--background-stronger); box-sizing: border-box; - border-bottom: 1px solid transparent; - - &[data-scrolled] { - border-bottom-color: var(--border-weak-base); - } + border-bottom: 1px solid var(--border-weak-base); } [data-slot="tabs-trigger-wrapper"] { diff --git a/packages/ui/src/components/text-reveal.css b/packages/ui/src/components/text-reveal.css index f799962f09..7939322e6d 100644 --- a/packages/ui/src/components/text-reveal.css +++ b/packages/ui/src/components/text-reveal.css @@ -4,14 +4,14 @@ * Instead of sliding text through a fixed mask (odometer style), * the mask itself sweeps across each span to reveal/hide text. * - * Direction: top-to-bottom. New text drops in from above, old text exits downward. + * Direction: bottom-to-top. New text rises in from below, old text exits upward. * - * Entering: gradient reveals top-to-bottom (top of text appears first). + * Entering: gradient reveals bottom-to-top (bottom of text appears first). * gradient(to bottom, white 33%, transparent 33%+edge) * pos 0 100% = transparent covers element = hidden * pos 0 0% = white covers element = visible * - * Leaving: gradient hides top-to-bottom (top of text disappears first). + * Leaving: gradient hides bottom-to-top (bottom of text disappears first). * gradient(to top, white 33%, transparent 33%+edge) * pos 0 100% = white covers element = visible * pos 0 0% = transparent covers element = hidden @@ -56,17 +56,17 @@ transition-timing-function: var(--_spring); } - /* ── entering: reveal top-to-bottom ── - * Gradient(to top): white at bottom, transparent at top of mask. - * Settled pos 0 100% = white covers element = visible - * Swap pos 0 0% = transparent covers = hidden - * Slides from above: translateY(-travel) → translateY(0) + /* ── entering: reveal bottom-to-top ── + * Gradient(to bottom): white at top, transparent at bottom of mask. + * Settled pos 0 0% = white covers element = visible + * Swap pos 0 100% = transparent covers = hidden + * Rises from below: translateY(travel) → translateY(0) */ [data-slot="text-reveal-entering"] { - mask-image: linear-gradient(to top, white 33%, transparent calc(33% + var(--_edge))); - -webkit-mask-image: linear-gradient(to top, white 33%, transparent calc(33% + var(--_edge))); - mask-position: 0 100%; - -webkit-mask-position: 0 100%; + mask-image: linear-gradient(to bottom, white 33%, transparent calc(33% + var(--_edge))); + -webkit-mask-image: linear-gradient(to bottom, white 33%, transparent calc(33% + var(--_edge))); + mask-position: 0 0%; + -webkit-mask-position: 0 0%; transition-property: mask-position, -webkit-mask-position, @@ -74,37 +74,37 @@ transform: translateY(0); } - /* ── leaving: hide top-to-bottom + slide downward ── - * Gradient(to bottom): white at top, transparent at bottom of mask. - * Swap pos 0 0% = white covers element = visible - * Settled pos 0 100% = transparent covers = hidden - * Slides down: translateY(0) → translateY(travel) + /* ── leaving: hide bottom-to-top + slide upward ── + * Gradient(to top): white at bottom, transparent at top of mask. + * Swap pos 0 100% = white covers element = visible + * Settled pos 0 0% = transparent covers = hidden + * Slides up: translateY(0) → translateY(-travel) */ [data-slot="text-reveal-leaving"] { - mask-image: linear-gradient(to bottom, white 33%, transparent calc(33% + var(--_edge))); - -webkit-mask-image: linear-gradient(to bottom, white 33%, transparent calc(33% + var(--_edge))); - mask-position: 0 100%; - -webkit-mask-position: 0 100%; + mask-image: linear-gradient(to top, white 33%, transparent calc(33% + var(--_edge))); + -webkit-mask-image: linear-gradient(to top, white 33%, transparent calc(33% + var(--_edge))); + mask-position: 0 0%; + -webkit-mask-position: 0 0%; transition-property: mask-position, -webkit-mask-position, transform; - transform: translateY(var(--_travel)); + transform: translateY(calc(var(--_travel) * -1)); } /* ── swapping: instant reset ── - * Snap entering to hidden (above), leaving to visible (center). + * Snap entering to hidden (below), leaving to visible (center). */ &[data-swapping="true"] [data-slot="text-reveal-entering"] { - mask-position: 0 0%; - -webkit-mask-position: 0 0%; - transform: translateY(calc(var(--_travel) * -1)); + mask-position: 0 100%; + -webkit-mask-position: 0 100%; + transform: translateY(var(--_travel)); transition-duration: 0ms !important; } &[data-swapping="true"] [data-slot="text-reveal-leaving"] { - mask-position: 0 0%; - -webkit-mask-position: 0 0%; + mask-position: 0 100%; + -webkit-mask-position: 0 100%; transform: translateY(0); transition-duration: 0ms !important; } @@ -126,15 +126,14 @@ &[data-truncate="true"] [data-slot="text-reveal-track"] { width: 100%; min-width: 0; - overflow: hidden; + overflow: clip; } &[data-truncate="true"] [data-slot="text-reveal-entering"], &[data-truncate="true"] [data-slot="text-reveal-leaving"] { min-width: 0; width: 100%; - overflow: hidden; - text-overflow: ellipsis; + overflow: clip; } } diff --git a/packages/ui/src/components/text-reveal.tsx b/packages/ui/src/components/text-reveal.tsx index c4fe1302f0..7ddf4a50b8 100644 --- a/packages/ui/src/components/text-reveal.tsx +++ b/packages/ui/src/components/text-reveal.tsx @@ -1,4 +1,13 @@ import { createEffect, createSignal, on, onCleanup, onMount } from "solid-js" +import { + animate, + type AnimationPlaybackControls, + clearFadeStyles, + clearMaskStyles, + GROW_SPRING, + WIPE_MASK, +} from "./motion" +import { prefersReducedMotion } from "../hooks/use-reduced-motion" const px = (value: number | string | undefined, fallback: number) => { if (typeof value === "number") return `${value}px` @@ -17,6 +26,11 @@ const pct = (value: number | undefined, fallback: number) => { return `${v}%` } +const clearWipe = (el: HTMLElement) => { + clearFadeStyles(el) + clearMaskStyles(el) +} + export function TextReveal(props: { text?: string class?: string @@ -39,10 +53,8 @@ export function TextReveal(props: { let outRef: HTMLSpanElement | undefined let rootRef: HTMLSpanElement | undefined let frame: number | undefined - const win = () => inRef?.scrollWidth ?? 0 const wout = () => outRef?.scrollWidth ?? 0 - const widen = (next: number) => { if (next <= 0) return if (props.growOnly ?? true) { @@ -51,21 +63,14 @@ export function TextReveal(props: { } setWidth(`${next}px`) } - createEffect( on( () => props.text, (next, prev) => { if (next === prev) return - if (typeof next === "string" && typeof prev === "string" && next.startsWith(prev)) { - setCur(next) - widen(win()) - return - } setSwapping(true) setOld(prev) setCur(next) - if (typeof requestAnimationFrame !== "function") { widen(Math.max(win(), wout())) rootRef?.offsetHeight @@ -133,3 +138,94 @@ export function TextReveal(props: { ) } + +export function TextWipe(props: { text?: string; class?: string; delay?: number; animate?: boolean }) { + let ref: HTMLSpanElement | undefined + let frame: number | undefined + let anim: AnimationPlaybackControls | undefined + + const run = () => { + if (props.animate === false) return + const el = ref + if (!el || !props.text || typeof window === "undefined") return + if (prefersReducedMotion()) return + + const mask = + typeof CSS !== "undefined" && + (CSS.supports("mask-image", "linear-gradient(to right, black, transparent)") || + CSS.supports("-webkit-mask-image", "linear-gradient(to right, black, transparent)")) + + anim?.stop() + if (frame !== undefined && typeof cancelAnimationFrame === "function") { + cancelAnimationFrame(frame) + frame = undefined + } + + el.style.opacity = "0" + el.style.filter = "blur(3px)" + el.style.transform = "translateX(-0.06em)" + + if (mask) { + el.style.maskImage = WIPE_MASK + el.style.webkitMaskImage = WIPE_MASK + el.style.maskSize = "240% 100%" + el.style.webkitMaskSize = "240% 100%" + el.style.maskRepeat = "no-repeat" + el.style.webkitMaskRepeat = "no-repeat" + el.style.maskPosition = "100% 0%" + el.style.webkitMaskPosition = "100% 0%" + } + + if (typeof requestAnimationFrame !== "function") { + clearWipe(el) + return + } + + frame = requestAnimationFrame(() => { + frame = undefined + const node = ref + if (!node) return + anim = mask + ? animate( + node, + { opacity: 1, filter: "blur(0px)", transform: "translateX(0)", maskPosition: "0% 0%" }, + { ...GROW_SPRING, delay: props.delay ?? 0 }, + ) + : animate( + node, + { opacity: 1, filter: "blur(0px)", transform: "translateX(0)" }, + { ...GROW_SPRING, delay: props.delay ?? 0 }, + ) + + anim?.finished.then(() => { + const value = ref + if (!value) return + clearWipe(value) + }) + }) + } + + createEffect( + on( + () => [props.text, props.animate] as const, + ([text, enabled]) => { + if (!text || enabled === false) { + if (ref) clearWipe(ref) + return + } + run() + }, + ), + ) + + onCleanup(() => { + if (frame !== undefined && typeof cancelAnimationFrame === "function") cancelAnimationFrame(frame) + anim?.stop() + }) + + return ( + + {props.text ?? "\u00A0"} + + ) +} diff --git a/packages/ui/src/components/text-shimmer.css b/packages/ui/src/components/text-shimmer.css index f042dd2d86..bd1437c273 100644 --- a/packages/ui/src/components/text-shimmer.css +++ b/packages/ui/src/components/text-shimmer.css @@ -1,11 +1,11 @@ [data-component="text-shimmer"] { --text-shimmer-step: 45ms; - --text-shimmer-duration: 1200ms; + --text-shimmer-duration: 2000ms; --text-shimmer-swap: 220ms; --text-shimmer-index: 0; --text-shimmer-angle: 90deg; --text-shimmer-spread: 5.2ch; - --text-shimmer-size: 360%; + --text-shimmer-size: 600%; --text-shimmer-base-color: var(--text-weak); --text-shimmer-peak-color: var(--text-strong); --text-shimmer-sweep: linear-gradient( @@ -16,15 +16,17 @@ ); --text-shimmer-base: linear-gradient(var(--text-shimmer-base-color), var(--text-shimmer-base-color)); - display: inline-flex; - align-items: baseline; + display: inline-block; + vertical-align: baseline; font: inherit; letter-spacing: inherit; line-height: inherit; } [data-component="text-shimmer"] [data-slot="text-shimmer-char"] { - display: inline-grid; + display: inline-block; + position: relative; + vertical-align: baseline; white-space: pre; font: inherit; letter-spacing: inherit; @@ -33,7 +35,7 @@ [data-component="text-shimmer"] [data-slot="text-shimmer-char-base"], [data-component="text-shimmer"] [data-slot="text-shimmer-char-shimmer"] { - grid-area: 1 / 1; + display: inline-block; white-space: pre; transition: opacity var(--text-shimmer-swap) ease-out; font: inherit; @@ -42,11 +44,14 @@ } [data-component="text-shimmer"] [data-slot="text-shimmer-char-base"] { + position: relative; color: inherit; opacity: 1; } [data-component="text-shimmer"] [data-slot="text-shimmer-char-shimmer"] { + position: absolute; + inset: 0; color: var(--text-weaker); opacity: 0; } diff --git a/packages/ui/src/components/text-shimmer.tsx b/packages/ui/src/components/text-shimmer.tsx index c4c20b8e76..0d797e5c1f 100644 --- a/packages/ui/src/components/text-shimmer.tsx +++ b/packages/ui/src/components/text-shimmer.tsx @@ -8,6 +8,7 @@ export const TextShimmer = (props: { active?: boolean offset?: number }) => { + const text = createMemo(() => props.text ?? "") const active = createMemo(() => props.active ?? true) const offset = createMemo(() => props.offset ?? 0) const [run, setRun] = createSignal(active()) @@ -36,24 +37,36 @@ export const TextShimmer = (props: { clearTimeout(timer) }) + const len = createMemo(() => Math.max(text().length, 1)) + const shimmerSize = createMemo(() => Math.max(300, Math.round(200 + 1400 / len()))) + + // duration = len × (size - 1) / velocity → uniform perceived sweep speed + const VELOCITY = 0.01375 // ch per ms, ~10% faster than original 0.0125 baseline + const shimmerDuration = createMemo(() => { + const s = shimmerSize() / 100 + return Math.max(1000, Math.min(2500, Math.round((len() * (s - 1)) / VELOCITY))) + }) + return ( diff --git a/packages/ui/src/components/text-utils.ts b/packages/ui/src/components/text-utils.ts new file mode 100644 index 0000000000..c094b5e65f --- /dev/null +++ b/packages/ui/src/components/text-utils.ts @@ -0,0 +1,17 @@ +/** Find the longest common character prefix between two strings. */ +export function commonPrefix(a: string, b: string) { + const ac = Array.from(a) + const bc = Array.from(b) + let i = 0 + while (i < ac.length && i < bc.length && ac[i] === bc[i]) i++ + return { + prefix: ac.slice(0, i).join(""), + aSuffix: ac.slice(i).join(""), + bSuffix: bc.slice(i).join(""), + } +} + +export function list(value: T[] | undefined | null, fallback: T[]): T[] { + if (Array.isArray(value)) return value + return fallback +} diff --git a/packages/ui/src/components/tool-count-label.css b/packages/ui/src/components/tool-count-label.css index 11a33ff5d1..4ed46e50b5 100644 --- a/packages/ui/src/components/tool-count-label.css +++ b/packages/ui/src/components/tool-count-label.css @@ -27,10 +27,10 @@ grid-template-columns: 0fr; opacity: 0; filter: blur(calc(var(--tool-motion-blur, 2px) * 0.42)); - overflow: hidden; + overflow: clip; transform: translateX(-0.04em); transition-property: grid-template-columns, opacity, filter, transform; - transition-duration: 250ms, 250ms, 250ms, 250ms; + transition-duration: 800ms, 400ms, 400ms, 800ms; transition-timing-function: var(--tool-motion-ease, cubic-bezier(0.22, 1, 0.36, 1)), ease-out, ease-out, var(--tool-motion-ease, cubic-bezier(0.22, 1, 0.36, 1)); @@ -45,7 +45,7 @@ [data-slot="tool-count-label-suffix-inner"] { min-width: 0; - overflow: hidden; + overflow: clip; white-space: pre; } } diff --git a/packages/ui/src/components/tool-count-label.tsx b/packages/ui/src/components/tool-count-label.tsx index 67e861cdcb..c374d2d376 100644 --- a/packages/ui/src/components/tool-count-label.tsx +++ b/packages/ui/src/components/tool-count-label.tsx @@ -1,5 +1,6 @@ import { createMemo } from "solid-js" import { AnimatedNumber } from "./animated-number" +import { commonPrefix } from "./text-utils" function split(text: string) { const match = /{{\s*count\s*}}/.exec(text) @@ -11,35 +12,23 @@ function split(text: string) { } } -function common(one: string, other: string) { - const a = Array.from(one) - const b = Array.from(other) - let i = 0 - while (i < a.length && i < b.length && a[i] === b[i]) i++ - return { - stem: a.slice(0, i).join(""), - one: a.slice(i).join(""), - other: b.slice(i).join(""), - } -} - export function AnimatedCountLabel(props: { count: number; one: string; other: string; class?: string }) { const one = createMemo(() => split(props.one)) const other = createMemo(() => split(props.other)) const singular = createMemo(() => Math.round(props.count) === 1) const active = createMemo(() => (singular() ? one() : other())) - const suffix = createMemo(() => common(one().after, other().after)) + const suffix = createMemo(() => commonPrefix(one().after, other().after)) const splitSuffix = createMemo( () => one().before === other().before && (one().after.startsWith(other().after) || other().after.startsWith(one().after)), ) const before = createMemo(() => (splitSuffix() ? one().before : active().before)) - const stem = createMemo(() => (splitSuffix() ? suffix().stem : active().after)) + const stem = createMemo(() => (splitSuffix() ? suffix().prefix : active().after)) const tail = createMemo(() => { if (!splitSuffix()) return "" - if (singular()) return suffix().one - return suffix().other + if (singular()) return suffix().aSuffix + return suffix().bSuffix }) const showTail = createMemo(() => splitSuffix() && tail().length > 0) diff --git a/packages/ui/src/components/tool-count-summary.css b/packages/ui/src/components/tool-count-summary.css index da8455267c..435ed95fe6 100644 --- a/packages/ui/src/components/tool-count-summary.css +++ b/packages/ui/src/components/tool-count-summary.css @@ -10,12 +10,12 @@ opacity: 1; filter: blur(0); transform: translateY(0) scale(1); - overflow: hidden; + overflow: clip; transform-origin: left center; transition-property: grid-template-columns, opacity, filter, transform; transition-duration: - var(--tool-motion-spring-ms, 480ms), var(--tool-motion-fade-ms, 240ms), var(--tool-motion-fade-ms, 280ms), - var(--tool-motion-spring-ms, 480ms); + var(--tool-motion-spring-ms, 800ms), var(--tool-motion-fade-ms, 400ms), var(--tool-motion-fade-ms, 400ms), + var(--tool-motion-spring-ms, 800ms); transition-timing-function: var(--tool-motion-ease, cubic-bezier(0.22, 1, 0.36, 1)), ease-out, ease-out, var(--tool-motion-ease, cubic-bezier(0.22, 1, 0.36, 1)); @@ -35,12 +35,12 @@ opacity: 0; filter: blur(var(--tool-motion-blur, 2px)); transform: translateY(0.06em) scale(0.985); - overflow: hidden; + overflow: clip; transform-origin: left center; transition-property: grid-template-columns, opacity, filter, transform; transition-duration: - var(--tool-motion-spring-ms, 480ms), var(--tool-motion-fade-ms, 280ms), var(--tool-motion-fade-ms, 320ms), - var(--tool-motion-spring-ms, 480ms); + var(--tool-motion-spring-ms, 800ms), var(--tool-motion-fade-ms, 400ms), var(--tool-motion-fade-ms, 400ms), + var(--tool-motion-spring-ms, 800ms); transition-timing-function: var(--tool-motion-ease, cubic-bezier(0.22, 1, 0.36, 1)), ease-out, ease-out, var(--tool-motion-ease, cubic-bezier(0.22, 1, 0.36, 1)); @@ -55,7 +55,7 @@ [data-slot="tool-count-summary-empty-inner"] { min-width: 0; - overflow: hidden; + overflow: clip; white-space: nowrap; } @@ -63,7 +63,7 @@ display: inline-flex; align-items: baseline; min-width: 0; - overflow: hidden; + overflow: clip; white-space: nowrap; } @@ -75,12 +75,11 @@ margin-right: 0; opacity: 0; filter: blur(calc(var(--tool-motion-blur, 2px) * 0.55)); - overflow: hidden; + overflow: clip; transform: translateX(-0.08em); transition-property: opacity, filter, transform; transition-duration: - calc(var(--tool-motion-fade-ms, 200ms) * 0.75), calc(var(--tool-motion-fade-ms, 220ms) * 0.75), - calc(var(--tool-motion-fade-ms, 220ms) * 0.6); + var(--tool-motion-fade-ms, 400ms), var(--tool-motion-fade-ms, 400ms), var(--tool-motion-fade-ms, 400ms); transition-timing-function: ease-out, ease-out, ease-out; } diff --git a/packages/ui/src/components/tool-status-title.css b/packages/ui/src/components/tool-status-title.css index d4415bd2da..050f5e390a 100644 --- a/packages/ui/src/components/tool-status-title.css +++ b/packages/ui/src/components/tool-status-title.css @@ -18,9 +18,8 @@ [data-slot="tool-status-swap"], [data-slot="tool-status-tail"] { display: inline-grid; - overflow: hidden; + overflow: clip; justify-items: start; - transition: width var(--tool-motion-spring-ms, 480ms) var(--tool-motion-ease, cubic-bezier(0.22, 1, 0.36, 1)); } [data-slot="tool-status-active"], @@ -31,8 +30,8 @@ text-align: start; transition-property: opacity, filter, transform; transition-duration: - var(--tool-motion-fade-ms, 240ms), calc(var(--tool-motion-fade-ms, 240ms) * 0.8), - calc(var(--tool-motion-fade-ms, 240ms) * 0.8); + var(--tool-motion-fade-ms, 400ms), calc(var(--tool-motion-fade-ms, 400ms) * 0.8), + calc(var(--tool-motion-fade-ms, 400ms) * 0.8); transition-timing-function: ease-out, ease-out, ease-out; } diff --git a/packages/ui/src/components/tool-status-title.tsx b/packages/ui/src/components/tool-status-title.tsx index 68440b6c63..0669f8cf26 100644 --- a/packages/ui/src/components/tool-status-title.tsx +++ b/packages/ui/src/components/tool-status-title.tsx @@ -1,17 +1,8 @@ import { Show, createEffect, createMemo, createSignal, on, onCleanup, onMount } from "solid-js" +import { animate, type AnimationPlaybackControls, GROW_SPRING } from "./motion" import { TextShimmer } from "./text-shimmer" - -function common(active: string, done: string) { - const a = Array.from(active) - const b = Array.from(done) - let i = 0 - while (i < a.length && i < b.length && a[i] === b[i]) i++ - return { - prefix: a.slice(0, i).join(""), - active: a.slice(i).join(""), - done: b.slice(i).join(""), - } -} +import { commonPrefix } from "./text-utils" +import { prefersReducedMotion } from "../hooks/use-reduced-motion" function contentWidth(el: HTMLSpanElement | undefined) { if (!el) return 0 @@ -27,25 +18,59 @@ export function ToolStatusTitle(props: { class?: string split?: boolean }) { - const split = createMemo(() => common(props.activeText, props.doneText)) + const split = createMemo(() => commonPrefix(props.activeText, props.doneText)) const suffix = createMemo( - () => (props.split ?? true) && split().prefix.length >= 2 && split().active.length > 0 && split().done.length > 0, + () => + (props.split ?? true) && split().prefix.length >= 2 && split().aSuffix.length > 0 && split().bSuffix.length > 0, ) const prefixLen = createMemo(() => Array.from(split().prefix).length) - const activeTail = createMemo(() => (suffix() ? split().active : props.activeText)) - const doneTail = createMemo(() => (suffix() ? split().done : props.doneText)) + const activeTail = createMemo(() => (suffix() ? split().aSuffix : props.activeText)) + const doneTail = createMemo(() => (suffix() ? split().bSuffix : props.doneText)) - const [width, setWidth] = createSignal("auto") const [ready, setReady] = createSignal(false) let activeRef: HTMLSpanElement | undefined let doneRef: HTMLSpanElement | undefined + let swapRef: HTMLSpanElement | undefined + let tailRef: HTMLSpanElement | undefined let frame: number | undefined let readyFrame: number | undefined + let widthAnim: AnimationPlaybackControls | undefined + + const node = () => (suffix() ? tailRef : swapRef) + + const reduce = prefersReducedMotion + + const setNodeWidth = (width: string) => { + if (swapRef) swapRef.style.width = width + if (tailRef) tailRef.style.width = width + } const measure = () => { const target = props.active ? activeRef : doneRef - const px = contentWidth(target) - if (px > 0) setWidth(`${px}px`) + const next = contentWidth(target) + if (next <= 0) return + + const ref = node() + if (!ref || !ready() || reduce()) { + widthAnim?.stop() + setNodeWidth(`${next}px`) + return + } + + const prev = Math.max(0, Math.ceil(ref.getBoundingClientRect().width)) + if (Math.abs(next - prev) < 1) { + ref.style.width = `${next}px` + return + } + + ref.style.width = `${prev}px` + widthAnim?.stop() + widthAnim = animate(ref, { width: `${next}px` }, GROW_SPRING) + widthAnim.finished.then(() => { + const el = node() + if (!el) return + el.style.width = `${next}px` + }) } const schedule = () => { @@ -90,6 +115,7 @@ export function ToolStatusTitle(props: { onCleanup(() => { if (frame !== undefined) cancelAnimationFrame(frame) if (readyFrame !== undefined) cancelAnimationFrame(readyFrame) + widthAnim?.stop() }) return ( @@ -104,7 +130,7 @@ export function ToolStatusTitle(props: { + @@ -118,7 +144,7 @@ export function ToolStatusTitle(props: { - + diff --git a/packages/ui/src/components/tool-utils.ts b/packages/ui/src/components/tool-utils.ts new file mode 100644 index 0000000000..171649e3dc --- /dev/null +++ b/packages/ui/src/components/tool-utils.ts @@ -0,0 +1,325 @@ +import { createEffect, createMemo, createSignal, on, onCleanup, onMount } from "solid-js" +import { + animate, + type AnimationPlaybackControls, + clearFadeStyles, + clearMaskStyles, + COLLAPSIBLE_SPRING, + GROW_SPRING, + WIPE_MASK, +} from "./motion" +import { prefersReducedMotion } from "../hooks/use-reduced-motion" +import type { ToolPart } from "@opencode-ai/sdk/v2" + +export const TEXT_RENDER_THROTTLE_MS = 100 + +export function createThrottledValue(getValue: () => string) { + const [value, setValue] = createSignal(getValue()) + let timeout: ReturnType | undefined + let last = 0 + + createEffect(() => { + const next = getValue() + const now = Date.now() + + const remaining = TEXT_RENDER_THROTTLE_MS - (now - last) + if (remaining <= 0) { + if (timeout) { + clearTimeout(timeout) + timeout = undefined + } + last = now + setValue(next) + return + } + if (timeout) clearTimeout(timeout) + timeout = setTimeout(() => { + last = Date.now() + setValue(next) + timeout = undefined + }, remaining) + }) + + onCleanup(() => { + if (timeout) clearTimeout(timeout) + }) + + return value +} + +export function busy(status: string | undefined) { + return status === "pending" || status === "running" +} + +export function hold(state: () => boolean, wait = 2000) { + const [live, setLive] = createSignal(state()) + let timer: ReturnType | undefined + + createEffect(() => { + if (state()) { + if (timer) clearTimeout(timer) + timer = undefined + setLive(true) + return + } + + if (timer) clearTimeout(timer) + timer = setTimeout(() => { + timer = undefined + setLive(false) + }, wait) + }) + + onCleanup(() => { + if (timer) clearTimeout(timer) + }) + + return live +} + +export function updateScrollMask(el: HTMLElement, fade = 12) { + const { scrollTop, scrollHeight, clientHeight } = el + const overflow = scrollHeight - clientHeight + if (overflow <= 1) { + el.style.maskImage = "" + el.style.webkitMaskImage = "" + return + } + const top = scrollTop > 1 + const bottom = scrollTop < overflow - 1 + const mask = + top && bottom + ? `linear-gradient(to bottom, transparent 0, black ${fade}px, black calc(100% - ${fade}px), transparent 100%)` + : top + ? `linear-gradient(to bottom, transparent 0, black ${fade}px)` + : bottom + ? `linear-gradient(to bottom, black calc(100% - ${fade}px), transparent 100%)` + : "" + el.style.maskImage = mask + el.style.webkitMaskImage = mask +} + +export function useCollapsible(options: { + content: () => HTMLElement | undefined + body: () => HTMLElement | undefined + open: () => boolean + measure?: () => number + onOpen?: () => void +}) { + let heightAnim: AnimationPlaybackControls | undefined + let fadeAnim: AnimationPlaybackControls | undefined + let gen = 0 + + createEffect( + on( + options.open, + (isOpen) => { + const content = options.content() + const body = options.body() + if (!content || !body) return + heightAnim?.stop() + fadeAnim?.stop() + const id = ++gen + if (isOpen) { + content.style.display = "" + content.style.height = "0px" + body.style.opacity = "0" + body.style.filter = "blur(2px)" + fadeAnim = animate(body, { opacity: [0, 1], filter: ["blur(2px)", "blur(0px)"] }, COLLAPSIBLE_SPRING) + queueMicrotask(() => { + if (gen !== id) return + const c = options.content() + if (!c) return + const h = options.measure?.() ?? Math.ceil(body.getBoundingClientRect().height) + heightAnim = animate(c, { height: ["0px", `${h}px`] }, COLLAPSIBLE_SPRING) + heightAnim.finished.then( + () => { + if (gen !== id) return + c.style.height = "auto" + options.onOpen?.() + }, + () => {}, + ) + }) + return + } + + const h = content.getBoundingClientRect().height + heightAnim = animate(content, { height: [`${h}px`, "0px"] }, COLLAPSIBLE_SPRING) + fadeAnim = animate(body, { opacity: [1, 0], filter: ["blur(0px)", "blur(2px)"] }, COLLAPSIBLE_SPRING) + heightAnim.finished.then( + () => { + if (gen !== id) return + content.style.display = "none" + }, + () => {}, + ) + }, + { defer: true }, + ), + ) + + onCleanup(() => { + ++gen + heightAnim?.stop() + fadeAnim?.stop() + }) +} + +export function useContextToolPending(parts: () => ToolPart[], working?: () => boolean) { + const anyRunning = createMemo(() => parts().some((part) => busy(part.state.status))) + const [settled, setSettled] = createSignal(false) + createEffect(() => { + if (!anyRunning() && !working?.()) setSettled(true) + }) + return createMemo(() => !settled() && (!!working?.() || anyRunning())) +} + +export function useRowWipe(opts: { + id: () => string + text: () => string | undefined + ref: () => HTMLElement | undefined + seen: Set +}) { + const reduce = prefersReducedMotion + + createEffect(() => { + const id = opts.id() + const txt = opts.text() + const el = opts.ref() + if (!el) return + if (!txt) { + clearFadeStyles(el) + clearMaskStyles(el) + return + } + if (reduce() || typeof window === "undefined") { + clearFadeStyles(el) + clearMaskStyles(el) + return + } + if (opts.seen.has(id)) { + clearFadeStyles(el) + clearMaskStyles(el) + return + } + opts.seen.add(id) + + el.style.maskImage = WIPE_MASK + el.style.webkitMaskImage = WIPE_MASK + el.style.maskSize = "240% 100%" + el.style.webkitMaskSize = "240% 100%" + el.style.maskRepeat = "no-repeat" + el.style.webkitMaskRepeat = "no-repeat" + el.style.maskPosition = "100% 0%" + el.style.webkitMaskPosition = "100% 0%" + el.style.opacity = "0" + el.style.filter = "blur(2px)" + el.style.transform = "translateX(-0.06em)" + + let done = false + const clear = () => { + if (done) return + done = true + clearFadeStyles(el) + clearMaskStyles(el) + } + if (typeof requestAnimationFrame !== "function") { + clear() + return + } + let anim: AnimationPlaybackControls | undefined + let frame: number | undefined = requestAnimationFrame(() => { + frame = undefined + const node = opts.ref() + if (!node) return + anim = animate( + node, + { + opacity: [0, 1], + filter: ["blur(2px)", "blur(0px)"], + transform: ["translateX(-0.06em)", "translateX(0)"], + maskPosition: "0% 0%", + }, + GROW_SPRING, + ) + + anim.finished.catch(() => {}).finally(clear) + }) + + onCleanup(() => { + if (frame !== undefined) { + cancelAnimationFrame(frame) + clear() + } + }) + }) +} + +export function useToolFade( + ref: () => HTMLElement | undefined, + options?: { delay?: number; wipe?: boolean; animate?: boolean }, +) { + let anim: AnimationPlaybackControls | undefined + let frame: number | undefined + const delay = options?.delay ?? 0 + const wipe = options?.wipe ?? false + const active = options?.animate !== false + + onMount(() => { + if (!active) return + + const el = ref() + if (!el || typeof window === "undefined") return + if (prefersReducedMotion()) return + + const mask = + wipe && + typeof CSS !== "undefined" && + (CSS.supports("mask-image", "linear-gradient(to right, black, transparent)") || + CSS.supports("-webkit-mask-image", "linear-gradient(to right, black, transparent)")) + + el.style.opacity = "0" + el.style.filter = wipe ? "blur(3px)" : "blur(2px)" + el.style.transform = wipe ? "translateX(-0.06em)" : "translateY(0.04em)" + + if (mask) { + el.style.maskImage = WIPE_MASK + el.style.webkitMaskImage = WIPE_MASK + el.style.maskSize = "240% 100%" + el.style.webkitMaskSize = "240% 100%" + el.style.maskRepeat = "no-repeat" + el.style.webkitMaskRepeat = "no-repeat" + el.style.maskPosition = "100% 0%" + el.style.webkitMaskPosition = "100% 0%" + } + + frame = requestAnimationFrame(() => { + frame = undefined + const node = ref() + if (!node) return + + anim = wipe + ? mask + ? animate( + node, + { opacity: 1, filter: "blur(0px)", transform: "translateX(0)", maskPosition: "0% 0%" }, + { ...GROW_SPRING, delay }, + ) + : animate(node, { opacity: 1, filter: "blur(0px)", transform: "translateX(0)" }, { ...GROW_SPRING, delay }) + : animate(node, { opacity: 1, filter: "blur(0px)", transform: "translateY(0)" }, { ...GROW_SPRING, delay }) + + anim?.finished.then(() => { + const value = ref() + if (!value) return + clearFadeStyles(value) + if (mask) clearMaskStyles(value) + }) + }) + }) + + onCleanup(() => { + if (frame !== undefined) cancelAnimationFrame(frame) + anim?.stop() + }) +} diff --git a/packages/ui/src/hooks/create-auto-scroll.tsx b/packages/ui/src/hooks/create-auto-scroll.tsx index 3dc520c621..d36102590b 100644 --- a/packages/ui/src/hooks/create-auto-scroll.tsx +++ b/packages/ui/src/hooks/create-auto-scroll.tsx @@ -1,6 +1,8 @@ import { createEffect, on, onCleanup } from "solid-js" import { createStore } from "solid-js/store" import { createResizeObserver } from "@solid-primitives/resize-observer" +import { animate, type AnimationPlaybackControls } from "motion" +import { FAST_SPRING } from "../components/motion" export interface AutoScrollOptions { working: () => boolean @@ -9,13 +11,28 @@ export interface AutoScrollOptions { bottomThreshold?: number } +const SETTLE_MS = 500 +const AUTO_SCROLL_GRACE_MS = 120 +const AUTO_SCROLL_EPSILON = 0.5 +const MANUAL_ANCHOR_MS = 3000 +const MANUAL_ANCHOR_QUIET_FRAMES = 24 + export function createAutoScroll(options: AutoScrollOptions) { let scroll: HTMLElement | undefined let settling = false let settleTimer: ReturnType | undefined - let autoTimer: ReturnType | undefined let cleanup: (() => void) | undefined - let auto: { top: number; time: number } | undefined + let programmaticUntil = 0 + let scrollAnim: AnimationPlaybackControls | undefined + let hold: + | { + el: HTMLElement + top: number + until: number + quiet: number + frame: number | undefined + } + | undefined const threshold = () => options.bottomThreshold ?? 10 @@ -27,77 +44,160 @@ export function createAutoScroll(options: AutoScrollOptions) { const active = () => options.working() || settling const distanceFromBottom = (el: HTMLElement) => { - return el.scrollHeight - el.clientHeight - el.scrollTop + // With column-reverse, scrollTop=0 is at the bottom, negative = scrolled up + return Math.abs(el.scrollTop) } const canScroll = (el: HTMLElement) => { return el.scrollHeight - el.clientHeight > 1 } - // Browsers can dispatch scroll events asynchronously. If new content arrives - // between us calling `scrollTo()` and the subsequent `scroll` event firing, - // the handler can see a non-zero `distanceFromBottom` and incorrectly assume - // the user scrolled. - const markAuto = (el: HTMLElement) => { - auto = { - top: Math.max(0, el.scrollHeight - el.clientHeight), - time: Date.now(), - } - - if (autoTimer) clearTimeout(autoTimer) - autoTimer = setTimeout(() => { - auto = undefined - autoTimer = undefined - }, 1500) + const markProgrammatic = () => { + programmaticUntil = Date.now() + AUTO_SCROLL_GRACE_MS } - const isAuto = (el: HTMLElement) => { - const a = auto - if (!a) return false + const clearHold = () => { + const next = hold + if (!next) return + if (next.frame !== undefined) cancelAnimationFrame(next.frame) + hold = undefined + } - if (Date.now() - a.time > 1500) { - auto = undefined + const tickHold = () => { + const next = hold + const el = scroll + if (!next || !el) return false + if (Date.now() > next.until) { + clearHold() + return false + } + if (!next.el.isConnected) { + clearHold() return false } - return Math.abs(el.scrollTop - a.top) < 2 - } - - const scrollToBottomNow = (behavior: ScrollBehavior) => { - const el = scroll - if (!el) return - markAuto(el) - if (behavior === "smooth") { - el.scrollTo({ top: el.scrollHeight, behavior }) - return + const current = next.el.getBoundingClientRect().top + if (!Number.isFinite(current)) { + clearHold() + return false } - // `scrollTop` assignment bypasses any CSS `scroll-behavior: smooth`. - el.scrollTop = el.scrollHeight + const delta = current - next.top + if (Math.abs(delta) <= AUTO_SCROLL_EPSILON) { + next.quiet += 1 + if (next.quiet > MANUAL_ANCHOR_QUIET_FRAMES) { + clearHold() + return false + } + return true + } + + next.quiet = 0 + if (!store.userScrolled) { + setStore("userScrolled", true) + options.onUserInteracted?.() + } + el.scrollTop += delta + markProgrammatic() + return true + } + + const scheduleHold = () => { + const next = hold + if (!next) return + if (next.frame !== undefined) return + + next.frame = requestAnimationFrame(() => { + const value = hold + if (!value) return + value.frame = undefined + if (!tickHold()) return + scheduleHold() + }) + } + + const preserve = (target: HTMLElement) => { + const el = scroll + if (!el) return + + if (!store.userScrolled) { + setStore("userScrolled", true) + options.onUserInteracted?.() + } + + const top = target.getBoundingClientRect().top + if (!Number.isFinite(top)) return + + clearHold() + hold = { + el: target, + top, + until: Date.now() + MANUAL_ANCHOR_MS, + quiet: 0, + frame: undefined, + } + scheduleHold() } const scrollToBottom = (force: boolean) => { if (!force && !active()) return + clearHold() + if (force && store.userScrolled) setStore("userScrolled", false) const el = scroll if (!el) return + if (scrollAnim) cancelSmooth() if (!force && store.userScrolled) return - const distance = distanceFromBottom(el) - if (distance < 2) { - markAuto(el) + // With column-reverse, scrollTop=0 is at the bottom + if (Math.abs(el.scrollTop) <= AUTO_SCROLL_EPSILON) { + markProgrammatic() return } - // For auto-following content we prefer immediate updates to avoid - // visible "catch up" animations while content is still settling. - scrollToBottomNow("auto") + el.scrollTop = 0 + markProgrammatic() } - const stop = () => { + const cancelSmooth = () => { + if (scrollAnim) { + scrollAnim.stop() + scrollAnim = undefined + } + } + + const smoothScrollToBottom = () => { + const el = scroll + if (!el) return + + cancelSmooth() + if (store.userScrolled) setStore("userScrolled", false) + + // With column-reverse, scrollTop=0 is at the bottom + if (Math.abs(el.scrollTop) <= AUTO_SCROLL_EPSILON) { + markProgrammatic() + return + } + + scrollAnim = animate(el.scrollTop, 0, { + ...FAST_SPRING, + onUpdate: (v) => { + markProgrammatic() + el.scrollTop = v + }, + onComplete: () => { + scrollAnim = undefined + markProgrammatic() + }, + }) + } + + const stop = (input?: { hold?: boolean }) => { + if (input?.hold !== false) clearHold() + const el = scroll if (!el) return if (!canScroll(el)) { @@ -106,15 +206,25 @@ export function createAutoScroll(options: AutoScrollOptions) { } if (store.userScrolled) return + markProgrammatic() setStore("userScrolled", true) options.onUserInteracted?.() } const handleWheel = (e: WheelEvent) => { + if (e.deltaY !== 0) clearHold() + + if (e.deltaY > 0) { + const el = scroll + if (!el) return + if (distanceFromBottom(el) >= threshold()) return + if (store.userScrolled) setStore("userScrolled", false) + markProgrammatic() + return + } + if (e.deltaY >= 0) return - // If the user is scrolling within a nested scrollable region (tool output, - // code block, etc), don't treat it as leaving the "follow bottom" mode. - // Those regions opt in via `data-scrollable`. + cancelSmooth() const el = scroll const target = e.target instanceof Element ? e.target : undefined const nested = target?.closest("[data-scrollable]") @@ -126,23 +236,27 @@ export function createAutoScroll(options: AutoScrollOptions) { const el = scroll if (!el) return + if (hold) { + if (Date.now() < programmaticUntil) return + clearHold() + } + if (!canScroll(el)) { if (store.userScrolled) setStore("userScrolled", false) + markProgrammatic() return } if (distanceFromBottom(el) < threshold()) { + if (Date.now() < programmaticUntil) return if (store.userScrolled) setStore("userScrolled", false) + markProgrammatic() return } - // Ignore scroll events triggered by our own scrollToBottom calls. - if (!store.userScrolled && isAuto(el)) { - scrollToBottom(false) - return - } + if (!store.userScrolled && Date.now() < programmaticUntil) return - stop() + stop({ hold: false }) } const handleInteraction = () => { @@ -154,6 +268,11 @@ export function createAutoScroll(options: AutoScrollOptions) { } const updateOverflowAnchor = (el: HTMLElement) => { + if (hold) { + el.style.overflowAnchor = "none" + return + } + const mode = options.overflowAnchor ?? "dynamic" if (mode === "none") { @@ -173,15 +292,17 @@ export function createAutoScroll(options: AutoScrollOptions) { () => store.contentRef, () => { const el = scroll + if (hold) { + scheduleHold() + return + } if (el && !canScroll(el)) { if (store.userScrolled) setStore("userScrolled", false) + markProgrammatic() return } if (!active()) return if (store.userScrolled) return - // ResizeObserver fires after layout, before paint. - // Keep the bottom locked in the same frame to avoid visible - // "jump up then catch up" artifacts while streaming content. scrollToBottom(false) }, ) @@ -200,13 +321,11 @@ export function createAutoScroll(options: AutoScrollOptions) { settling = true settleTimer = setTimeout(() => { settling = false - }, 300) + }, SETTLE_MS) }), ) createEffect(() => { - // Track `userScrolled` even before `scrollRef` is attached, so we can - // update overflow anchoring once the element exists. store.userScrolled const el = scroll if (!el) return @@ -215,7 +334,8 @@ export function createAutoScroll(options: AutoScrollOptions) { onCleanup(() => { if (settleTimer) clearTimeout(settleTimer) - if (autoTimer) clearTimeout(autoTimer) + clearHold() + cancelSmooth() if (cleanup) cleanup() }) @@ -228,8 +348,12 @@ export function createAutoScroll(options: AutoScrollOptions) { scroll = el - if (!el) return + if (!el) { + clearHold() + return + } + markProgrammatic() updateOverflowAnchor(el) el.addEventListener("wheel", handleWheel, { passive: true }) @@ -240,13 +364,18 @@ export function createAutoScroll(options: AutoScrollOptions) { contentRef: (el: HTMLElement | undefined) => setStore("contentRef", el), handleScroll, handleInteraction, + preserve, pause: stop, - resume: () => { - if (store.userScrolled) setStore("userScrolled", false) - scrollToBottom(true) - }, - scrollToBottom: () => scrollToBottom(false), forceScrollToBottom: () => scrollToBottom(true), + smoothScrollToBottom, + snapToBottom: () => { + const el = scroll + if (!el) return + if (store.userScrolled) setStore("userScrolled", false) + // With column-reverse, scrollTop=0 is at the bottom + el.scrollTop = 0 + markProgrammatic() + }, userScrolled: () => store.userScrolled, } } diff --git a/packages/ui/src/hooks/index.ts b/packages/ui/src/hooks/index.ts index 1c90a2e493..4a218024d6 100644 --- a/packages/ui/src/hooks/index.ts +++ b/packages/ui/src/hooks/index.ts @@ -1,2 +1,5 @@ export * from "./use-filtered-list" export * from "./create-auto-scroll" +export * from "./use-element-height" +export * from "./use-reduced-motion" +export * from "./use-page-visible" diff --git a/packages/ui/src/hooks/use-element-height.ts b/packages/ui/src/hooks/use-element-height.ts new file mode 100644 index 0000000000..a9f06ec8b8 --- /dev/null +++ b/packages/ui/src/hooks/use-element-height.ts @@ -0,0 +1,25 @@ +import { createEffect, createSignal, onCleanup, type Accessor } from "solid-js" + +/** + * Tracks an element's height via ResizeObserver. + * Returns a reactive signal that updates whenever the element resizes. + */ +export function useElementHeight( + ref: Accessor | (() => HTMLElement | undefined), + initial = 0, +): Accessor { + const [height, setHeight] = createSignal(initial) + + createEffect(() => { + const el = ref() + if (!el) return + setHeight(el.getBoundingClientRect().height) + const observer = new ResizeObserver(() => { + setHeight(el.getBoundingClientRect().height) + }) + observer.observe(el) + onCleanup(() => observer.disconnect()) + }) + + return height +} diff --git a/packages/ui/src/hooks/use-page-visible.ts b/packages/ui/src/hooks/use-page-visible.ts new file mode 100644 index 0000000000..88788ef4a9 --- /dev/null +++ b/packages/ui/src/hooks/use-page-visible.ts @@ -0,0 +1,11 @@ +import { createSignal } from "solid-js" + +export const pageVisible = /* @__PURE__ */ (() => { + const [visible, setVisible] = createSignal(true) + if (typeof document !== "undefined") { + const sync = () => setVisible(document.visibilityState !== "hidden") + sync() + document.addEventListener("visibilitychange", sync) + } + return visible +})() diff --git a/packages/ui/src/hooks/use-reduced-motion.ts b/packages/ui/src/hooks/use-reduced-motion.ts new file mode 100644 index 0000000000..7fa815bbd3 --- /dev/null +++ b/packages/ui/src/hooks/use-reduced-motion.ts @@ -0,0 +1,9 @@ +import { createSignal } from "solid-js" + +export const prefersReducedMotion = /* @__PURE__ */ (() => { + if (typeof window === "undefined") return () => false + const mql = window.matchMedia("(prefers-reduced-motion: reduce)") + const [reduced, setReduced] = createSignal(mql.matches) + mql.addEventListener("change", () => setReduced(mql.matches)) + return reduced +})() diff --git a/packages/ui/src/i18n/ar.ts b/packages/ui/src/i18n/ar.ts index f0a56f772c..d75918aa7c 100644 --- a/packages/ui/src/i18n/ar.ts +++ b/packages/ui/src/i18n/ar.ts @@ -102,6 +102,7 @@ export const dict = { "ui.tool.todos.read": "قراءة المهام", "ui.tool.questions": "أسئلة", "ui.tool.agent": "وكيل {{type}}", + "ui.tool.agent.default": "وكيل", "ui.common.file.one": "ملف", "ui.common.file.other": "ملفات", diff --git a/packages/ui/src/i18n/br.ts b/packages/ui/src/i18n/br.ts index d060506054..085184fcce 100644 --- a/packages/ui/src/i18n/br.ts +++ b/packages/ui/src/i18n/br.ts @@ -102,6 +102,7 @@ export const dict = { "ui.tool.todos.read": "Ler tarefas", "ui.tool.questions": "Perguntas", "ui.tool.agent": "Agente {{type}}", + "ui.tool.agent.default": "Agente", "ui.common.file.one": "arquivo", "ui.common.file.other": "arquivos", diff --git a/packages/ui/src/i18n/bs.ts b/packages/ui/src/i18n/bs.ts index 754c6bcefe..28a292989a 100644 --- a/packages/ui/src/i18n/bs.ts +++ b/packages/ui/src/i18n/bs.ts @@ -106,6 +106,7 @@ export const dict = { "ui.tool.todos.read": "Čitanje liste zadataka", "ui.tool.questions": "Pitanja", "ui.tool.agent": "{{type}} agent", + "ui.tool.agent.default": "agent", "ui.common.file.one": "datoteka", "ui.common.file.other": "datoteke", diff --git a/packages/ui/src/i18n/da.ts b/packages/ui/src/i18n/da.ts index 0126a60c89..30ff4639a9 100644 --- a/packages/ui/src/i18n/da.ts +++ b/packages/ui/src/i18n/da.ts @@ -101,6 +101,7 @@ export const dict = { "ui.tool.todos.read": "Læs opgaver", "ui.tool.questions": "Spørgsmål", "ui.tool.agent": "{{type}} Agent", + "ui.tool.agent.default": "Agent", "ui.common.file.one": "fil", "ui.common.file.other": "filer", diff --git a/packages/ui/src/i18n/de.ts b/packages/ui/src/i18n/de.ts index 24d99ef790..bbfcd0f68a 100644 --- a/packages/ui/src/i18n/de.ts +++ b/packages/ui/src/i18n/de.ts @@ -107,6 +107,7 @@ export const dict = { "ui.tool.todos.read": "Aufgaben lesen", "ui.tool.questions": "Fragen", "ui.tool.agent": "{{type}} Agent", + "ui.tool.agent.default": "Agent", "ui.common.file.one": "Datei", "ui.common.file.other": "Dateien", diff --git a/packages/ui/src/i18n/en.ts b/packages/ui/src/i18n/en.ts index 1d92ea507c..7f4a4020ad 100644 --- a/packages/ui/src/i18n/en.ts +++ b/packages/ui/src/i18n/en.ts @@ -103,6 +103,7 @@ export const dict: Record = { "ui.tool.todos.read": "Read to-dos", "ui.tool.questions": "Questions", "ui.tool.agent": "{{type}} Agent", + "ui.tool.agent.default": "Agent", "ui.common.file.one": "file", "ui.common.file.other": "files", diff --git a/packages/ui/src/i18n/es.ts b/packages/ui/src/i18n/es.ts index 9ee95d8245..52f1506c04 100644 --- a/packages/ui/src/i18n/es.ts +++ b/packages/ui/src/i18n/es.ts @@ -102,6 +102,7 @@ export const dict = { "ui.tool.todos.read": "Leer tareas", "ui.tool.questions": "Preguntas", "ui.tool.agent": "Agente {{type}}", + "ui.tool.agent.default": "Agente", "ui.common.file.one": "archivo", "ui.common.file.other": "archivos", diff --git a/packages/ui/src/i18n/fr.ts b/packages/ui/src/i18n/fr.ts index 431abe5683..f42c13882d 100644 --- a/packages/ui/src/i18n/fr.ts +++ b/packages/ui/src/i18n/fr.ts @@ -102,6 +102,7 @@ export const dict = { "ui.tool.todos.read": "Lire les tâches", "ui.tool.questions": "Questions", "ui.tool.agent": "Agent {{type}}", + "ui.tool.agent.default": "Agent", "ui.common.file.one": "fichier", "ui.common.file.other": "fichiers", diff --git a/packages/ui/src/i18n/ja.ts b/packages/ui/src/i18n/ja.ts index c6cb2ac401..0c9e4da2bd 100644 --- a/packages/ui/src/i18n/ja.ts +++ b/packages/ui/src/i18n/ja.ts @@ -101,6 +101,7 @@ export const dict = { "ui.tool.todos.read": "Todo読み込み", "ui.tool.questions": "質問", "ui.tool.agent": "{{type}}エージェント", + "ui.tool.agent.default": "エージェント", "ui.common.file.one": "ファイル", "ui.common.file.other": "ファイル", diff --git a/packages/ui/src/i18n/ko.ts b/packages/ui/src/i18n/ko.ts index cd306e879e..74c2d4ec80 100644 --- a/packages/ui/src/i18n/ko.ts +++ b/packages/ui/src/i18n/ko.ts @@ -102,6 +102,7 @@ export const dict = { "ui.tool.todos.read": "할 일 읽기", "ui.tool.questions": "질문", "ui.tool.agent": "{{type}} 에이전트", + "ui.tool.agent.default": "에이전트", "ui.common.file.one": "파일", "ui.common.file.other": "파일", diff --git a/packages/ui/src/i18n/no.ts b/packages/ui/src/i18n/no.ts index ddfe094618..489f218ca5 100644 --- a/packages/ui/src/i18n/no.ts +++ b/packages/ui/src/i18n/no.ts @@ -105,6 +105,7 @@ export const dict: Record = { "ui.tool.todos.read": "Les gjøremål", "ui.tool.questions": "Spørsmål", "ui.tool.agent": "{{type}}-agent", + "ui.tool.agent.default": "agent", "ui.common.file.one": "fil", "ui.common.file.other": "filer", diff --git a/packages/ui/src/i18n/pl.ts b/packages/ui/src/i18n/pl.ts index 73fa96afae..9b37a0fd6c 100644 --- a/packages/ui/src/i18n/pl.ts +++ b/packages/ui/src/i18n/pl.ts @@ -101,6 +101,7 @@ export const dict = { "ui.tool.todos.read": "Czytaj zadania", "ui.tool.questions": "Pytania", "ui.tool.agent": "Agent {{type}}", + "ui.tool.agent.default": "Agent", "ui.common.file.one": "plik", "ui.common.file.other": "pliki", diff --git a/packages/ui/src/i18n/ru.ts b/packages/ui/src/i18n/ru.ts index 085be28436..7157670c42 100644 --- a/packages/ui/src/i18n/ru.ts +++ b/packages/ui/src/i18n/ru.ts @@ -101,6 +101,7 @@ export const dict = { "ui.tool.todos.read": "Читать задачи", "ui.tool.questions": "Вопросы", "ui.tool.agent": "Агент {{type}}", + "ui.tool.agent.default": "Агент", "ui.common.file.one": "файл", "ui.common.file.other": "файлов", diff --git a/packages/ui/src/i18n/th.ts b/packages/ui/src/i18n/th.ts index 705f68d1b4..553638cf43 100644 --- a/packages/ui/src/i18n/th.ts +++ b/packages/ui/src/i18n/th.ts @@ -103,6 +103,7 @@ export const dict = { "ui.tool.todos.read": "อ่านรายการงาน", "ui.tool.questions": "คำถาม", "ui.tool.agent": "เอเจนต์ {{type}}", + "ui.tool.agent.default": "เอเจนต์", "ui.common.file.one": "ไฟล์", "ui.common.file.other": "ไฟล์", diff --git a/packages/ui/src/i18n/tr.ts b/packages/ui/src/i18n/tr.ts index fa3bddb218..5b4d71e4aa 100644 --- a/packages/ui/src/i18n/tr.ts +++ b/packages/ui/src/i18n/tr.ts @@ -98,6 +98,7 @@ export const dict = { "ui.tool.todos.read": "Görevleri oku", "ui.tool.questions": "Sorular", "ui.tool.agent": "{{type}} Ajan", + "ui.tool.agent.default": "Ajan", "ui.common.file.one": "dosya", "ui.common.file.other": "dosya", diff --git a/packages/ui/src/i18n/zh.ts b/packages/ui/src/i18n/zh.ts index 571574d92e..638230544c 100644 --- a/packages/ui/src/i18n/zh.ts +++ b/packages/ui/src/i18n/zh.ts @@ -106,6 +106,7 @@ export const dict = { "ui.tool.todos.read": "读取待办", "ui.tool.questions": "问题", "ui.tool.agent": "{{type}} 智能体", + "ui.tool.agent.default": "智能体", "ui.common.file.one": "个文件", "ui.common.file.other": "个文件", diff --git a/packages/ui/src/i18n/zht.ts b/packages/ui/src/i18n/zht.ts index edbc96b12f..f793ce345b 100644 --- a/packages/ui/src/i18n/zht.ts +++ b/packages/ui/src/i18n/zht.ts @@ -106,6 +106,7 @@ export const dict = { "ui.tool.todos.read": "讀取待辦", "ui.tool.questions": "問題", "ui.tool.agent": "{{type}} 代理程式", + "ui.tool.agent.default": "代理程式", "ui.common.file.one": "個檔案", "ui.common.file.other": "個檔案", diff --git a/packages/ui/src/styles/index.css b/packages/ui/src/styles/index.css index cec42f5a0c..213a37c514 100644 --- a/packages/ui/src/styles/index.css +++ b/packages/ui/src/styles/index.css @@ -40,6 +40,7 @@ @import "../components/progress-circle.css" layer(components); @import "../components/radio-group.css" layer(components); @import "../components/resize-handle.css" layer(components); +@import "../components/rolling-results.css" layer(components); @import "../components/select.css" layer(components); @import "../components/spinner.css" layer(components); @import "../components/switch.css" layer(components); diff --git a/packages/ui/src/styles/tailwind/colors.css b/packages/ui/src/styles/tailwind/colors.css index 376cd35d32..e43f199ea5 100644 --- a/packages/ui/src/styles/tailwind/colors.css +++ b/packages/ui/src/styles/tailwind/colors.css @@ -1,4 +1,4 @@ -/* Generated by script/colors.ts */ +/* Generated by script/tailwind.ts */ /* Do not edit this file manually */ @theme { @@ -77,10 +77,6 @@ --color-text-weaker: var(--text-weaker); --color-text-strong: var(--text-strong); --color-text-interactive-base: var(--text-interactive-base); - --color-text-invert-base: var(--text-invert-base); - --color-text-invert-weak: var(--text-invert-weak); - --color-text-invert-weaker: var(--text-invert-weaker); - --color-text-invert-strong: var(--text-invert-strong); --color-text-on-brand-base: var(--text-on-brand-base); --color-text-on-interactive-base: var(--text-on-interactive-base); --color-text-on-interactive-weak: var(--text-on-interactive-weak); @@ -123,6 +119,7 @@ --color-border-weak-selected: var(--border-weak-selected); --color-border-weak-disabled: var(--border-weak-disabled); --color-border-weak-focus: var(--border-weak-focus); + --color-border-weaker-base: var(--border-weaker-base); --color-border-interactive-base: var(--border-interactive-base); --color-border-interactive-hover: var(--border-interactive-hover); --color-border-interactive-active: var(--border-interactive-active); @@ -233,12 +230,6 @@ --color-markdown-image-text: var(--markdown-image-text); --color-markdown-code-block: var(--markdown-code-block); --color-border-color: var(--border-color); - --color-border-weaker-base: var(--border-weaker-base); - --color-border-weaker-hover: var(--border-weaker-hover); - --color-border-weaker-active: var(--border-weaker-active); - --color-border-weaker-selected: var(--border-weaker-selected); - --color-border-weaker-disabled: var(--border-weaker-disabled); - --color-border-weaker-focus: var(--border-weaker-focus); --color-button-ghost-hover: var(--button-ghost-hover); --color-button-ghost-hover2: var(--button-ghost-hover2); } diff --git a/packages/ui/src/styles/theme.css b/packages/ui/src/styles/theme.css index 832b43ec74..2637b4a281 100644 --- a/packages/ui/src/styles/theme.css +++ b/packages/ui/src/styles/theme.css @@ -85,6 +85,10 @@ 0 0 0 1px var(--border-weak-base, rgba(0, 0, 0, 0.07)), 0 36px 80px 0 rgba(0, 0, 0, 0.03), 0 13.141px 29.201px 0 rgba(0, 0, 0, 0.04), 0 6.38px 14.177px 0 rgba(0, 0, 0, 0.05), 0 3.127px 6.95px 0 rgba(0, 0, 0, 0.06), 0 1.237px 2.748px 0 rgba(0, 0, 0, 0.09); + --shadow-sidebar-overlay: + 0 100px 80px 0 rgba(0, 0, 0, 0.29), 0 41.778px 33.422px 0 rgba(0, 0, 0, 0.21), + 0 22.336px 17.869px 0 rgba(0, 0, 0, 0.17), 0 12.522px 10.017px 0 rgba(0, 0, 0, 0.14), + 0 6.65px 5.32px 0 rgba(0, 0, 0, 0.12), 0 2.767px 2.214px 0 rgba(0, 0, 0, 0.08); color-scheme: light; --text-mix-blend-mode: multiply; @@ -212,6 +216,7 @@ --border-weak-selected: var(--cobalt-light-alpha-5); --border-weak-disabled: var(--smoke-light-alpha-6); --border-weak-focus: var(--smoke-light-alpha-7); + --border-weaker-base: var(--smoke-light-alpha-3); --border-interactive-base: var(--cobalt-light-7); --border-interactive-hover: var(--cobalt-light-8); --border-interactive-active: var(--cobalt-light-9); @@ -323,12 +328,6 @@ --markdown-image-text: #318795; --markdown-code-block: #1a1a1a; --border-color: #ffffff; - --border-weaker-base: var(--smoke-light-alpha-3); - --border-weaker-hover: var(--smoke-light-alpha-4); - --border-weaker-active: var(--smoke-light-alpha-6); - --border-weaker-selected: var(--cobalt-light-alpha-4); - --border-weaker-disabled: var(--smoke-light-alpha-2); - --border-weaker-focus: var(--smoke-light-alpha-6); --button-ghost-hover: var(--smoke-light-alpha-2); --button-ghost-hover2: var(--smoke-light-alpha-3); --avatar-background-pink: #feeef8; @@ -582,12 +581,7 @@ --markdown-image-text: #56b6c2; --markdown-code-block: #eeeeee; --border-color: #ffffff; - --border-weaker-base: var(--smoke-dark-alpha-3); - --border-weaker-hover: var(--smoke-dark-alpha-4); - --border-weaker-active: var(--smoke-dark-alpha-6); - --border-weaker-selected: var(--cobalt-dark-alpha-3); - --border-weaker-disabled: var(--smoke-dark-alpha-2); - --border-weaker-focus: var(--smoke-dark-alpha-6); + --border-weaker-base: var(--smoke-dark-alpha-2); --button-ghost-hover: var(--smoke-dark-alpha-2); --button-ghost-hover2: var(--smoke-dark-alpha-3); --avatar-background-pink: #501b3f; diff --git a/packages/ui/src/theme/resolve.ts b/packages/ui/src/theme/resolve.ts index f098e8028a..e8fca41036 100644 --- a/packages/ui/src/theme/resolve.ts +++ b/packages/ui/src/theme/resolve.ts @@ -152,11 +152,6 @@ export function resolveThemeVariant(variant: ThemeVariant, isDark: boolean): Res tokens["border-weak-disabled"] = neutralAlpha[5] tokens["border-weak-focus"] = neutralAlpha[isDark ? 7 : 6] tokens["border-weaker-base"] = neutralAlpha[2] - tokens["border-weaker-hover"] = neutralAlpha[3] - tokens["border-weaker-active"] = neutralAlpha[5] - tokens["border-weaker-selected"] = withAlpha(interactive[3], isDark ? 0.3 : 0.4) as ColorValue - tokens["border-weaker-disabled"] = neutralAlpha[1] - tokens["border-weaker-focus"] = neutralAlpha[5] tokens["border-interactive-base"] = interactive[6] tokens["border-interactive-hover"] = interactive[7] diff --git a/packages/ui/src/theme/themes/oc-1.json b/packages/ui/src/theme/themes/oc-1.json index 03a67ee239..132825e3fe 100644 --- a/packages/ui/src/theme/themes/oc-1.json +++ b/packages/ui/src/theme/themes/oc-1.json @@ -247,11 +247,6 @@ "markdown-code-block": "#1a1a1a", "border-color": "#ffffff", "border-weaker-base": "var(--smoke-light-alpha-3)", - "border-weaker-hover": "var(--smoke-light-alpha-4)", - "border-weaker-active": "var(--smoke-light-alpha-6)", - "border-weaker-selected": "var(--cobalt-light-alpha-4)", - "border-weaker-disabled": "var(--smoke-light-alpha-2)", - "border-weaker-focus": "var(--smoke-light-alpha-6)", "button-ghost-hover": "var(--smoke-light-alpha-2)", "button-ghost-hover2": "var(--smoke-light-alpha-3)", "avatar-background-pink": "#feeef8", @@ -513,11 +508,6 @@ "markdown-code-block": "#eeeeee", "border-color": "#ffffff", "border-weaker-base": "var(--smoke-dark-alpha-3)", - "border-weaker-hover": "var(--smoke-dark-alpha-4)", - "border-weaker-active": "var(--smoke-dark-alpha-6)", - "border-weaker-selected": "var(--cobalt-dark-alpha-3)", - "border-weaker-disabled": "var(--smoke-dark-alpha-2)", - "border-weaker-focus": "var(--smoke-dark-alpha-6)", "button-ghost-hover": "var(--smoke-dark-alpha-2)", "button-ghost-hover2": "var(--smoke-dark-alpha-3)", "avatar-background-pink": "#501b3f", diff --git a/packages/ui/src/theme/themes/oc-2.json b/packages/ui/src/theme/themes/oc-2.json index 01ec1131a2..73ca57da9d 100644 --- a/packages/ui/src/theme/themes/oc-2.json +++ b/packages/ui/src/theme/themes/oc-2.json @@ -4,7 +4,7 @@ "id": "oc-2", "light": { "seeds": { - "neutral": "#8e8b8b", + "neutral": "#8f8f8f", "primary": "#dcde8d", "success": "#12c905", "warning": "#ffdc17", @@ -15,32 +15,32 @@ "diffDelete": "#fc533a" }, "overrides": { - "background-base": "#f8f7f7", - "background-weak": "var(--gray-light-3)", - "background-strong": "var(--gray-light-1)", + "background-base": "#f8f8f8", + "background-weak": "#f3f3f3", + "background-strong": "#fcfcfc", "background-stronger": "#fcfcfc", - "surface-base": "var(--gray-light-alpha-2)", - "base": "var(--gray-light-alpha-2)", - "surface-base-hover": "#0500000f", - "surface-base-active": "var(--gray-light-alpha-3)", + "surface-base": "#00000008", + "base": "#00000008", + "surface-base-hover": "#0000000f", + "surface-base-active": "#0000000d", "surface-base-interactive-active": "var(--cobalt-light-alpha-3)", - "base2": "var(--gray-light-alpha-2)", - "base3": "var(--gray-light-alpha-2)", - "surface-inset-base": "var(--gray-light-alpha-2)", - "surface-inset-base-hover": "var(--gray-light-alpha-3)", - "surface-inset-strong": "#1f000017", - "surface-inset-strong-hover": "#1f000017", - "surface-raised-base": "var(--gray-light-alpha-2)", - "surface-float-base": "var(--gray-dark-1)", - "surface-float-base-hover": "var(--gray-dark-2)", - "surface-raised-base-hover": "var(--gray-light-alpha-3)", - "surface-raised-base-active": "var(--gray-light-alpha-5)", - "surface-raised-strong": "var(--gray-light-1)", + "base2": "#00000008", + "base3": "#00000008", + "surface-inset-base": "#00000008", + "surface-inset-base-hover": "#0000000d", + "surface-inset-strong": "#00000017", + "surface-inset-strong-hover": "#00000017", + "surface-raised-base": "#00000008", + "surface-float-base": "#161616", + "surface-float-base-hover": "#1c1c1c", + "surface-raised-base-hover": "#0000000d", + "surface-raised-base-active": "#00000017", + "surface-raised-strong": "#fcfcfc", "surface-raised-strong-hover": "var(--white)", "surface-raised-stronger": "var(--white)", "surface-raised-stronger-hover": "var(--white)", - "surface-weak": "var(--gray-light-alpha-3)", - "surface-weaker": "var(--gray-light-alpha-4)", + "surface-weak": "#0000000d", + "surface-weaker": "#00000012", "surface-strong": "#ffffff", "surface-raised-stronger-non-alpha": "var(--white)", "surface-brand-base": "var(--yuzu-light-9)", @@ -62,7 +62,7 @@ "surface-info-weak": "var(--lilac-light-2)", "surface-info-strong": "var(--lilac-light-9)", "surface-diff-unchanged-base": "#ffffff00", - "surface-diff-skip-base": "var(--gray-light-2)", + "surface-diff-skip-base": "#f8f8f8", "surface-diff-hidden-base": "var(--blue-light-3)", "surface-diff-hidden-weak": "var(--blue-light-2)", "surface-diff-hidden-weaker": "var(--blue-light-1)", @@ -78,69 +78,69 @@ "surface-diff-delete-weaker": "var(--ember-light-1)", "surface-diff-delete-strong": "var(--ember-light-6)", "surface-diff-delete-stronger": "var(--ember-light-9)", - "input-base": "var(--gray-light-1)", - "input-hover": "var(--gray-light-2)", + "input-base": "#fcfcfc", + "input-hover": "#f8f8f8", "input-active": "var(--cobalt-light-1)", "input-selected": "var(--cobalt-light-4)", "input-focus": "var(--cobalt-light-1)", - "input-disabled": "var(--gray-light-4)", - "text-base": "var(--gray-light-11)", - "text-weak": "var(--gray-light-9)", - "text-weaker": "var(--gray-light-8)", - "text-strong": "var(--gray-light-12)", - "text-invert-base": "var(--gray-dark-alpha-11)", - "text-invert-weak": "var(--gray-dark-alpha-9)", - "text-invert-weaker": "var(--gray-dark-alpha-8)", - "text-invert-strong": "var(--gray-dark-alpha-12)", + "input-disabled": "#ededed", + "text-base": "#6f6f6f", + "text-weak": "#8f8f8f", + "text-weaker": "#c7c7c7", + "text-strong": "#171717", + "text-invert-base": "#ffffff96", + "text-invert-weak": "#ffffff63", + "text-invert-weaker": "#ffffff40", + "text-invert-strong": "#ffffffeb", "text-interactive-base": "var(--cobalt-light-9)", - "text-on-brand-base": "var(--gray-light-alpha-11)", - "text-on-interactive-base": "var(--gray-light-1)", - "text-on-interactive-weak": "var(--gray-dark-alpha-11)", + "text-on-brand-base": "#0000008f", + "text-on-interactive-base": "#fcfcfc", + "text-on-interactive-weak": "#ffffff96", "text-on-success-base": "var(--apple-light-10)", "text-on-critical-base": "var(--ember-light-10)", "text-on-critical-weak": "var(--ember-light-8)", "text-on-critical-strong": "var(--ember-light-12)", - "text-on-warning-base": "var(--gray-dark-alpha-11)", - "text-on-info-base": "var(--gray-dark-alpha-11)", + "text-on-warning-base": "#ffffff96", + "text-on-info-base": "#ffffff96", "text-diff-add-base": "var(--mint-light-11)", "text-diff-delete-base": "var(--ember-light-10)", "text-diff-delete-strong": "var(--ember-light-12)", "text-diff-add-strong": "var(--mint-light-12)", - "text-on-info-weak": "var(--gray-dark-alpha-9)", - "text-on-info-strong": "var(--gray-dark-alpha-12)", - "text-on-warning-weak": "var(--gray-dark-alpha-9)", - "text-on-warning-strong": "var(--gray-dark-alpha-12)", + "text-on-info-weak": "#ffffff63", + "text-on-info-strong": "#ffffffeb", + "text-on-warning-weak": "#ffffff63", + "text-on-warning-strong": "#ffffffeb", "text-on-success-weak": "var(--apple-light-6)", "text-on-success-strong": "var(--apple-light-12)", - "text-on-brand-weak": "var(--gray-light-alpha-9)", - "text-on-brand-weaker": "var(--gray-light-alpha-8)", - "text-on-brand-strong": "var(--gray-light-alpha-12)", - "button-primary-base": "var(--gray-light-12)", - "button-secondary-base": "var(--gray-light-1)", + "text-on-brand-weak": "#00000070", + "text-on-brand-weaker": "#00000038", + "text-on-brand-strong": "#000000e8", + "button-primary-base": "#171717", + "button-secondary-base": "#fcfcfc", "button-secondary-hover": "FFFFFF0A", - "border-base": "var(--gray-light-alpha-7)", - "border-hover": "var(--gray-light-alpha-8)", - "border-active": "var(--gray-light-alpha-9)", + "border-base": "#00000024", + "border-hover": "#00000038", + "border-active": "#00000070", "border-selected": "var(--cobalt-light-alpha-9)", - "border-disabled": "var(--gray-light-alpha-8)", - "border-focus": "var(--gray-light-alpha-9)", - "border-weak-base": "var(--gray-light-alpha-5)", - "border-strong-base": "var(--gray-light-alpha-7)", - "border-strong-hover": "var(--gray-light-alpha-8)", - "border-strong-active": "var(--gray-light-alpha-7)", + "border-disabled": "#00000038", + "border-focus": "#00000070", + "border-weak-base": "#e5e5e5", + "border-strong-base": "#00000024", + "border-strong-hover": "#00000038", + "border-strong-active": "#00000024", "border-strong-selected": "var(--cobalt-light-alpha-6)", - "border-strong-disabled": "var(--gray-light-alpha-6)", - "border-strong-focus": "var(--gray-light-alpha-7)", - "border-weak-hover": "var(--gray-light-alpha-6)", - "border-weak-active": "var(--gray-light-alpha-7)", + "border-strong-disabled": "#0000001c", + "border-strong-focus": "#00000024", + "border-weak-hover": "#0000001c", + "border-weak-active": "#00000024", "border-weak-selected": "var(--cobalt-light-alpha-5)", - "border-weak-disabled": "var(--gray-light-alpha-6)", - "border-weak-focus": "var(--gray-light-alpha-7)", + "border-weak-disabled": "#0000001c", + "border-weak-focus": "#00000024", "border-interactive-base": "var(--cobalt-light-7)", "border-interactive-hover": "var(--cobalt-light-8)", "border-interactive-active": "var(--cobalt-light-9)", "border-interactive-selected": "var(--cobalt-light-9)", - "border-interactive-disabled": "var(--gray-light-8)", + "border-interactive-disabled": "#c7c7c7", "border-interactive-focus": "var(--cobalt-light-9)", "border-success-base": "var(--apple-light-6)", "border-success-hover": "var(--apple-light-7)", @@ -154,26 +154,26 @@ "border-info-base": "var(--lilac-light-6)", "border-info-hover": "var(--lilac-light-7)", "border-info-selected": "var(--lilac-light-9)", - "icon-base": "var(--gray-light-9)", - "icon-hover": "var(--gray-light-11)", - "icon-active": "var(--gray-light-12)", - "icon-selected": "var(--gray-light-12)", - "icon-disabled": "var(--gray-light-8)", - "icon-focus": "var(--gray-light-12)", + "icon-base": "#8f8f8f", + "icon-hover": "#6f6f6f", + "icon-active": "#171717", + "icon-selected": "#171717", + "icon-disabled": "#c7c7c7", + "icon-focus": "#171717", "icon-invert-base": "#ffffff", - "icon-weak-base": "var(--gray-light-7)", - "icon-weak-hover": "var(--gray-light-8)", - "icon-weak-active": "var(--gray-light-9)", - "icon-weak-selected": "var(--gray-light-10)", - "icon-weak-disabled": "var(--gray-light-6)", - "icon-weak-focus": "var(--gray-light-9)", - "icon-strong-base": "var(--gray-light-12)", - "icon-strong-hover": "#151313", + "icon-weak-base": "#dbdbdb", + "icon-weak-hover": "#c7c7c7", + "icon-weak-active": "#8f8f8f", + "icon-weak-selected": "#858585", + "icon-weak-disabled": "#e2e2e2", + "icon-weak-focus": "#8f8f8f", + "icon-strong-base": "#171717", + "icon-strong-hover": "#151515", "icon-strong-active": "#020202", "icon-strong-selected": "#020202", - "icon-strong-disabled": "var(--gray-light-6)", + "icon-strong-disabled": "#e2e2e2", "icon-strong-focus": "#020202", - "icon-brand-base": "var(--gray-light-12)", + "icon-brand-base": "#171717", "icon-interactive-base": "var(--cobalt-light-9)", "icon-success-base": "var(--apple-light-7)", "icon-success-hover": "var(--apple-light-8)", @@ -187,10 +187,10 @@ "icon-info-base": "var(--lilac-light-7)", "icon-info-hover": "var(--lilac-light-8)", "icon-info-active": "var(--lilac-light-11)", - "icon-on-brand-base": "var(--gray-light-alpha-11)", - "icon-on-brand-hover": "var(--gray-light-alpha-12)", - "icon-on-brand-selected": "var(--gray-light-alpha-12)", - "icon-on-interactive-base": "var(--gray-light-1)", + "icon-on-brand-base": "#0000008f", + "icon-on-brand-hover": "#000000e8", + "icon-on-brand-selected": "#000000e8", + "icon-on-interactive-base": "#fcfcfc", "icon-agent-plan-base": "var(--purple-light-9)", "icon-agent-docs-base": "var(--amber-light-9)", "icon-agent-ask-base": "var(--cyan-light-9)", @@ -246,14 +246,9 @@ "markdown-image-text": "#318795", "markdown-code-block": "#1a1a1a", "border-color": "#ffffff", - "border-weaker-base": "var(--gray-light-alpha-3)", - "border-weaker-hover": "var(--gray-light-alpha-4)", - "border-weaker-active": "var(--gray-light-alpha-6)", - "border-weaker-selected": "var(--cobalt-light-alpha-4)", - "border-weaker-disabled": "var(--gray-light-alpha-2)", - "border-weaker-focus": "var(--gray-light-alpha-6)", - "button-ghost-hover": "var(--gray-light-alpha-2)", - "button-ghost-hover2": "var(--gray-light-alpha-3)", + "border-weaker-base": "#efefef", + "button-ghost-hover": "#00000008", + "button-ghost-hover2": "#0000000d", "avatar-background-pink": "#feeef8", "avatar-background-mint": "#e1fbf4", "avatar-background-orange": "#fff1e7", @@ -270,7 +265,7 @@ }, "dark": { "seeds": { - "neutral": "#716c6b", + "neutral": "#707070", "primary": "#fab283", "success": "#12c905", "warning": "#fcd53a", @@ -281,33 +276,33 @@ "diffDelete": "#fc533a" }, "overrides": { - "base": "var(--gray-dark-alpha-2)", - "base2": "var(--gray-dark-alpha-2)", - "base3": "var(--gray-dark-alpha-2)", + "base": "#ffffff08", + "base2": "#ffffff08", + "base3": "#ffffff08", "background-base": "#101010", "background-weak": "#1E1E1E", "background-strong": "#121212", "background-stronger": "#151515", - "surface-base": "var(--gray-dark-alpha-2)", + "surface-base": "#ffffff08", "surface-base-hover": "#FFFFFF0A", - "surface-base-active": "var(--gray-dark-alpha-3)", + "surface-base-active": "#ffffff0f", "surface-base-interactive-active": "var(--cobalt-dark-alpha-2)", - "surface-inset-base": "#0e0b0b7f", - "surface-inset-base-hover": "#0e0b0b7f", - "surface-inset-strong": "#060505cc", - "surface-inset-strong-hover": "#060505cc", - "surface-raised-base": "var(--gray-dark-alpha-3)", - "surface-float-base": "var(--gray-dark-1)", - "surface-float-base-hover": "var(--gray-dark-2)", - "surface-raised-base-hover": "var(--gray-dark-alpha-4)", - "surface-raised-base-active": "var(--gray-dark-alpha-5)", - "surface-raised-strong": "var(--gray-dark-alpha-4)", - "surface-raised-strong-hover": "var(--gray-dark-alpha-6)", - "surface-raised-stronger": "var(--gray-dark-alpha-6)", - "surface-raised-stronger-hover": "var(--gray-dark-alpha-7)", - "surface-weak": "var(--gray-dark-alpha-4)", - "surface-weaker": "var(--gray-dark-alpha-5)", - "surface-strong": "var(--gray-dark-alpha-7)", + "surface-inset-base": "#0000007f", + "surface-inset-base-hover": "#0000007f", + "surface-inset-strong": "#000000cc", + "surface-inset-strong-hover": "#000000cc", + "surface-raised-base": "#ffffff0f", + "surface-float-base": "#161616", + "surface-float-base-hover": "#1c1c1c", + "surface-raised-base-hover": "#ffffff14", + "surface-raised-base-active": "#ffffff1a", + "surface-raised-strong": "#ffffff14", + "surface-raised-strong-hover": "#ffffff21", + "surface-raised-stronger": "#ffffff21", + "surface-raised-stronger-hover": "#ffffff2b", + "surface-weak": "#ffffff14", + "surface-weaker": "#ffffff1a", + "surface-strong": "#ffffff2b", "surface-raised-stronger-non-alpha": "#1B1B1B", "surface-brand-base": "var(--yuzu-light-9)", "surface-brand-hover": "var(--yuzu-light-10)", @@ -327,8 +322,8 @@ "surface-info-base": "var(--lilac-light-3)", "surface-info-weak": "var(--lilac-light-2)", "surface-info-strong": "var(--lilac-light-9)", - "surface-diff-unchanged-base": "var(--gray-dark-1)", - "surface-diff-skip-base": "var(--gray-dark-alpha-1)", + "surface-diff-unchanged-base": "#161616", + "surface-diff-skip-base": "#00000000", "surface-diff-hidden-base": "var(--blue-dark-2)", "surface-diff-hidden-weak": "var(--blue-dark-1)", "surface-diff-hidden-weaker": "var(--blue-dark-3)", @@ -344,64 +339,64 @@ "surface-diff-delete-weaker": "var(--ember-dark-3)", "surface-diff-delete-strong": "var(--ember-dark-5)", "surface-diff-delete-stronger": "var(--ember-dark-11)", - "input-base": "var(--gray-dark-2)", - "input-hover": "var(--gray-dark-2)", + "input-base": "#1c1c1c", + "input-hover": "#1c1c1c", "input-active": "var(--cobalt-dark-1)", "input-selected": "var(--cobalt-dark-2)", "input-focus": "var(--cobalt-dark-1)", - "input-disabled": "var(--gray-dark-4)", - "text-base": "var(--gray-dark-alpha-11)", - "text-weak": "var(--gray-dark-alpha-9)", - "text-weaker": "var(--gray-dark-alpha-8)", - "text-strong": "var(--gray-dark-alpha-12)", - "text-invert-base": "var(--gray-dark-alpha-11)", - "text-invert-weak": "var(--gray-dark-alpha-9)", - "text-invert-weaker": "var(--gray-dark-alpha-8)", - "text-invert-strong": "var(--gray-dark-alpha-12)", + "input-disabled": "#282828", + "text-base": "#ffffff96", + "text-weak": "#ffffff63", + "text-weaker": "#ffffff40", + "text-strong": "#ffffffeb", + "text-invert-base": "#ffffff96", + "text-invert-weak": "#ffffff63", + "text-invert-weaker": "#ffffff40", + "text-invert-strong": "#ffffffeb", "text-interactive-base": "var(--cobalt-dark-11)", - "text-on-brand-base": "var(--gray-dark-alpha-11)", - "text-on-interactive-base": "var(--gray-dark-12)", - "text-on-interactive-weak": "var(--gray-dark-alpha-11)", + "text-on-brand-base": "#ffffff96", + "text-on-interactive-base": "#ededed", + "text-on-interactive-weak": "#ffffff96", "text-on-success-base": "var(--apple-dark-9)", "text-on-critical-base": "var(--ember-dark-9)", "text-on-critical-weak": "var(--ember-dark-8)", "text-on-critical-strong": "var(--ember-dark-12)", - "text-on-warning-base": "var(--gray-dark-alpha-11)", - "text-on-info-base": "var(--gray-dark-alpha-11)", + "text-on-warning-base": "#ffffff96", + "text-on-info-base": "#ffffff96", "text-diff-add-base": "var(--mint-dark-11)", "text-diff-delete-base": "var(--ember-dark-9)", "text-diff-delete-strong": "var(--ember-dark-12)", "text-diff-add-strong": "var(--mint-dark-8)", - "text-on-info-weak": "var(--gray-dark-alpha-9)", - "text-on-info-strong": "var(--gray-dark-alpha-12)", - "text-on-warning-weak": "var(--gray-dark-alpha-9)", - "text-on-warning-strong": "var(--gray-dark-alpha-12)", + "text-on-info-weak": "#ffffff63", + "text-on-info-strong": "#ffffffeb", + "text-on-warning-weak": "#ffffff63", + "text-on-warning-strong": "#ffffffeb", "text-on-success-weak": "var(--apple-dark-8)", "text-on-success-strong": "var(--apple-dark-12)", - "text-on-brand-weak": "var(--gray-dark-alpha-9)", - "text-on-brand-weaker": "var(--gray-dark-alpha-8)", - "text-on-brand-strong": "var(--gray-dark-alpha-12)", - "button-primary-base": "var(--gray-dark-12)", - "button-secondary-base": "var(--gray-dark-2)", + "text-on-brand-weak": "#ffffff63", + "text-on-brand-weaker": "#ffffff40", + "text-on-brand-strong": "#ffffffeb", + "button-primary-base": "#ededed", + "button-secondary-base": "#1c1c1c", "button-secondary-hover": "#FFFFFF0A", - "border-base": "var(--gray-dark-alpha-7)", - "border-hover": "var(--gray-dark-alpha-8)", - "border-active": "var(--gray-dark-alpha-9)", + "border-base": "#ffffff2b", + "border-hover": "#ffffff40", + "border-active": "#ffffff63", "border-selected": "var(--cobalt-dark-alpha-11)", - "border-disabled": "var(--gray-dark-alpha-8)", - "border-focus": "var(--gray-dark-alpha-9)", - "border-weak-base": "var(--gray-dark-alpha-5)", - "border-weak-hover": "var(--gray-dark-alpha-7)", - "border-weak-active": "var(--gray-dark-alpha-8)", + "border-disabled": "#ffffff40", + "border-focus": "#ffffff63", + "border-weak-base": "#282828", + "border-weak-hover": "#ffffff2b", + "border-weak-active": "#ffffff40", "border-weak-selected": "var(--cobalt-dark-alpha-6)", - "border-weak-disabled": "var(--gray-dark-alpha-6)", - "border-weak-focus": "var(--gray-dark-alpha-8)", - "border-strong-base": "var(--gray-dark-alpha-8)", + "border-weak-disabled": "#ffffff21", + "border-weak-focus": "#ffffff40", + "border-strong-base": "#ffffff40", "border-interactive-base": "var(--cobalt-light-7)", "border-interactive-hover": "var(--cobalt-light-8)", "border-interactive-active": "var(--cobalt-light-9)", "border-interactive-selected": "var(--cobalt-light-9)", - "border-interactive-disabled": "var(--gray-light-8)", + "border-interactive-disabled": "#c7c7c7", "border-interactive-focus": "var(--cobalt-light-9)", "border-success-base": "var(--apple-light-6)", "border-success-hover": "var(--apple-light-7)", @@ -415,24 +410,24 @@ "border-info-base": "var(--lilac-light-6)", "border-info-hover": "var(--lilac-light-7)", "border-info-selected": "var(--lilac-light-9)", - "icon-base": "var(--gray-dark-10)", - "icon-hover": "var(--gray-dark-11)", - "icon-active": "var(--gray-dark-12)", - "icon-selected": "var(--gray-dark-12)", - "icon-disabled": "var(--gray-dark-8)", - "icon-focus": "var(--gray-dark-12)", - "icon-invert-base": "var(--gray-dark-1)", - "icon-weak-base": "var(--gray-dark-6)", - "icon-weak-hover": "var(--gray-light-7)", - "icon-weak-active": "var(--gray-light-8)", - "icon-weak-selected": "var(--gray-light-9)", - "icon-weak-disabled": "var(--gray-light-4)", - "icon-weak-focus": "var(--gray-light-9)", - "icon-strong-base": "var(--gray-dark-12)", + "icon-base": "#7e7e7e", + "icon-hover": "#a0a0a0", + "icon-active": "#ededed", + "icon-selected": "#ededed", + "icon-disabled": "#505050", + "icon-focus": "#ededed", + "icon-invert-base": "#161616", + "icon-weak-base": "#343434", + "icon-weak-hover": "#dbdbdb", + "icon-weak-active": "#c7c7c7", + "icon-weak-selected": "#8f8f8f", + "icon-weak-disabled": "#ededed", + "icon-weak-focus": "#8f8f8f", + "icon-strong-base": "#ededed", "icon-strong-hover": "#F3F3F3", "icon-strong-active": "#EBEBEB", "icon-strong-selected": "#FCFCFC", - "icon-strong-disabled": "var(--gray-dark-7)", + "icon-strong-disabled": "#3e3e3e", "icon-strong-focus": "#FCFCFC", "icon-brand-base": "var(--white)", "icon-interactive-base": "var(--cobalt-dark-11)", @@ -448,10 +443,10 @@ "icon-info-base": "var(--lilac-dark-7)", "icon-info-hover": "var(--lilac-dark-8)", "icon-info-active": "var(--lilac-dark-11)", - "icon-on-brand-base": "var(--gray-light-alpha-11)", - "icon-on-brand-hover": "var(--gray-light-alpha-12)", - "icon-on-brand-selected": "var(--gray-light-alpha-12)", - "icon-on-interactive-base": "var(--gray-dark-12)", + "icon-on-brand-base": "#0000008f", + "icon-on-brand-hover": "#000000e8", + "icon-on-brand-selected": "#000000e8", + "icon-on-interactive-base": "#ededed", "icon-agent-plan-base": "var(--purple-dark-9)", "icon-agent-docs-base": "var(--amber-dark-9)", "icon-agent-ask-base": "var(--cyan-dark-9)", @@ -507,14 +502,9 @@ "markdown-image-text": "#56b6c2", "markdown-code-block": "#eeeeee", "border-color": "#ffffff", - "border-weaker-base": "var(--gray-dark-alpha-3)", - "border-weaker-hover": "var(--gray-dark-alpha-4)", - "border-weaker-active": "var(--gray-dark-alpha-6)", - "border-weaker-selected": "var(--cobalt-dark-alpha-3)", - "border-weaker-disabled": "var(--gray-dark-alpha-2)", - "border-weaker-focus": "var(--gray-dark-alpha-6)", - "button-ghost-hover": "var(--gray-dark-alpha-2)", - "button-ghost-hover2": "var(--gray-dark-alpha-3)", + "border-weaker-base": "#1e1e1e", + "button-ghost-hover": "#ffffff08", + "button-ghost-hover2": "#ffffff0f", "avatar-background-pink": "#501b3f", "avatar-background-mint": "#033a34", "avatar-background-orange": "#5f2a06", diff --git a/packages/util/package.json b/packages/util/package.json index bc84be6360..1caf496d77 100644 --- a/packages/util/package.json +++ b/packages/util/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/util", - "version": "1.2.19", + "version": "1.2.21", "private": true, "type": "module", "license": "MIT", diff --git a/packages/util/src/array.ts b/packages/util/src/array.ts index 1fb8ac69ec..91b923dee2 100644 --- a/packages/util/src/array.ts +++ b/packages/util/src/array.ts @@ -1,3 +1,10 @@ +export function same(a: readonly T[] | undefined, b: readonly T[] | undefined) { + if (a === b) return true + if (!a || !b) return false + if (a.length !== b.length) return false + return a.every((x, i) => x === b[i]) +} + export function findLast( items: readonly T[], predicate: (item: T, index: number, items: readonly T[]) => boolean, diff --git a/packages/web/package.json b/packages/web/package.json index c85ba1a0b9..79c72a8840 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -2,7 +2,7 @@ "name": "@opencode-ai/web", "type": "module", "license": "MIT", - "version": "1.2.19", + "version": "1.2.21", "scripts": { "dev": "astro dev", "dev:remote": "VITE_API_URL=https://api.opencode.ai astro dev", diff --git a/sdks/vscode/package.json b/sdks/vscode/package.json index 215143ac99..9dd009cbd6 100644 --- a/sdks/vscode/package.json +++ b/sdks/vscode/package.json @@ -2,7 +2,7 @@ "name": "opencode", "displayName": "opencode", "description": "opencode for VS Code", - "version": "1.2.19", + "version": "1.2.21", "publisher": "sst-dev", "repository": { "type": "git",