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..5202b70d98 100644 --- a/bun.lock +++ b/bun.lock @@ -26,7 +26,7 @@ }, "packages/app": { "name": "@opencode-ai/app", - "version": "1.2.19", + "version": "1.2.22", "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.22", "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.22", "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.22", "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.22", "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.22", "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.22", "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.22", "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.22", "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.22", "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.22", "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.22", "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.22", "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.22", "dependencies": { "@kobalte/core": "catalog:", "@opencode-ai/sdk": "workspace:*", @@ -483,8 +483,11 @@ "@pierre/diffs": "catalog:", "@shikijs/transformers": "3.9.2", "@solid-primitives/bounds": "0.1.3", + "@solid-primitives/lifecycle": "0.1.2", "@solid-primitives/media": "2.3.3", + "@solid-primitives/page-visibility": "2.1.1", "@solid-primitives/resize-observer": "2.1.3", + "@solid-primitives/rootless": "1.5.2", "@solidjs/meta": "catalog:", "@solidjs/router": "catalog:", "dompurify": "3.3.1", @@ -521,7 +524,7 @@ }, "packages/util": { "name": "@opencode-ai/util", - "version": "1.2.19", + "version": "1.2.22", "dependencies": { "zod": "catalog:", }, @@ -532,7 +535,7 @@ }, "packages/web": { "name": "@opencode-ai/web", - "version": "1.2.19", + "version": "1.2.22", "dependencies": { "@astrojs/cloudflare": "12.6.3", "@astrojs/markdown-remark": "6.3.1", @@ -601,8 +604,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", @@ -1834,10 +1837,14 @@ "@solid-primitives/keyed": ["@solid-primitives/keyed@1.5.3", "", { "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-zNadtyYBhJSOjXtogkGHmRxjGdz9KHc8sGGVAGlUABkE8BED2tbIZoxkwSqzOwde8OcUEH0bb5DLZUWIMvyBSA=="], + "@solid-primitives/lifecycle": ["@solid-primitives/lifecycle@0.1.2", "", { "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-+K0T10kZXqorocFj0coIqt8NYm2UqoZfpF3nm2RwrDMZMV+C+SC0Oi3N6Dnq2i7W/n1cHAnfpoV4CBLsW21lJw=="], + "@solid-primitives/map": ["@solid-primitives/map@0.4.13", "", { "dependencies": { "@solid-primitives/trigger": "^1.1.0" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-B1zyFbsiTQvqPr+cuPCXO72sRuczG9Swncqk5P74NCGw1VE8qa/Ry9GlfI1e/VdeQYHjan+XkbE3rO2GW/qKew=="], "@solid-primitives/media": ["@solid-primitives/media@2.3.3", "", { "dependencies": { "@solid-primitives/event-listener": "^2.4.3", "@solid-primitives/rootless": "^1.5.2", "@solid-primitives/static-store": "^0.1.2", "@solid-primitives/utils": "^6.3.2" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-hQ4hLOGvfbugQi5Eu1BFWAIJGIAzztq9x0h02xgBGl2l0Jaa3h7tg6bz5tV1NSuNYVGio4rPoa7zVQQLkkx9dA=="], + "@solid-primitives/page-visibility": ["@solid-primitives/page-visibility@2.1.1", "", { "dependencies": { "@solid-primitives/event-listener": "^2.4.1", "@solid-primitives/rootless": "^1.5.1", "@solid-primitives/utils": "^6.3.1" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-CV9BqMqhunf4OOyBkhJCH9f5ivg0ADavdcaBsrqoFvwIk1FoD/blPSHYM4CK8IjS/AEXNcsjlNVc34lMu+2Wdg=="], + "@solid-primitives/props": ["@solid-primitives/props@3.2.2", "", { "dependencies": { "@solid-primitives/utils": "^6.3.2" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-lZOTwFJajBrshSyg14nBMEP0h8MXzPowGO0s3OeiR3z6nXHTfj0FhzDtJMv+VYoRJKQHG2QRnJTgCzK6erARAw=="], "@solid-primitives/refs": ["@solid-primitives/refs@1.1.2", "", { "dependencies": { "@solid-primitives/utils": "^6.3.2" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-K7tf2thy7L+YJjdqXspXOg5xvNEOH8tgEWsp0+1mQk3obHBRD6hEjYZk7p7FlJphSZImS35je3UfmWuD7MhDfg=="], @@ -2684,9 +2691,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 +5277,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..2f14f9bf4e 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-c99eE1cKAQHvwJosaFo42U9Hk0Rtp/U5oTTlyiz2Zw4=", + "aarch64-linux": "sha256-LbdssPrf8Bijyp4mRo8QaO/swxwUWSo1g0jLPm2rvUA=", + "aarch64-darwin": "sha256-0L9y6Zk4l2vAxsM2bENahhtRZY1C3XhdxLgnnYlhkkY=", + "x86_64-darwin": "sha256-0J5sFG/kHHRDcTpdpdPBMJEOHwCRnAUYmbxEHPPLDvU=" } } 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..9454d683f0 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 }) @@ -53,16 +25,26 @@ test("closing active project navigates to another open project", async ({ page, await clickMenuItem(menu, /^Close$/i, { force: true }) await expect - .poll(() => { - const pathname = new URL(page.url()).pathname - if (new RegExp(`^/${slug}/session(?:/[^/]+)?/?$`).test(pathname)) return "project" - if (pathname === "/") return "home" - return "" - }) + .poll( + () => { + const pathname = new URL(page.url()).pathname + if (new RegExp(`^/${slug}/session(?:/[^/]+)?/?$`).test(pathname)) return "project" + if (pathname === "/") return "home" + return "" + }, + { timeout: 15_000 }, + ) .toMatch(/^(project|home)$/) await expect(page).not.toHaveURL(new RegExp(`/${otherSlug}/session(?:[/?#]|$)`)) - await expect(otherButton).toHaveCount(0) + await expect + .poll( + async () => { + return await page.locator(projectSwitchSelector(otherSlug)).count() + }, + { timeout: 15_000 }, + ) + .toBe(0) }, { extra: [other] }, ) 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/settings/settings.spec.ts b/packages/app/e2e/settings/settings.spec.ts index c2a8522eb0..42fe0b06c9 100644 --- a/packages/app/e2e/settings/settings.spec.ts +++ b/packages/app/e2e/settings/settings.spec.ts @@ -83,16 +83,23 @@ test("changing theme persists in localStorage", async ({ page, gotoSession }) => const select = dialog.locator(settingsThemeSelector) await expect(select).toBeVisible() + const currentThemeId = await page.evaluate(() => { + return document.documentElement.getAttribute("data-theme") + }) + const currentTheme = (await select.locator('[data-slot="select-select-trigger-value"]').textContent())?.trim() ?? "" + await select.locator('[data-slot="select-select-trigger"]').click() const items = page.locator('[data-slot="select-select-item"]') const count = await items.count() expect(count).toBeGreaterThan(1) - const firstTheme = await items.nth(1).locator('[data-slot="select-select-item-label"]').textContent() - expect(firstTheme).toBeTruthy() + const nextTheme = (await items.locator('[data-slot="select-select-item-label"]').allTextContents()) + .map((x) => x.trim()) + .find((x) => x && x !== currentTheme) + expect(nextTheme).toBeTruthy() - await items.nth(1).click() + await items.filter({ hasText: nextTheme! }).first().click() await page.keyboard.press("Escape") @@ -101,7 +108,7 @@ test("changing theme persists in localStorage", async ({ page, gotoSession }) => }) expect(storedThemeId).not.toBeNull() - expect(storedThemeId).not.toBe("oc-1") + expect(storedThemeId).not.toBe(currentThemeId) const dataTheme = await page.evaluate(() => { return document.documentElement.getAttribute("data-theme") 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..afa6254cd0 --- /dev/null +++ b/packages/app/e2e/terminal/terminal-tabs.spec.ts @@ -0,0 +1,139 @@ +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("inactive 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"]') + const first = tabs.filter({ hasText: /Terminal 1/ }).first() + const second = tabs.filter({ hasText: /Terminal 2/ }).first() + + 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 first.click() + await expect(first).toHaveAttribute("aria-selected", "true") + 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: first.includes(one), + second: second.includes(two), + } + }, + { timeout: 30_000 }, + ) + .toEqual({ first: false, second: true }) + + await second.click() + await expect(second).toHaveAttribute("aria-selected", "true") + 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: first.includes(one), + second: second.includes(two), + } + }, + { timeout: 30_000 }, + ) + .toEqual({ first: true, second: false }) + }) +}) + +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..51f9883a56 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.22", "description": "", "type": "module", "exports": { diff --git a/packages/app/public/oc-theme-preload.js b/packages/app/public/oc-theme-preload.js index f8c7104961..5851f756e5 100644 --- a/packages/app/public/oc-theme-preload.js +++ b/packages/app/public/oc-theme-preload.js @@ -1,6 +1,5 @@ ;(function () { - var themeId = localStorage.getItem("opencode-theme-id") - if (!themeId) return + var themeId = localStorage.getItem("opencode-theme-id") || "oc-2" var scheme = localStorage.getItem("opencode-color-scheme") || "system" var isDark = scheme === "dark" || (scheme === "system" && matchMedia("(prefers-color-scheme: dark)").matches) @@ -9,9 +8,9 @@ document.documentElement.dataset.theme = themeId document.documentElement.dataset.colorScheme = mode - if (themeId === "oc-1") return + if (themeId === "oc-2") return - var css = localStorage.getItem("opencode-theme-css-" + themeId + "-" + mode) + var css = localStorage.getItem("opencode-theme-css-" + mode) if (css) { var style = document.createElement("style") style.id = "oc-theme-preload" 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..52251dbb20 100644 --- a/packages/app/src/components/session/session-new-view.tsx +++ b/packages/app/src/components/session/session-new-view.tsx @@ -4,12 +4,12 @@ import { useSync } from "@/context/sync" import { useSDK } from "@/context/sdk" import { useLanguage } from "@/context/language" import { Icon } from "@opencode-ai/ui/icon" +import { Mark } from "@opencode-ai/ui/logo" 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 +50,43 @@ 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/terminal.tsx b/packages/app/src/components/terminal.tsx index c27d6a977f..9e5f12ee4c 100644 --- a/packages/app/src/components/terminal.tsx +++ b/packages/app/src/components/terminal.tsx @@ -217,7 +217,7 @@ export const Terminal = (props: TerminalProps) => { const currentTheme = theme.themes()[theme.themeId()] if (!currentTheme) return fallback const variant = mode === "dark" ? currentTheme.dark : currentTheme.light - if (!variant?.seeds) return fallback + if (!variant?.seeds && !variant?.palette) return fallback const resolved = resolveThemeVariant(variant, mode === "dark") const text = resolved["text-stronger"] ?? fallback.foreground const background = resolved["background-stronger"] ?? fallback.background 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 (
-
+
{ diff --git a/packages/app/src/context/global-sync/event-reducer.test.ts b/packages/app/src/context/global-sync/event-reducer.test.ts index ab7f99cef3..cf2da135cb 100644 --- a/packages/app/src/context/global-sync/event-reducer.test.ts +++ b/packages/app/src/context/global-sync/event-reducer.test.ts @@ -2,7 +2,7 @@ import { describe, expect, test } from "bun:test" import type { Message, Part, PermissionRequest, Project, QuestionRequest, Session } from "@opencode-ai/sdk/v2/client" import { createStore } from "solid-js/store" import type { State } from "./types" -import { applyDirectoryEvent, applyGlobalEvent } from "./event-reducer" +import { applyDirectoryEvent, applyGlobalEvent, cleanupDroppedSessionCaches } from "./event-reducer" const rootSession = (input: { id: string; parentID?: string; archived?: number }) => ({ @@ -248,6 +248,62 @@ describe("applyDirectoryEvent", () => { } }) + test("cleans caches for trimmed sessions on session.created", () => { + const dropped = rootSession({ id: "ses_b" }) + const kept = rootSession({ id: "ses_a" }) + const message = userMessage("msg_1", dropped.id) + const todos: string[] = [] + const [store, setStore] = createStore( + baseState({ + limit: 1, + session: [dropped], + message: { [dropped.id]: [message] }, + part: { [message.id]: [textPart("prt_1", dropped.id, message.id)] }, + session_diff: { [dropped.id]: [] }, + todo: { [dropped.id]: [] }, + permission: { [dropped.id]: [] }, + question: { [dropped.id]: [] }, + session_status: { [dropped.id]: { type: "busy" } }, + }), + ) + + applyDirectoryEvent({ + event: { type: "session.created", properties: { info: kept } }, + store, + setStore, + push() {}, + directory: "/tmp", + loadLsp() {}, + setSessionTodo(sessionID, value) { + if (value !== undefined) return + todos.push(sessionID) + }, + }) + + expect(store.session.map((x) => x.id)).toEqual([kept.id]) + expect(store.message[dropped.id]).toBeUndefined() + expect(store.part[message.id]).toBeUndefined() + expect(store.session_diff[dropped.id]).toBeUndefined() + expect(store.todo[dropped.id]).toBeUndefined() + expect(store.permission[dropped.id]).toBeUndefined() + expect(store.question[dropped.id]).toBeUndefined() + expect(store.session_status[dropped.id]).toBeUndefined() + expect(todos).toEqual([dropped.id]) + }) + + test("cleanupDroppedSessionCaches clears part-only orphan state", () => { + const [store, setStore] = createStore( + baseState({ + session: [rootSession({ id: "ses_keep" })], + part: { msg_1: [textPart("prt_1", "ses_drop", "msg_1")] }, + }), + ) + + cleanupDroppedSessionCaches(store, setStore, store.session) + + expect(store.part.msg_1).toBeUndefined() + }) + test("upserts and removes messages while clearing orphaned parts", () => { const sessionID = "ses_1" const [store, setStore] = createStore( diff --git a/packages/app/src/context/global-sync/event-reducer.ts b/packages/app/src/context/global-sync/event-reducer.ts index 241dfb14d7..b8eda0573f 100644 --- a/packages/app/src/context/global-sync/event-reducer.ts +++ b/packages/app/src/context/global-sync/event-reducer.ts @@ -13,6 +13,7 @@ import type { } from "@opencode-ai/sdk/v2/client" import type { State, VcsCache } from "./types" import { trimSessions } from "./session-trim" +import { dropSessionCaches } from "./session-cache" export function applyGlobalEvent(input: { event: { type: string; properties?: unknown } @@ -40,37 +41,44 @@ export function applyGlobalEvent(input: { } function cleanupSessionCaches( - store: Store, setStore: SetStoreFunction, sessionID: string, setSessionTodo?: (sessionID: string, todos: Todo[] | undefined) => void, ) { if (!sessionID) return - const hasAny = - store.message[sessionID] !== undefined || - store.session_diff[sessionID] !== undefined || - store.todo[sessionID] !== undefined || - store.permission[sessionID] !== undefined || - store.question[sessionID] !== undefined || - store.session_status[sessionID] !== undefined setSessionTodo?.(sessionID, undefined) - if (!hasAny) return setStore( produce((draft) => { - const messages = draft.message[sessionID] - if (messages) { - for (const message of messages) { - const id = message?.id - if (!id) continue - delete draft.part[id] - } - } - delete draft.message[sessionID] - delete draft.session_diff[sessionID] - delete draft.todo[sessionID] - delete draft.permission[sessionID] - delete draft.question[sessionID] - delete draft.session_status[sessionID] + dropSessionCaches(draft, [sessionID]) + }), + ) +} + +export function cleanupDroppedSessionCaches( + store: Store, + setStore: SetStoreFunction, + next: Session[], + setSessionTodo?: (sessionID: string, todos: Todo[] | undefined) => void, +) { + const keep = new Set(next.map((item) => item.id)) + const stale = [ + ...Object.keys(store.message), + ...Object.keys(store.session_diff), + ...Object.keys(store.todo), + ...Object.keys(store.permission), + ...Object.keys(store.question), + ...Object.keys(store.session_status), + ...Object.values(store.part) + .map((parts) => parts?.find((part) => !!part?.sessionID)?.sessionID) + .filter((sessionID): sessionID is string => !!sessionID), + ].filter((sessionID, index, list) => !keep.has(sessionID) && list.indexOf(sessionID) === index) + if (stale.length === 0) return + for (const sessionID of stale) { + setSessionTodo?.(sessionID, undefined) + } + setStore( + produce((draft) => { + dropSessionCaches(draft, stale) }), ) } @@ -102,6 +110,7 @@ export function applyDirectoryEvent(input: { next.splice(result.index, 0, info) const trimmed = trimSessions(next, { limit: input.store.limit, permission: input.store.permission }) input.setStore("session", reconcile(trimmed, { key: "id" })) + cleanupDroppedSessionCaches(input.store, input.setStore, trimmed, input.setSessionTodo) if (!info.parentID) input.setStore("sessionTotal", (value) => value + 1) break } @@ -117,7 +126,7 @@ export function applyDirectoryEvent(input: { }), ) } - cleanupSessionCaches(input.store, input.setStore, info.id, input.setSessionTodo) + cleanupSessionCaches(input.setStore, info.id, input.setSessionTodo) if (info.parentID) break input.setStore("sessionTotal", (value) => Math.max(0, value - 1)) break @@ -130,6 +139,7 @@ export function applyDirectoryEvent(input: { next.splice(result.index, 0, info) const trimmed = trimSessions(next, { limit: input.store.limit, permission: input.store.permission }) input.setStore("session", reconcile(trimmed, { key: "id" })) + cleanupDroppedSessionCaches(input.store, input.setStore, trimmed, input.setSessionTodo) break } case "session.deleted": { @@ -143,7 +153,7 @@ export function applyDirectoryEvent(input: { }), ) } - cleanupSessionCaches(input.store, input.setStore, info.id, input.setSessionTodo) + cleanupSessionCaches(input.setStore, info.id, input.setSessionTodo) if (info.parentID) break input.setStore("sessionTotal", (value) => Math.max(0, value - 1)) break diff --git a/packages/app/src/context/global-sync/session-cache.test.ts b/packages/app/src/context/global-sync/session-cache.test.ts new file mode 100644 index 0000000000..8e11110e3d --- /dev/null +++ b/packages/app/src/context/global-sync/session-cache.test.ts @@ -0,0 +1,102 @@ +import { describe, expect, test } from "bun:test" +import type { + FileDiff, + Message, + Part, + PermissionRequest, + QuestionRequest, + SessionStatus, + Todo, +} from "@opencode-ai/sdk/v2/client" +import { dropSessionCaches, pickSessionCacheEvictions } from "./session-cache" + +const msg = (id: string, sessionID: string) => + ({ + id, + sessionID, + role: "user", + time: { created: 1 }, + agent: "assistant", + model: { providerID: "openai", modelID: "gpt" }, + }) as Message + +const part = (id: string, sessionID: string, messageID: string) => + ({ + id, + sessionID, + messageID, + type: "text", + text: id, + }) as Part + +describe("app session cache", () => { + test("dropSessionCaches clears orphaned parts without message rows", () => { + const store: { + session_status: Record + session_diff: Record + todo: Record + message: Record + part: Record + permission: Record + question: Record + } = { + session_status: { ses_1: { type: "busy" } as SessionStatus }, + session_diff: { ses_1: [] }, + todo: { ses_1: [] as Todo[] }, + message: {}, + part: { msg_1: [part("prt_1", "ses_1", "msg_1")] }, + permission: { ses_1: [] as PermissionRequest[] }, + question: { ses_1: [] as QuestionRequest[] }, + } + + dropSessionCaches(store, ["ses_1"]) + + expect(store.message.ses_1).toBeUndefined() + expect(store.part.msg_1).toBeUndefined() + expect(store.todo.ses_1).toBeUndefined() + expect(store.session_diff.ses_1).toBeUndefined() + expect(store.session_status.ses_1).toBeUndefined() + expect(store.permission.ses_1).toBeUndefined() + expect(store.question.ses_1).toBeUndefined() + }) + + test("dropSessionCaches clears message-backed parts", () => { + const m = msg("msg_1", "ses_1") + const store: { + session_status: Record + session_diff: Record + todo: Record + message: Record + part: Record + permission: Record + question: Record + } = { + session_status: {}, + session_diff: {}, + todo: {}, + message: { ses_1: [m] }, + part: { [m.id]: [part("prt_1", "ses_1", m.id)] }, + permission: {}, + question: {}, + } + + dropSessionCaches(store, ["ses_1"]) + + expect(store.message.ses_1).toBeUndefined() + expect(store.part[m.id]).toBeUndefined() + }) + + test("pickSessionCacheEvictions preserves requested sessions", () => { + const seen = new Set(["ses_1", "ses_2", "ses_3"]) + + const stale = pickSessionCacheEvictions({ + seen, + keep: "ses_4", + limit: 2, + preserve: ["ses_1"], + }) + + expect(stale).toEqual(["ses_2", "ses_3"]) + expect([...seen]).toEqual(["ses_1", "ses_4"]) + }) +}) diff --git a/packages/app/src/context/global-sync/session-cache.ts b/packages/app/src/context/global-sync/session-cache.ts new file mode 100644 index 0000000000..0177ebbe13 --- /dev/null +++ b/packages/app/src/context/global-sync/session-cache.ts @@ -0,0 +1,62 @@ +import type { + FileDiff, + Message, + Part, + PermissionRequest, + QuestionRequest, + SessionStatus, + Todo, +} from "@opencode-ai/sdk/v2/client" + +export const SESSION_CACHE_LIMIT = 40 + +type SessionCache = { + session_status: Record + session_diff: Record + todo: Record + message: Record + part: Record + permission: Record + question: Record +} + +export function dropSessionCaches(store: SessionCache, sessionIDs: Iterable) { + const stale = new Set(Array.from(sessionIDs).filter(Boolean)) + if (stale.size === 0) return + + for (const key of Object.keys(store.part)) { + const parts = store.part[key] + if (!parts?.some((part) => stale.has(part?.sessionID ?? ""))) continue + delete store.part[key] + } + + for (const sessionID of stale) { + delete store.message[sessionID] + delete store.todo[sessionID] + delete store.session_diff[sessionID] + delete store.session_status[sessionID] + delete store.permission[sessionID] + delete store.question[sessionID] + } +} + +export function pickSessionCacheEvictions(input: { + seen: Set + keep: string + limit: number + preserve?: Iterable +}) { + const stale: string[] = [] + const keep = new Set([input.keep, ...Array.from(input.preserve ?? [])]) + if (input.seen.has(input.keep)) input.seen.delete(input.keep) + input.seen.add(input.keep) + for (const id of input.seen) { + if (input.seen.size - stale.length <= input.limit) break + if (keep.has(id)) continue + stale.push(id) + } + for (const id of stale) { + input.seen.delete(id) + } + return stale +} diff --git a/packages/app/src/context/language.tsx b/packages/app/src/context/language.tsx index be1a1769bf..b1edd541c3 100644 --- a/packages/app/src/context/language.tsx +++ b/packages/app/src/context/language.tsx @@ -146,6 +146,7 @@ const DICT: Record = { } 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..5623a2c7cd 100644 --- a/packages/app/src/context/sync.tsx +++ b/packages/app/src/context/sync.tsx @@ -6,6 +6,7 @@ import { createSimpleContext } from "@opencode-ai/ui/context" import { useGlobalSync } from "./global-sync" import { useSDK } from "./sdk" import type { Message, Part } from "@opencode-ai/sdk/v2/client" +import { SESSION_CACHE_LIMIT, dropSessionCaches, pickSessionCacheEvictions } from "./global-sync/session-cache" function sortParts(parts: Part[]) { return parts.filter((part) => !!part?.id).sort((a, b) => cmp(a.id, b.id)) @@ -108,6 +109,8 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ const inflight = new Map>() const inflightDiff = new Map>() const inflightTodo = new Map>() + const maxDirs = 30 + const seen = new Map>() const [meta, setMeta] = createStore({ limit: {} as Record, complete: {} as Record, @@ -121,6 +124,62 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ return undefined } + const seenFor = (directory: string) => { + const existing = seen.get(directory) + if (existing) { + seen.delete(directory) + seen.set(directory, existing) + return existing + } + const created = new Set() + seen.set(directory, created) + while (seen.size > maxDirs) { + const first = seen.keys().next().value + if (!first) break + const stale = [...(seen.get(first) ?? [])] + seen.delete(first) + const [, setStore] = globalSync.child(first, { bootstrap: false }) + evict(first, setStore, stale) + } + return created + } + + const clearMeta = (directory: string, sessionIDs: string[]) => { + if (sessionIDs.length === 0) return + setMeta( + produce((draft) => { + for (const sessionID of sessionIDs) { + const key = keyFor(directory, sessionID) + delete draft.limit[key] + delete draft.complete[key] + delete draft.loading[key] + } + }), + ) + } + + const evict = (directory: string, setStore: Setter, sessionIDs: string[]) => { + if (sessionIDs.length === 0) return + for (const sessionID of sessionIDs) { + globalSync.todo.set(sessionID, undefined) + } + setStore( + produce((draft) => { + dropSessionCaches(draft, sessionIDs) + }), + ) + clearMeta(directory, sessionIDs) + } + + const touch = (directory: string, setStore: Setter, sessionID: string) => { + const stale = pickSessionCacheEvictions({ + seen: seenFor(directory), + keep: sessionID, + limit: SESSION_CACHE_LIMIT, + }) + evict(directory, setStore, stale) + } + const fetchMessages = async (input: { client: typeof sdk.client; sessionID: string; limit: number }) => { const messages = await retry(() => input.client.session.messages({ sessionID: input.sessionID, limit: input.limit }), @@ -135,6 +194,8 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ } } + const tracked = (directory: string, sessionID: string) => seen.get(directory)?.has(sessionID) ?? false + const loadMessages = async (input: { directory: string client: typeof sdk.client @@ -148,6 +209,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ setMeta("loading", key, true) await fetchMessages(input) .then((next) => { + if (!tracked(input.directory, input.sessionID)) return batch(() => { input.setStore("message", input.sessionID, reconcile(next.session, { key: "id" })) for (const p of next.part) { @@ -158,6 +220,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ }) }) .finally(() => { + if (!tracked(input.directory, input.sessionID)) return setMeta("loading", key, false) }) } @@ -199,6 +262,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 +271,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, { @@ -222,11 +287,16 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ const key = keyFor(directory, sessionID) const hasSession = Binary.search(store.session, sessionID, (s) => s.id).found + touch(directory, setStore, sessionID) + + if (store.message[sessionID] !== undefined && hasSession && meta.limit[key] !== undefined) return + const limit = meta.limit[key] ?? messagePageSize const sessionReq = hasSession ? Promise.resolve() : retry(() => client.session.get({ sessionID })).then((session) => { + if (!tracked(directory, sessionID)) return const data = session.data if (!data) return setStore( @@ -256,11 +326,13 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ const directory = sdk.directory const client = sdk.client const [store, setStore] = globalSync.child(directory) + touch(directory, setStore, sessionID) if (store.session_diff[sessionID] !== undefined) return const key = keyFor(directory, sessionID) return runInflight(inflightDiff, key, () => retry(() => client.session.diff({ sessionID })).then((diff) => { + if (!tracked(directory, sessionID)) return setStore("session_diff", sessionID, reconcile(diff.data ?? [], { key: "file" })) }), ) @@ -269,6 +341,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ const directory = sdk.directory const client = sdk.client const [store, setStore] = globalSync.child(directory) + touch(directory, setStore, sessionID) const existing = store.todo[sessionID] const cached = globalSync.data.session_todo[sessionID] if (existing !== undefined) { @@ -285,6 +358,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ const key = keyFor(directory, sessionID) return runInflight(inflightTodo, key, () => retry(() => client.session.todo({ sessionID })).then((todo) => { + if (!tracked(directory, sessionID)) return const list = todo.data ?? [] setStore("todo", sessionID, reconcile(list, { key: "id" })) globalSync.todo.set(sessionID, list) @@ -308,6 +382,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ const directory = sdk.directory const client = sdk.client const [, setStore] = globalSync.child(directory) + touch(directory, setStore, sessionID) const key = keyFor(directory, sessionID) const step = count ?? messagePageSize if (meta.loading[key]) return @@ -323,6 +398,11 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ }) }, }, + evict(sessionID: string, directory = sdk.directory) { + const [, setStore] = globalSync.child(directory) + seenFor(directory).delete(sessionID) + evict(directory, setStore, [sessionID]) + }, fetch: async (count = 10) => { const directory = sdk.directory const client = sdk.client diff --git a/packages/app/src/context/terminal.tsx b/packages/app/src/context/terminal.tsx index 64f026219a..4467495b79 100644 --- a/packages/app/src/context/terminal.tsx +++ b/packages/app/src/context/terminal.tsx @@ -1,6 +1,6 @@ import { createStore, produce } from "solid-js/store" import { createSimpleContext } from "@opencode-ai/ui/context" -import { batch, createEffect, createMemo, createRoot, onCleanup } from "solid-js" +import { batch, createEffect, createMemo, createRoot, on, onCleanup } from "solid-js" import { useParams } from "@solidjs/router" import { useSDK } from "./sdk" import type { Platform } from "./platform" @@ -38,6 +38,16 @@ type TerminalCacheEntry = { const caches = new Set>() +const trimTerminal = (pty: LocalPTY) => { + if (!pty.buffer && pty.cursor === undefined && pty.scrollY === undefined) return pty + return { + ...pty, + buffer: undefined, + cursor: undefined, + scrollY: undefined, + } +} + export function clearWorkspaceTerminals(dir: string, sessionIDs?: string[], platform?: Platform) { const key = getWorkspaceTerminalCacheKey(dir) for (const cache of caches) { @@ -188,6 +198,18 @@ function createWorkspaceTerminalSession(sdk: ReturnType, dir: str console.error("Failed to update terminal", error) }) }, + trim(id: string) { + const index = store.all.findIndex((x) => x.id === id) + if (index === -1) return + setStore("all", index, (pty) => trimTerminal(pty)) + }, + trimAll() { + setStore("all", (all) => { + const next = all.map(trimTerminal) + if (next.every((pty, index) => pty === all[index])) return all + return next + }) + }, async clone(id: string) { const index = store.all.findIndex((x) => x.id === id) const pty = store.all[index] @@ -322,12 +344,27 @@ export const { use: useTerminal, provider: TerminalProvider } = createSimpleCont const workspace = createMemo(() => loadWorkspace(params.dir!, params.id)) + createEffect( + on( + () => ({ dir: params.dir, id: params.id }), + (next, prev) => { + if (!prev?.dir) return + if (next.dir === prev.dir && next.id === prev.id) return + if (next.dir === prev.dir && next.id) return + loadWorkspace(prev.dir, prev.id).trimAll() + }, + { defer: true }, + ), + ) + return { ready: () => workspace().ready(), all: () => workspace().all(), active: () => workspace().active(), new: () => workspace().new(), update: (pty: Partial & { id: string }) => workspace().update(pty), + trim: (id: string) => workspace().trim(id), + trimAll: () => workspace().trimAll(), clone: (id: string) => workspace().clone(id), open: (id: string) => workspace().open(id), close: (id: string) => workspace().close(id), 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..b7ac28ae1a 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" @@ -36,6 +34,7 @@ import { useProviders } from "@/hooks/use-providers" import { showToast, Toast, toaster } from "@opencode-ai/ui/toast" import { useGlobalSDK } from "@/context/global-sdk" import { clearWorkspaceTerminals } from "@/context/terminal" +import { dropSessionCaches, pickSessionCacheEvictions } from "@/context/global-sync/session-cache" import { useNotification } from "@/context/notification" import { usePermission } from "@/context/permission" import { Binary } from "@opencode-ai/util/binary" @@ -59,7 +58,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 +94,7 @@ export default function Layout(props: ParentProps) { workspaceName: {} as Record, workspaceBranchName: {} as Record>, workspaceExpanded: {} as Record, + gettingStartedDismissed: false, }), ) @@ -157,6 +156,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 +170,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 +182,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 +209,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) @@ -601,25 +658,24 @@ export default function Layout(props: ParentProps) { const prefetchQueues = new Map() const PREFETCH_MAX_SESSIONS_PER_DIR = 10 - const prefetchedByDir = new Map>() + const prefetchedByDir = new Map>() const lruFor = (directory: string) => { const existing = prefetchedByDir.get(directory) if (existing) return existing - const created = new Map() + const created = new Set() prefetchedByDir.set(directory, created) return created } const markPrefetched = (directory: string, sessionID: string) => { const lru = lruFor(directory) - if (lru.has(sessionID)) lru.delete(sessionID) - lru.set(sessionID, true) - while (lru.size > PREFETCH_MAX_SESSIONS_PER_DIR) { - const oldest = lru.keys().next().value as string | undefined - if (!oldest) return - lru.delete(oldest) - } + return pickSessionCacheEvictions({ + seen: lru, + keep: sessionID, + limit: PREFETCH_MAX_SESSIONS_PER_DIR, + preserve: directory === params.dir && params.id ? [params.id] : undefined, + }) } createEffect(() => { @@ -668,6 +724,7 @@ export default function Layout(props: ParentProps) { return retry(() => globalSDK.client.session.messages({ directory, sessionID, limit: prefetchChunk })) .then((messages) => { if (prefetchToken.value !== token) return + if (!lruFor(directory).has(sessionID)) return const items = (messages.data ?? []).filter((x) => !!x?.info?.id) const next = items.map((x) => x.info).filter((m): m is Message => !!m?.id) @@ -731,7 +788,18 @@ export default function Layout(props: ParentProps) { const lru = lruFor(directory) const known = lru.has(session.id) if (!known && lru.size >= PREFETCH_MAX_SESSIONS_PER_DIR && priority !== "high") return - markPrefetched(directory, session.id) + const stale = markPrefetched(directory, session.id) + if (stale.length > 0) { + const [, setStore] = globalSync.child(directory, { bootstrap: false }) + for (const id of stale) { + globalSync.todo.set(id, undefined) + } + setStore( + produce((draft) => { + dropSessionCaches(draft, stale) + }), + ) + } if (priority === "high") q.pending.unshift(session.id) if (priority !== "high") q.pending.push(session.id) @@ -1125,6 +1193,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 +1889,9 @@ 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 hover = createMemo(() => !panelProps.mobile && panelProps.merged === false && !layout.sidebar.opened()) const projectName = createMemo(() => { const project = panelProps.project if (!project) return "" @@ -1841,12 +1917,19 @@ export default function Layout(props: ParentProps) { return (
- + {(p) => ( <>
@@ -1855,7 +1938,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 +1947,7 @@ export default function Layout(props: ParentProps) { - {p.worktree.replace(homedir(), "~")} + {p().worktree.replace(homedir(), "~")}
@@ -1883,33 +1966,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 +2003,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 +2024,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 +2032,7 @@ export default function Layout(props: ParentProps) {
@@ -1959,7 +2042,7 @@ export default function Layout(props: ParentProps) { > <>
-
@@ -1984,7 +2067,7 @@ export default function Layout(props: ParentProps) { @@ -2009,25 +2092,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 +2126,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..187cd2f335 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" @@ -93,6 +91,7 @@ const ProjectTile = (props: { modal={!props.sidebarHovering()} onOpenChange={(value) => { props.setMenu(value) + props.setSuppressHover(value) if (value) props.setOpen(false) }} > @@ -109,6 +108,12 @@ const ProjectTile = (props: { !props.selected() && !props.active(), "bg-surface-base-hover border border-border-weak-base": !props.selected() && props.active(), }} + onPointerDown={(event) => { + if (!props.overlay()) return + if (event.button !== 2 && !(event.button === 0 && event.ctrlKey)) return + props.setSuppressHover(true) + event.preventDefault() + }} onMouseEnter={(event: MouseEvent) => { if (!props.overlay()) return if (props.suppressHover()) return @@ -137,7 +142,7 @@ const ProjectTile = (props: { > - + props.showEditProjectDialog(props.project)}> {props.language.t("common.edit")} @@ -194,21 +199,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 +836,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 @@ -1035,6 +886,7 @@ export default function Page() { let scrollStateFrame: number | undefined let scrollStateTarget: HTMLDivElement | undefined + let historyFillFrame: number | undefined const scrollSpy = createScrollSpy({ onActive: (id) => { if (id === store.messageId) return @@ -1045,7 +897,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 +920,7 @@ export default function Page() { const resumeScroll = () => { setStore("messageId", undefined) - autoScroll.forceScrollToBottom() + autoScroll.smoothScrollToBottom() clearMessageHash() const el = scroller @@ -1104,7 +956,9 @@ export default function Page() { scroller = el autoScroll.scrollRef(el) scrollSpy.setContainer(el) - if (el) scheduleScrollState(el) + if (!el) return + scheduleScrollState(el) + scheduleHistoryFill() } createResizeObserver( @@ -1113,6 +967,7 @@ export default function Page() { const el = scroller if (el) scheduleScrollState(el) scrollSpy.markDirty() + scheduleHistoryFill() }, ) @@ -1127,6 +982,45 @@ export default function Page() { scroller: () => scroller, }) + const scheduleHistoryFill = () => { + if (historyFillFrame !== undefined) return + + historyFillFrame = requestAnimationFrame(() => { + historyFillFrame = undefined + + if (!params.id || !messagesReady()) return + if (autoScroll.userScrolled() || historyLoading()) return + + const el = scroller + if (!el) return + if (el.scrollHeight > el.clientHeight + 1) return + if (historyWindow.turnStart() <= 0 && !historyMore()) return + + void historyWindow.loadAndReveal() + }) + } + + createEffect( + on( + () => + [ + params.id, + messagesReady(), + historyWindow.turnStart(), + historyMore(), + historyLoading(), + autoScroll.userScrolled(), + visibleUserMessages().length, + ] as const, + ([id, ready, start, more, loading, scrolled]) => { + if (!id || !ready || loading || scrolled) return + if (start <= 0 && !more) return + scheduleHistoryFill() + }, + { defer: true }, + ), + ) + createResizeObserver( () => promptDock, ({ height }) => { @@ -1136,16 +1030,15 @@ 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() + scheduleHistoryFill() }, ) @@ -1175,6 +1068,7 @@ export default function Page() { document.removeEventListener("keydown", handleKeyDown) scrollSpy.destroy() if (scrollStateFrame !== undefined) cancelAnimationFrame(scrollStateFrame) + if (historyFillFrame !== undefined) cancelAnimationFrame(historyFillFrame) }) return ( @@ -1193,9 +1087,9 @@ export default function Page() { {/* Session panel */}
{ content = el @@ -1291,17 +1186,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/history-window.test.ts b/packages/app/src/pages/session/history-window.test.ts new file mode 100644 index 0000000000..4a9b894e27 --- /dev/null +++ b/packages/app/src/pages/session/history-window.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, test } from "bun:test" +import { historyLoadMode, historyRevealTop } from "./history-window" + +describe("historyLoadMode", () => { + test("reveals cached turns before fetching", () => { + expect(historyLoadMode({ start: 10, more: true, loading: false })).toBe("reveal") + }) + + test("fetches older history when cache is already revealed", () => { + expect(historyLoadMode({ start: 0, more: true, loading: false })).toBe("fetch") + }) + + test("does nothing while history is unavailable or loading", () => { + expect(historyLoadMode({ start: 0, more: false, loading: false })).toBe("noop") + expect(historyLoadMode({ start: 0, more: true, loading: true })).toBe("noop") + }) +}) + +describe("historyRevealTop", () => { + test("pins the viewport to the top when older turns were revealed there", () => { + expect(historyRevealTop({ top: -400, height: 1000, gap: 0, max: 400 }, { clientHeight: 600, height: 2000 })).toBe( + -1400, + ) + }) + + test("keeps the latest turns pinned when the viewport was underfilled", () => { + expect(historyRevealTop({ top: 0, height: 200, gap: -400, max: -400 }, { clientHeight: 600, height: 2000 })).toBe(0) + }) + + test("keeps the current anchor when the user was not at the top", () => { + expect(historyRevealTop({ top: -200, height: 1000, gap: 200, max: 400 }, { clientHeight: 600, height: 2000 })).toBe( + -200, + ) + }) +}) diff --git a/packages/app/src/pages/session/history-window.ts b/packages/app/src/pages/session/history-window.ts new file mode 100644 index 0000000000..e3ef20f13d --- /dev/null +++ b/packages/app/src/pages/session/history-window.ts @@ -0,0 +1,273 @@ +import type { UserMessage } from "@opencode-ai/sdk/v2" +import { createEffect, createMemo, on } from "solid-js" +import { createStore } from "solid-js/store" +import { same } from "@/utils/same" + +export const emptyUserMessages: UserMessage[] = [] + +export type SessionHistoryWindowInput = { + sessionID: () => string | undefined + messagesReady: () => boolean + visibleUserMessages: () => UserMessage[] + historyMore: () => boolean + historyLoading: () => boolean + loadMore: (sessionID: string) => Promise + userScrolled: () => boolean + scroller: () => HTMLDivElement | undefined +} + +type Snap = { + top: number + height: number + gap: number + max: number +} + +export const historyLoadMode = (input: { start: number; more: boolean; loading: boolean }) => { + if (input.start > 0) return "reveal" + if (!input.more || input.loading) return "noop" + return "fetch" +} + +export const historyRevealTop = ( + mark: { top: number; height: number; gap: number; max: number }, + next: { clientHeight: number; height: number }, + threshold = 16, +) => { + const delta = next.height - mark.height + if (delta <= 0) return mark.top + if (mark.max <= 0) return mark.top + if (mark.gap > threshold) return mark.top + + const max = next.height - next.clientHeight + if (max <= 0) return 0 + return Math.max(-max, Math.min(0, mark.top - delta)) +} + +const snap = (el: HTMLDivElement | undefined): Snap | undefined => { + if (!el) return + const max = el.scrollHeight - el.clientHeight + return { + top: el.scrollTop, + height: el.scrollHeight, + gap: max + el.scrollTop, + max, + } +} + +const clamp = (el: HTMLDivElement, top: number) => { + const max = el.scrollHeight - el.clientHeight + if (max <= 0) return 0 + return Math.max(-max, Math.min(0, top)) +} + +const revealThreshold = 16 + +const reveal = (input: SessionHistoryWindowInput, mark: Snap | undefined) => { + const el = input.scroller() + if (!el || !mark) return + el.scrollTop = clamp( + el, + historyRevealTop(mark, { clientHeight: el.clientHeight, height: el.scrollHeight }, revealThreshold), + ) +} + +const preserve = (input: SessionHistoryWindowInput, fn: () => void) => { + const el = input.scroller() + if (!el) { + fn() + return + } + const top = el.scrollTop + fn() + el.scrollTop = top +} + +/** + * Maintains the rendered history window for a session timeline. + * + * It keeps initial paint bounded to recent turns, reveals cached turns in + * small batches while scrolling upward, and prefetches older history near top. + */ +export function createSessionHistoryWindow(input: SessionHistoryWindowInput) { + const turnInit = 10 + const turnBatch = 8 + const turnScrollThreshold = 200 + const turnPrefetchBuffer = 16 + const prefetchCooldownMs = 400 + const prefetchNoGrowthLimit = 2 + + const [state, setState] = createStore({ + turnID: undefined as string | undefined, + turnStart: 0, + prefetchUntil: 0, + prefetchNoGrowth: 0, + }) + + const initialTurnStart = (len: number) => (len > turnInit ? len - turnInit : 0) + + const turnStart = createMemo(() => { + const id = input.sessionID() + const len = input.visibleUserMessages().length + if (!id || len <= 0) return 0 + if (state.turnID !== id) return initialTurnStart(len) + if (state.turnStart <= 0) return 0 + if (state.turnStart >= len) return initialTurnStart(len) + return state.turnStart + }) + + const setTurnStart = (start: number) => { + const id = input.sessionID() + const next = start > 0 ? start : 0 + if (!id) { + setState({ turnID: undefined, turnStart: next }) + return + } + setState({ turnID: id, turnStart: next }) + } + + const renderedUserMessages = createMemo( + () => { + const msgs = input.visibleUserMessages() + const start = turnStart() + if (start <= 0) return msgs + return msgs.slice(start) + }, + emptyUserMessages, + { + equals: same, + }, + ) + + const backfillTurns = () => { + const start = turnStart() + if (start <= 0) return + + const next = start - turnBatch + const nextStart = next > 0 ? next : 0 + + preserve(input, () => setTurnStart(nextStart)) + } + + /** Button path: reveal cached turns first, then fetch older history. */ + const loadAndReveal = async () => { + const id = input.sessionID() + if (!id) return + + const start = turnStart() + const mode = historyLoadMode({ + start, + more: input.historyMore(), + loading: input.historyLoading(), + }) + + if (mode === "reveal") { + const mark = snap(input.scroller()) + setTurnStart(0) + reveal(input, mark) + return + } + + if (mode === "noop") return + + const beforeVisible = input.visibleUserMessages().length + const mark = snap(input.scroller()) + + await input.loadMore(id) + if (input.sessionID() !== id) return + + const afterVisible = input.visibleUserMessages().length + const growth = afterVisible - beforeVisible + if (growth <= 0) return + if (state.prefetchNoGrowth) setState("prefetchNoGrowth", 0) + + reveal(input, mark) + } + + /** Scroll/prefetch path: fetch older history from server. */ + const fetchOlderMessages = async (opts?: { prefetch?: boolean }) => { + const id = input.sessionID() + if (!id) return + if (!input.historyMore() || input.historyLoading()) return + + if (opts?.prefetch) { + const now = Date.now() + if (state.prefetchUntil > now) return + if (state.prefetchNoGrowth >= prefetchNoGrowthLimit) return + setState("prefetchUntil", now + prefetchCooldownMs) + } + + const start = turnStart() + const beforeVisible = input.visibleUserMessages().length + const beforeRendered = start <= 0 ? beforeVisible : renderedUserMessages().length + + await input.loadMore(id) + if (input.sessionID() !== id) return + + const afterVisible = input.visibleUserMessages().length + const growth = afterVisible - beforeVisible + + if (opts?.prefetch) { + setState("prefetchNoGrowth", growth > 0 ? 0 : state.prefetchNoGrowth + 1) + } else if (growth > 0 && state.prefetchNoGrowth) { + setState("prefetchNoGrowth", 0) + } + + if (growth <= 0) return + if (turnStart() !== start) return + + const revealMore = !opts?.prefetch + const currentRendered = renderedUserMessages().length + const base = Math.max(beforeRendered, currentRendered) + const target = revealMore ? Math.min(afterVisible, base + turnBatch) : base + const nextStart = Math.max(0, afterVisible - target) + preserve(input, () => setTurnStart(nextStart)) + } + + const onScrollerScroll = () => { + if (!input.userScrolled()) return + const el = input.scroller() + if (!el) return + if (el.scrollHeight - el.clientHeight + el.scrollTop >= turnScrollThreshold) return + + const start = turnStart() + if (start > 0) { + if (start <= turnPrefetchBuffer) { + void fetchOlderMessages({ prefetch: true }) + } + backfillTurns() + return + } + + void fetchOlderMessages() + } + + createEffect( + on( + input.sessionID, + () => { + setState({ prefetchUntil: 0, prefetchNoGrowth: 0 }) + }, + { defer: true }, + ), + ) + + createEffect( + on( + () => [input.sessionID(), input.messagesReady()] as const, + ([id, ready]) => { + if (!id || !ready) return + setTurnStart(initialTurnStart(input.visibleUserMessages().length)) + }, + { defer: true }, + ), + ) + + return { + turnStart, + setTurnStart, + renderedUserMessages, + loadAndReveal, + onScrollerScroll, + } +} 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..32412f0a7f --- /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 { useReducedMotion } from "@opencode-ai/ui/hooks" +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 { 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 = useReducedMotion() + + 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..8fd652e903 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,143 @@ 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.trim(id)} + onCleanup={terminal.update} + onConnectError={() => 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..62474711f0 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.22", "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..b8cf081047 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.22", "private": true, "type": "module", "license": "MIT", diff --git a/packages/console/function/package.json b/packages/console/function/package.json index e771aae844..ed4cfed17a 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.22", "$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..c41e66c051 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.22", "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..2b9ce92da4 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.22", "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..8663cc8d58 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.22", "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..9807922a2c 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.22", "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}>
    -
    + } + /> + ) +} + +export function ContextToolExpandedList(props: { parts: ToolPart[]; expanded: boolean }) { + let contentRef: HTMLDivElement | undefined + let bodyRef: HTMLDivElement | undefined + let scrollRef: HTMLDivElement | undefined + const updateMask = () => { + if (scrollRef) updateScrollMask(scrollRef) + } + + useCollapsible({ + content: () => contentRef, + body: () => bodyRef, + open: () => props.expanded, + onOpen: updateMask, + }) + + return ( +
    +
    +
    + + {(part) => { + const label = createMemo(() => contextToolLabel(part)) + return ( +
    + {label().action} + {label().detail} +
    + ) + }} +
    +
    +
    +
    + ) +} + +export function ContextToolRollingResults(props: { parts: ToolPart[]; pending: boolean }) { + const reduce = useReducedMotion() + const wiped = new Set() + const [mounted, setMounted] = createSignal(false) + onMount(() => setMounted(true)) + const show = () => mounted() && props.pending + const opacity = useSpring(() => (show() ? 1 : 0), GROW_SPRING) + const blur = useSpring(() => (show() ? 0 : 2), GROW_SPRING) + return ( +
    + part.callID || part.id} + render={(part) => { + const label = createMemo(() => contextToolLabel(part)) + const k = part.callID || part.id + return ( +
    + {label().action} + {(() => { + const [detailRef, setDetailRef] = createSignal() + useRowWipe({ + id: () => k, + text: () => label().detail, + ref: detailRef, + seen: wiped, + }) + return ( + + {label().detail} + + ) + })()} +
    + ) + }} + /> +
    + ) +} diff --git a/packages/ui/src/components/grow-box.tsx b/packages/ui/src/components/grow-box.tsx new file mode 100644 index 0000000000..c8ea6f3b3a --- /dev/null +++ b/packages/ui/src/components/grow-box.tsx @@ -0,0 +1,432 @@ +import { createEffect, on, type JSX, onMount, onCleanup } from "solid-js" +import { useReducedMotion } from "../hooks/use-reduced-motion" +import { animate, tunableSpringValue, type AnimationPlaybackControls, GROW_SPRING, type SpringConfig } from "./motion" + +export interface GrowBoxProps { + children: JSX.Element + /** Enable animation. When false, content shows immediately at full height. */ + animate?: boolean + /** Animate height from 0 to content height. Default: true. */ + grow?: boolean + /** Keep watching body size and animate subsequent height changes. Default: false. */ + watch?: boolean + /** Fade in body content (opacity + blur). Default: true. */ + fade?: boolean + /** Top padding in px on the body wrapper. Default: 0. */ + gap?: number + /** Reset to height:auto after grow completes, or stay at fixed px. Default: true. */ + autoHeight?: boolean + /** Controlled visibility for animating open/close without unmounting children. */ + open?: boolean + /** Animate controlled open/close changes after mount. Default: true. */ + animateToggle?: boolean + /** data-slot attribute on the root div. */ + slot?: string + /** CSS class on the root div. */ + class?: string + /** Override mount and resize spring config. Default: GROW_SPRING. */ + spring?: SpringConfig + /** Override controlled open/close spring config. Default: spring. */ + toggleSpring?: SpringConfig + /** Show a temporary bottom edge fade while height animation is running. */ + edge?: boolean + /** Edge fade height in px. Default: 20. */ + edgeHeight?: number + /** Edge fade opacity (0-1). Default: 1. */ + edgeOpacity?: number + /** Delay before edge fades out after height settles. Default: 320. */ + edgeIdle?: number + /** Edge fade-out duration in seconds. Default: 0.24. */ + edgeFade?: number + /** Edge fade-in duration in seconds. Default: 0.2. */ + edgeRise?: number +} + +/** + * Wraps children in a container that animates from zero height on mount. + * + * Includes a ResizeObserver so content changes after mount are also spring-animated. + * Used for timeline turns, assistant part groups, and user messages. + */ +export function GrowBox(props: GrowBoxProps) { + const reduce = useReducedMotion() + const spring = () => props.spring ?? GROW_SPRING + const toggleSpring = () => props.toggleSpring ?? spring() + let mode: "mount" | "toggle" = "mount" + let root: HTMLDivElement | undefined + let body: HTMLDivElement | undefined + let fadeAnim: AnimationPlaybackControls | undefined + let edgeRef: HTMLDivElement | undefined + let edgeAnim: AnimationPlaybackControls | undefined + let edgeTimer: ReturnType | undefined + let edgeOn = false + let mountFrame: number | undefined + let resizeFrame: number | undefined + let observer: ResizeObserver | undefined + let springTarget = -1 + const height = tunableSpringValue(0, { + type: "spring", + get visualDuration() { + return (mode === "toggle" ? toggleSpring() : spring()).visualDuration + }, + get bounce() { + return (mode === "toggle" ? toggleSpring() : spring()).bounce + }, + }) + + const gap = () => Math.max(0, props.gap ?? 0) + const grow = () => props.grow !== false + const watch = () => props.watch === true + const open = () => props.open !== false + const animateToggle = () => props.animateToggle !== false + const edge = () => props.edge === true + const edgeHeight = () => Math.max(0, props.edgeHeight ?? 20) + const edgeOpacity = () => Math.min(1, Math.max(0, props.edgeOpacity ?? 1)) + const edgeIdle = () => Math.max(0, props.edgeIdle ?? 320) + const edgeFade = () => Math.max(0.05, props.edgeFade ?? 0.24) + const edgeRise = () => Math.max(0.05, props.edgeRise ?? 0.2) + const animated = () => props.animate !== false && !reduce() + const edgeReady = () => animated() && open() && edge() && edgeHeight() > 0 + + const stopEdgeTimer = () => { + if (edgeTimer === undefined) return + clearTimeout(edgeTimer) + edgeTimer = undefined + } + + const hideEdge = (instant = false) => { + stopEdgeTimer() + if (!edgeRef) { + edgeOn = false + return + } + edgeAnim?.stop() + edgeAnim = undefined + if (instant || reduce()) { + edgeRef.style.opacity = "0" + edgeOn = false + return + } + if (!edgeOn) { + edgeRef.style.opacity = "0" + return + } + const current = animate(edgeRef, { opacity: 0 }, { type: "spring", visualDuration: edgeFade(), bounce: 0 }) + edgeAnim = current + current.finished + .catch(() => {}) + .finally(() => { + if (edgeAnim !== current) return + edgeAnim = undefined + if (!edgeRef) return + edgeRef.style.opacity = "0" + edgeOn = false + }) + } + + const showEdge = () => { + stopEdgeTimer() + if (!edgeRef) return + if (reduce()) { + edgeRef.style.opacity = `${edgeOpacity()}` + edgeOn = true + return + } + if (edgeOn && edgeAnim === undefined) { + edgeRef.style.opacity = `${edgeOpacity()}` + return + } + edgeAnim?.stop() + edgeAnim = undefined + if (!edgeOn) edgeRef.style.opacity = "0" + const current = animate( + edgeRef, + { opacity: edgeOpacity() }, + { type: "spring", visualDuration: edgeRise(), bounce: 0 }, + ) + edgeAnim = current + edgeOn = true + current.finished + .catch(() => {}) + .finally(() => { + if (edgeAnim !== current) return + edgeAnim = undefined + if (!edgeRef) return + edgeRef.style.opacity = `${edgeOpacity()}` + }) + } + + const queueEdgeHide = () => { + stopEdgeTimer() + if (!edgeOn) return + if (edgeIdle() <= 0) { + hideEdge() + return + } + edgeTimer = setTimeout(() => { + edgeTimer = undefined + hideEdge() + }, edgeIdle()) + } + + const hideBody = () => { + if (!body) return + body.style.opacity = "0" + body.style.filter = "blur(2px)" + } + + const clearBody = () => { + if (!body) return + body.style.opacity = "" + body.style.filter = "" + } + + const fadeBodyIn = (nextMode: "mount" | "toggle" = "mount") => { + if (props.fade === false || !body) return + if (reduce()) { + clearBody() + return + } + hideBody() + fadeAnim?.stop() + fadeAnim = animate(body, { opacity: 1, filter: "blur(0px)" }, nextMode === "toggle" ? toggleSpring() : spring()) + fadeAnim.finished.then(() => { + if (!body || !open()) return + clearBody() + }) + } + + const setInstant = (visible: boolean) => { + const next = visible ? targetHeight() : 0 + springTarget = next + height.jump(next) + root!.style.height = visible ? "" : "0px" + root!.style.overflow = visible ? "" : "clip" + hideEdge(true) + if (visible || props.fade === false) clearBody() + else hideBody() + } + + const currentHeight = () => { + if (!root) return 0 + const v = root.style.height + if (v && v !== "auto") { + const n = Number.parseFloat(v) + if (!Number.isNaN(n)) return n + } + return Math.max(0, root.getBoundingClientRect().height) + } + + const targetHeight = () => Math.max(0, Math.ceil(body?.getBoundingClientRect().height ?? 0)) + + const setHeight = (nextMode: "mount" | "toggle" = "mount") => { + if (!root || !open()) return + const next = targetHeight() + if (reduce()) { + springTarget = next + height.jump(next) + if (props.autoHeight === false || watch()) { + root.style.height = `${next}px` + root.style.overflow = next > 0 ? "visible" : "clip" + return + } + root.style.height = "auto" + root.style.overflow = next > 0 ? "visible" : "clip" + return + } + if (next === springTarget) return + const prev = currentHeight() + if (Math.abs(next - prev) < 1) { + springTarget = next + if (props.autoHeight === false || watch()) { + root.style.height = `${next}px` + root.style.overflow = next > 0 ? "visible" : "clip" + } + return + } + root.style.overflow = "clip" + springTarget = next + mode = nextMode + height.set(next) + } + + onMount(() => { + if (!root || !body) return + + const offChange = height.on("change", (next) => { + if (!root) return + root.style.height = `${Math.max(0, next)}px` + }) + const offStart = height.on("animationStart", () => { + if (!root) return + root.style.overflow = "clip" + root.style.willChange = "height" + root.style.contain = "layout style" + if (edgeReady()) showEdge() + }) + const offComplete = height.on("animationComplete", () => { + if (!root) return + root.style.willChange = "" + root.style.contain = "" + if (!open()) { + springTarget = 0 + root.style.height = "0px" + root.style.overflow = "clip" + return + } + const next = targetHeight() + springTarget = next + if (props.autoHeight === false || watch()) { + root.style.height = `${next}px` + root.style.overflow = next > 0 ? "visible" : "clip" + if (edgeReady()) queueEdgeHide() + return + } + root.style.height = "auto" + root.style.overflow = "visible" + if (edgeReady()) queueEdgeHide() + }) + + onCleanup(() => { + offComplete() + offStart() + offChange() + }) + + if (watch()) { + observer = new ResizeObserver(() => { + if (!open()) return + if (resizeFrame !== undefined) return + resizeFrame = requestAnimationFrame(() => { + resizeFrame = undefined + setHeight("mount") + }) + }) + observer.observe(body) + } + + if (!animated()) { + setInstant(open()) + return + } + + if (props.fade !== false) hideBody() + hideEdge(true) + + if (!open()) { + root.style.height = "0px" + root.style.overflow = "clip" + } else { + if (grow()) { + root.style.height = "0px" + root.style.overflow = "clip" + } else { + root.style.height = "auto" + root.style.overflow = "visible" + } + mountFrame = requestAnimationFrame(() => { + mountFrame = undefined + fadeBodyIn("mount") + if (grow()) setHeight("mount") + }) + } + }) + + createEffect( + on( + () => props.open, + (value) => { + if (value === undefined) return + if (!root || !body) return + if (!animateToggle() || reduce()) { + setInstant(value) + return + } + fadeAnim?.stop() + if (!value) hideEdge(true) + if (!value) { + const next = currentHeight() + if (Math.abs(next - height.get()) >= 1) { + springTarget = next + height.jump(next) + root.style.height = `${next}px` + } + if (props.fade !== false) { + fadeAnim = animate(body, { opacity: 0, filter: "blur(2px)" }, toggleSpring()) + } + root.style.overflow = "clip" + springTarget = 0 + mode = "toggle" + height.set(0) + return + } + fadeBodyIn("toggle") + setHeight("toggle") + }, + { defer: true }, + ), + ) + + createEffect(() => { + if (!edgeRef) return + edgeRef.style.height = `${edgeHeight()}px` + if (!animated() || !open() || edgeHeight() <= 0) { + hideEdge(true) + return + } + if (edge()) return + hideEdge() + }) + + createEffect(() => { + if (!root || !body) return + if (!reduce()) return + fadeAnim?.stop() + edgeAnim?.stop() + setInstant(open()) + }) + + onCleanup(() => { + stopEdgeTimer() + if (mountFrame !== undefined) cancelAnimationFrame(mountFrame) + if (resizeFrame !== undefined) cancelAnimationFrame(resizeFrame) + observer?.disconnect() + height.destroy() + fadeAnim?.stop() + edgeAnim?.stop() + edgeAnim = undefined + edgeOn = false + }) + + return ( +
    +
    0 ? `${gap()}px` : undefined }}> + {props.children} +
    +
    +
    + ) +} diff --git a/packages/ui/src/components/line-comment.tsx b/packages/ui/src/components/line-comment.tsx index 6a247990b3..73d83f7d72 100644 --- a/packages/ui/src/components/line-comment.tsx +++ b/packages/ui/src/components/line-comment.tsx @@ -240,6 +240,7 @@ export const LineCommentEditor = (props: LineCommentEditorProps) => { }} on:keydown={(e) => { const event = e as KeyboardEvent + if (event.isComposing || event.keyCode === 229) return event.stopPropagation() if (e.key === "Escape") { event.preventDefault() diff --git a/packages/ui/src/components/markdown.tsx b/packages/ui/src/components/markdown.tsx index bb41c74efb..01254f1189 100644 --- a/packages/ui/src/components/markdown.tsx +++ b/packages/ui/src/components/markdown.tsx @@ -44,6 +44,19 @@ function sanitize(html: string) { return DOMPurify.sanitize(html, config) } +function escape(text: string) { + return text + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/\"/g, """) + .replace(/'/g, "'") +} + +function fallback(markdown: string) { + return escape(markdown).replace(/\r\n?/g, "\n").replace(/\n/g, "
    ") +} + type CopyLabels = { copy: string copied: string @@ -237,7 +250,7 @@ export function Markdown( const [html] = createResource( () => local.text, async (markdown) => { - if (isServer) return "" + if (isServer) return fallback(markdown) const hash = checksum(markdown) const key = local.cacheKey ?? hash @@ -255,7 +268,7 @@ export function Markdown( if (key && hash) touch(key, { hash, html: safe }) return safe }, - { initialValue: "" }, + { initialValue: isServer ? fallback(local.text) : "" }, ) let copySetupTimer: ReturnType | undefined diff --git a/packages/ui/src/components/message-part.css b/packages/ui/src/components/message-part.css index 8fc7090133..9a6784d702 100644 --- a/packages/ui/src/components/message-part.css +++ b/packages/ui/src/components/message-part.css @@ -1,10 +1,20 @@ [data-component="assistant-message"] { content-visibility: auto; width: 100%; +} + +[data-component="assistant-parts"] { + width: 100%; + min-width: 0; display: flex; flex-direction: column; align-items: flex-start; - gap: 12px; + gap: 0; +} + +[data-component="assistant-part-item"] { + width: 100%; + min-width: 0; } [data-component="user-message"] { @@ -27,6 +37,14 @@ color: var(--text-weak); } + [data-slot="user-message-inner"] { + position: relative; + display: flex; + flex-direction: column; + align-items: flex-end; + width: 100%; + gap: 4px; + } [data-slot="user-message-attachments"] { display: flex; flex-wrap: wrap; @@ -35,6 +53,7 @@ width: fit-content; max-width: min(82%, 64ch); margin-left: auto; + margin-bottom: 4px; } [data-slot="user-message-attachment"] { @@ -134,7 +153,7 @@ [data-slot="user-message-copy-wrapper"] { min-height: 24px; - margin-top: 4px; + margin-top: 0; display: flex; align-items: center; justify-content: flex-end; @@ -144,7 +163,6 @@ pointer-events: none; transition: opacity 0.15s ease; will-change: opacity; - [data-component="tooltip-trigger"] { display: inline-flex; width: fit-content; @@ -187,56 +205,21 @@ opacity: 1; pointer-events: auto; } - - .text-text-strong { - color: var(--text-strong); - } - - .font-medium { - font-weight: var(--font-weight-medium); - } } [data-component="text-part"] { width: 100%; - margin-top: 24px; + margin-top: 0; + padding-block: 4px; + position: relative; [data-slot="text-part-body"] { margin-top: 0; } - [data-slot="text-part-copy-wrapper"] { - min-height: 24px; - margin-top: 4px; - display: flex; - align-items: center; - justify-content: flex-start; - gap: 10px; - opacity: 0; - pointer-events: none; - transition: opacity 0.15s ease; - will-change: opacity; - - [data-component="tooltip-trigger"] { - display: inline-flex; - width: fit-content; - } - } - - [data-slot="text-part-meta"] { - user-select: none; - } - - [data-slot="text-part-copy-wrapper"][data-interrupted] { + [data-slot="text-part-turn-summary"] { width: 100%; - justify-content: flex-end; - gap: 12px; - } - - &:hover [data-slot="text-part-copy-wrapper"], - &:focus-within [data-slot="text-part-copy-wrapper"] { - opacity: 1; - pointer-events: auto; + min-width: 0; } [data-component="markdown"] { @@ -245,6 +228,10 @@ } } +[data-component="assistant-part-item"][data-kind="text"][data-last="true"] [data-component="text-part"] { + padding-bottom: 0; +} + [data-component="compaction-part"] { width: 100%; display: flex; @@ -278,7 +265,6 @@ line-height: var(--line-height-normal); [data-component="markdown"] { - margin-top: 24px; font-style: normal; font-size: inherit; color: var(--text-weak); @@ -372,13 +358,16 @@ height: auto; max-height: 240px; overflow-y: auto; + overscroll-behavior: contain; scrollbar-width: none; -ms-overflow-style: none; - + -webkit-mask-image: linear-gradient(to bottom, transparent 0, black 6px, black calc(100% - 6px), transparent 100%); + mask-image: linear-gradient(to bottom, transparent 0, black 6px, black calc(100% - 6px), transparent 100%); + -webkit-mask-repeat: no-repeat; + mask-repeat: no-repeat; &::-webkit-scrollbar { display: none; } - [data-component="markdown"] { overflow: visible; } @@ -448,7 +437,7 @@ [data-component="write-trigger"] { display: flex; align-items: center; - justify-content: space-between; + justify-content: flex-start; gap: 8px; width: 100%; @@ -461,7 +450,8 @@ } [data-slot="message-part-title"] { - flex-shrink: 0; + flex-shrink: 1; + min-width: 0; display: flex; align-items: center; gap: 8px; @@ -493,40 +483,45 @@ [data-slot="message-part-title-text"] { text-transform: capitalize; color: var(--text-strong); + flex-shrink: 0; + } + + [data-slot="message-part-meta-line"], + .message-part-meta-line { + min-width: 0; + display: inline-flex; + align-items: center; + gap: 6px; + font-weight: var(--font-weight-regular); + + [data-component="diff-changes"] { + flex-shrink: 0; + gap: 6px; + } + } + + .message-part-meta-line.soft { + [data-slot="message-part-title-filename"] { + color: var(--text-base); + } } [data-slot="message-part-title-filename"] { /* No text-transform - preserve original filename casing */ - font-weight: var(--font-weight-regular); + color: var(--text-strong); + flex-shrink: 0; } - [data-slot="message-part-path"] { - display: flex; - flex-grow: 1; - min-width: 0; - font-weight: var(--font-weight-regular); - } - - [data-slot="message-part-directory"] { + [data-slot="message-part-directory-inline"] { color: var(--text-weak); + min-width: 0; + max-width: min(48vw, 36ch); text-overflow: ellipsis; overflow: hidden; white-space: nowrap; direction: rtl; text-align: left; } - - [data-slot="message-part-filename"] { - color: var(--text-strong); - flex-shrink: 0; - } - - [data-slot="message-part-actions"] { - display: flex; - gap: 16px; - align-items: center; - justify-content: flex-end; - } } [data-component="edit-content"] { @@ -617,6 +612,17 @@ } } +[data-slot="webfetch-meta"] { + min-width: 0; + display: inline-flex; + align-items: center; + gap: 8px; + + [data-component="tool-action"] { + flex-shrink: 0; + } +} + [data-component="todos"] { padding: 10px 0 24px 0; display: flex; @@ -639,7 +645,6 @@ } [data-component="context-tool-group-trigger"] { - width: 100%; min-height: 24px; display: flex; align-items: center; @@ -647,28 +652,352 @@ gap: 0px; cursor: pointer; + &[data-pending] { + cursor: default; + } + [data-slot="context-tool-group-title"] { flex-shrink: 1; min-width: 0; } +} - [data-slot="collapsible-arrow"] { - color: var(--icon-weaker); +/* Prevent the trigger content from stretching full-width so the arrow sits after the text */ +[data-slot="basic-tool-tool-trigger-content"]:has([data-component="context-tool-group-trigger"]) { + width: auto; + flex: 0 1 auto; + + [data-slot="basic-tool-tool-info"] { + flex: 0 1 auto; } } -[data-component="context-tool-group-list"] { - padding: 6px 0 4px 0; +[data-component="context-tool-step"] { + width: 100%; + min-width: 0; + padding-left: 12px; +} + +[data-component="context-tool-expanded-list"] { display: flex; flex-direction: column; - gap: 2px; + padding: 4px 0 4px 12px; + max-height: 200px; + overflow-y: auto; + overscroll-behavior: contain; + scrollbar-width: none; + -ms-overflow-style: none; + -webkit-mask-repeat: no-repeat; + mask-repeat: no-repeat; - [data-slot="context-tool-group-item"] { - min-width: 0; - padding: 6px 0; + &::-webkit-scrollbar { + display: none; } } +[data-component="context-tool-expanded-row"] { + display: flex; + align-items: center; + gap: 6px; + min-width: 0; + height: 22px; + flex-shrink: 0; + white-space: nowrap; + overflow: hidden; + + [data-slot="context-tool-expanded-action"] { + flex-shrink: 0; + font-size: var(--font-size-base); + font-weight: 500; + color: var(--text-base); + } + + [data-slot="context-tool-expanded-detail"] { + flex-shrink: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + font-size: var(--font-size-base); + color: var(--text-base); + opacity: 0.75; + } +} + +[data-component="context-tool-rolling-row"] { + display: inline-flex; + align-items: center; + gap: 6px; + width: 100%; + min-width: 0; + white-space: nowrap; + overflow: hidden; + padding-left: 12px; + + [data-slot="context-tool-rolling-action"] { + flex-shrink: 0; + font-size: var(--font-size-base); + font-weight: 500; + color: var(--text-base); + } + + [data-slot="context-tool-rolling-detail"] { + flex-shrink: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + font-size: var(--font-size-base); + color: var(--text-weak); + } +} + +[data-component="shell-rolling-results"] { + width: 100%; + min-width: 0; + display: flex; + flex-direction: column; + + [data-slot="shell-rolling-header-clip"] { + &:hover [data-slot="shell-rolling-actions"] { + opacity: 1; + } + + &[data-clickable="true"] { + cursor: pointer; + } + } + + [data-slot="shell-rolling-header"] { + display: inline-flex; + align-items: center; + gap: 8px; + min-width: 0; + max-width: 100%; + height: 37px; + box-sizing: border-box; + } + + [data-slot="shell-rolling-title"] { + flex-shrink: 0; + font-family: var(--font-family-sans); + font-size: 14px; + font-style: normal; + font-weight: var(--font-weight-medium); + line-height: var(--line-height-large); + letter-spacing: var(--letter-spacing-normal); + color: var(--text-strong); + } + + [data-slot="shell-rolling-subtitle"] { + flex: 0 1 auto; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-family: var(--font-family-sans); + font-size: 14px; + font-weight: var(--font-weight-normal); + line-height: var(--line-height-large); + color: var(--text-weak); + } + + [data-slot="shell-rolling-actions"] { + flex-shrink: 0; + display: inline-flex; + align-items: center; + gap: 2px; + opacity: 0; + transition: opacity 0.15s ease; + } + + .shell-rolling-copy { + border: none !important; + outline: none !important; + box-shadow: none !important; + background: transparent !important; + + [data-slot="icon-svg"] { + color: var(--icon-weaker); + } + + &:hover:not(:disabled) { + background: color-mix(in srgb, var(--text-base) 8%, transparent) !important; + box-shadow: 0 0 0 1px color-mix(in srgb, var(--icon-weaker) 40%, transparent) !important; + border-radius: var(--radius-sm); + + [data-slot="icon-svg"] { + color: var(--icon-base); + } + } + } + + [data-slot="shell-rolling-arrow"] { + display: inline-flex; + align-items: center; + justify-content: center; + color: var(--icon-weaker); + transform: rotate(-90deg); + transition: transform 0.15s ease; + } + + [data-slot="shell-rolling-arrow"][data-open="true"] { + transform: rotate(0deg); + } +} + +[data-component="shell-rolling-output"] { + width: 100%; + min-width: 0; +} + +[data-slot="shell-rolling-preview"] { + width: 100%; + min-width: 0; +} + +[data-component="shell-expanded-output"] { + width: 100%; + max-width: 100%; + overflow-y: auto; + overflow-x: hidden; + scrollbar-width: none; + -ms-overflow-style: none; + + &::-webkit-scrollbar { + display: none; + } +} + +[data-component="shell-expanded-shell"] { + position: relative; + width: 100%; + min-width: 0; + border: 1px solid var(--border-weak-base); + border-radius: 6px; + background: transparent; + overflow: hidden; +} + +[data-slot="shell-expanded-body"] { + position: relative; + width: 100%; + min-width: 0; +} + +[data-slot="shell-expanded-top"] { + position: relative; + width: 100%; + min-width: 0; + padding: 9px 44px 9px 16px; + box-sizing: border-box; +} + +[data-slot="shell-expanded-command"] { + display: flex; + align-items: flex-start; + gap: 8px; + width: 100%; + min-width: 0; + font-family: var(--font-family-mono); + font-feature-settings: var(--font-family-mono--font-feature-settings); + font-size: 13px; + line-height: 1.45; +} + +[data-slot="shell-expanded-prompt"] { + flex-shrink: 0; + color: var(--text-weaker); +} + +[data-slot="shell-expanded-input"] { + min-width: 0; + color: var(--text-strong); + white-space: pre-wrap; + overflow-wrap: anywhere; +} + +[data-slot="shell-expanded-actions"] { + position: absolute; + top: 50%; + right: 8px; + z-index: 1; + transform: translateY(-50%); +} + +.shell-expanded-copy { + border: none !important; + outline: none !important; + box-shadow: none !important; + background: transparent !important; + + [data-slot="icon-svg"] { + color: var(--icon-weaker); + } + + &:hover:not(:disabled) { + background: color-mix(in srgb, var(--text-base) 8%, transparent) !important; + box-shadow: 0 0 0 1px color-mix(in srgb, var(--icon-weaker) 40%, transparent) !important; + border-radius: var(--radius-sm); + + [data-slot="icon-svg"] { + color: var(--icon-base); + } + } +} + +[data-slot="shell-expanded-divider"] { + width: 100%; + height: 1px; + background: var(--border-weak-base); +} + +[data-slot="shell-expanded-pre"] { + margin: 0; + padding: 12px 16px; + white-space: pre-wrap; + overflow-wrap: anywhere; + + code { + font-family: var(--font-family-mono); + font-feature-settings: var(--font-family-mono--font-feature-settings); + font-size: 13px; + line-height: 1.45; + color: var(--text-base); + } +} + +[data-component="shell-rolling-command"], +[data-component="shell-rolling-row"] { + display: inline-flex; + align-items: center; + width: 100%; + min-width: 0; + overflow: hidden; + white-space: pre; + padding-left: 12px; +} + +[data-slot="shell-rolling-text"] { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + font-family: var(--font-family-mono); + font-feature-settings: var(--font-family-mono--font-feature-settings); + font-size: var(--font-size-small); + line-height: var(--line-height-large); +} + +[data-component="shell-rolling-command"] [data-slot="shell-rolling-text"] { + color: var(--text-base); +} + +[data-component="shell-rolling-command"] [data-slot="shell-rolling-prompt"] { + color: var(--text-weaker); +} + +[data-component="shell-rolling-row"] [data-slot="shell-rolling-text"] { + color: var(--text-weak); +} + [data-component="diagnostics"] { display: flex; flex-direction: column; @@ -729,6 +1058,30 @@ width: 100%; } +[data-slot="assistant-part-grow"] { + width: 100%; + min-width: 0; + overflow: visible; +} + +[data-component="tool-part-wrapper"][data-tool="bash"] { + [data-component="tool-trigger"] { + width: auto; + max-width: 100%; + } + + [data-slot="basic-tool-tool-info-main"] { + align-items: center; + } + + [data-slot="basic-tool-tool-title"], + [data-slot="basic-tool-tool-subtitle"] { + display: inline-flex; + align-items: center; + line-height: var(--line-height-large); + } +} + [data-component="dock-prompt"][data-kind="permission"] { position: relative; display: flex; @@ -1187,8 +1540,7 @@ position: sticky; top: var(--sticky-accordion-top, 0px); z-index: 20; - height: 40px; - padding-bottom: 8px; + height: 37px; background-color: var(--background-stronger); } } @@ -1199,11 +1551,12 @@ } [data-slot="apply-patch-trigger-content"] { - display: flex; + display: inline-flex; align-items: center; - justify-content: space-between; - width: 100%; - gap: 20px; + justify-content: flex-start; + max-width: 100%; + min-width: 0; + gap: 8px; } [data-slot="apply-patch-file-info"] { @@ -1237,9 +1590,9 @@ [data-slot="apply-patch-trigger-actions"] { flex-shrink: 0; display: flex; - gap: 16px; + gap: 8px; align-items: center; - justify-content: flex-end; + justify-content: flex-start; } [data-slot="apply-patch-change"] { @@ -1279,10 +1632,11 @@ } [data-component="tool-loaded-file"] { + min-width: 0; display: flex; align-items: center; gap: 8px; - padding: 4px 0 4px 28px; + padding: 4px 0 4px 12px; font-family: var(--font-family-sans); font-size: var(--font-size-small); font-weight: var(--font-weight-regular); @@ -1293,4 +1647,11 @@ flex-shrink: 0; color: var(--icon-weak); } + + span { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } } diff --git a/packages/ui/src/components/message-part.tsx b/packages/ui/src/components/message-part.tsx index fbeb8bda28..d821211592 100644 --- a/packages/ui/src/components/message-part.tsx +++ b/packages/ui/src/components/message-part.tsx @@ -1,18 +1,6 @@ -import { - Component, - createEffect, - createMemo, - createSignal, - For, - Match, - onMount, - Show, - Switch, - onCleanup, - Index, - type JSX, -} from "solid-js" +import { Component, createEffect, createMemo, createSignal, For, Match, on, Show, Switch, type JSX } from "solid-js" import stripAnsi from "strip-ansi" +import { createStore } from "solid-js/store" import { Dynamic } from "solid-js/web" import { AgentPart, @@ -31,13 +19,11 @@ import { import { useData } from "../context" import { useFileComponent } from "../context/file" import { useDialog } from "../context/dialog" -import { useI18n } from "../context/i18n" -import { BasicTool } from "./basic-tool" -import { GenericTool } from "./basic-tool" +import { type UiI18n, useI18n } from "../context/i18n" +import { GenericTool, ToolCall } from "./basic-tool" import { Accordion } from "./accordion" import { StickyAccordionHeader } from "./sticky-accordion-header" import { Card } from "./card" -import { Collapsible } from "./collapsible" import { FileIcon } from "./file-icon" import { Icon } from "./icon" import { Checkbox } from "./checkbox" @@ -49,43 +35,12 @@ import { checksum } from "@opencode-ai/util/encode" import { Tooltip } from "./tooltip" import { IconButton } from "./icon-button" import { TextShimmer } from "./text-shimmer" -import { AnimatedCountList } from "./tool-count-summary" -import { ToolStatusTitle } from "./tool-status-title" -import { animate } from "motion" -import { useLocation } from "@solidjs/router" - -function ShellSubmessage(props: { text: string; animate?: boolean }) { - let widthRef: HTMLSpanElement | undefined - let valueRef: HTMLSpanElement | undefined - - onMount(() => { - if (!props.animate) return - requestAnimationFrame(() => { - if (widthRef) { - animate(widthRef, { width: "auto" }, { type: "spring", visualDuration: 0.25, bounce: 0 }) - } - if (valueRef) { - animate(valueRef, { opacity: 1, filter: "blur(0px)" }, { duration: 0.32, ease: [0.16, 1, 0.3, 1] }) - } - }) - }) - - return ( - - - - - {props.text} - - - - - ) -} +import { list } from "./text-utils" +import { GrowBox } from "./grow-box" +import { COLLAPSIBLE_SPRING } from "./motion" +import { busy, createThrottledValue, useToolFade, useContextToolPending } from "./tool-utils" +import { ContextToolGroupHeader, ContextToolExpandedList, ContextToolRollingResults } from "./context-tool-results" +import { ShellRollingResults } from "./shell-rolling-results" interface Diagnostic { range: { @@ -126,64 +81,22 @@ function DiagnosticsDisplay(props: { diagnostics: Diagnostic[] }): JSX.Element { ) } -export interface MessageProps { - message: MessageType - parts: PartType[] - showAssistantCopyPartID?: string | null - interrupted?: boolean - queued?: boolean - showReasoningSummaries?: boolean -} - export interface MessagePartProps { part: PartType message: MessageType hideDetails?: boolean defaultOpen?: boolean showAssistantCopyPartID?: string | null - turnDurationMs?: number + showTurnDiffSummary?: boolean + turnDiffSummary?: () => JSX.Element + animate?: boolean + working?: boolean } export type PartComponent = Component export const PART_MAPPING: Record = {} -const TEXT_RENDER_THROTTLE_MS = 100 - -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 -} - function relativizeProjectPath(path: string, directory?: string) { if (!path) return "" if (!directory) return path @@ -210,6 +123,11 @@ export type ToolInfo = { subtitle?: string } +function agentTitle(i18n: UiI18n, type?: string) { + if (!type) return i18n.t("ui.tool.agent.default") + return i18n.t("ui.tool.agent", { type }) +} + export function getToolInfo(tool: string, input: any = {}): ToolInfo { const i18n = useI18n() switch (tool) { @@ -255,12 +173,17 @@ export function getToolInfo(tool: string, input: any = {}): ToolInfo { title: i18n.t("ui.tool.codesearch"), subtitle: input.query, } - case "task": + case "task": { + const type = + typeof input.subagent_type === "string" && input.subagent_type + ? input.subagent_type[0]!.toUpperCase() + input.subagent_type.slice(1) + : undefined return { icon: "task", - title: i18n.t("ui.tool.agent", { type: input.subagent_type || "task" }), + title: agentTitle(i18n, type), subtitle: input.description, } + } case "bash": return { icon: "console", @@ -305,7 +228,8 @@ export function getToolInfo(tool: string, input: any = {}): ToolInfo { case "skill": return { icon: "brain", - title: input.name || "skill", + title: i18n.t("ui.tool.skill"), + subtitle: typeof input.name === "string" ? input.name : undefined, } default: return { @@ -330,105 +254,22 @@ function urls(text: string | undefined) { const CONTEXT_GROUP_TOOLS = new Set(["read", "glob", "grep", "list"]) const HIDDEN_TOOLS = new Set(["todowrite", "todoread"]) -function list(value: T[] | undefined | null, fallback: T[]) { - if (Array.isArray(value)) return value - return fallback -} - -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]) -} - -type PartRef = { - messageID: string - partID: string -} - -type PartGroup = - | { - key: string - type: "part" - ref: PartRef - } - | { - key: string - type: "context" - refs: PartRef[] - } - -function sameRef(a: PartRef, b: PartRef) { - return a.messageID === b.messageID && a.partID === b.partID -} - -function sameGroup(a: PartGroup, b: PartGroup) { - if (a === b) return true - if (a.key !== b.key) return false - if (a.type !== b.type) return false - if (a.type === "part") { - if (b.type !== "part") return false - return sameRef(a.ref, b.ref) +function createGroupOpenState() { + const [state, setState] = createStore>({}) + const read = (key?: string, collapse?: boolean) => { + if (!key) return true + const value = state[key] + if (value !== undefined) return value + return !collapse } - if (b.type !== "context") return false - if (a.refs.length !== b.refs.length) return false - return a.refs.every((ref, i) => sameRef(ref, b.refs[i]!)) -} - -function sameGroups(a: readonly PartGroup[] | undefined, b: readonly PartGroup[] | undefined) { - if (a === b) return true - if (!a || !b) return false - if (a.length !== b.length) return false - return a.every((item, i) => sameGroup(item, b[i]!)) -} - -function groupParts(parts: { messageID: string; part: PartType }[]) { - const result: PartGroup[] = [] - let start = -1 - - const flush = (end: number) => { - if (start < 0) return - const first = parts[start] - const last = parts[end] - if (!first || !last) { - start = -1 - return - } - result.push({ - key: `context:${first.part.id}`, - type: "context", - refs: parts.slice(start, end + 1).map((item) => ({ - messageID: item.messageID, - partID: item.part.id, - })), - }) - start = -1 + const controlled = (key?: string) => { + if (!key) return false + return state[key] !== undefined } - - parts.forEach((item, index) => { - if (isContextGroupTool(item.part)) { - if (start < 0) start = index - return - } - - flush(index - 1) - result.push({ - key: `part:${item.messageID}:${item.part.id}`, - type: "part", - ref: { - messageID: item.messageID, - partID: item.part.id, - }, - }) - }) - - flush(parts.length - 1) - return result -} - -function partByID(parts: readonly PartType[], partID: string) { - return parts.find((part) => part.id === partID) + const write = (key: string, value: boolean) => { + setState(key, value) + } + return { read, controlled, write } } function renderable(part: PartType, showReasoningSummaries = true) { @@ -444,7 +285,8 @@ function renderable(part: PartType, showReasoningSummaries = true) { function toolDefaultOpen(tool: string, shell = false, edit = false) { if (tool === "bash") return shell - if (tool === "edit" || tool === "write" || tool === "apply_patch") return edit + if (tool === "edit" || tool === "write") return edit + if (tool === "apply_patch") return false } function partDefaultOpen(part: PartType, shell = false, edit = false) { @@ -452,99 +294,323 @@ function partDefaultOpen(part: PartType, shell = false, edit = false) { return toolDefaultOpen(part.tool, shell, edit) } +function PartGrow(props: { + children: JSX.Element + animate?: boolean + animateToggle?: boolean + gap?: number + fade?: boolean + edge?: boolean + edgeHeight?: number + edgeOpacity?: number + edgeIdle?: number + edgeFade?: number + edgeRise?: number + grow?: boolean + watch?: boolean + open?: boolean + spring?: import("./motion").SpringConfig + toggleSpring?: import("./motion").SpringConfig +}) { + return ( + + {props.children} + + ) +} + export function AssistantParts(props: { messages: AssistantMessage[] showAssistantCopyPartID?: string | null - turnDurationMs?: number + showTurnDiffSummary?: boolean + turnDiffSummary?: () => JSX.Element working?: boolean showReasoningSummaries?: boolean shellToolDefaultOpen?: boolean editToolDefaultOpen?: boolean + animate?: boolean }) { const data = useData() const emptyParts: PartType[] = [] - const emptyTools: ToolPart[] = [] + const groupState = createGroupOpenState() + const grouped = createMemo(() => { + const keys: string[] = [] + const items: Record< + string, + | { + type: "part" + part: PartType + message: AssistantMessage + context?: boolean + groupKey?: string + afterTool?: boolean + groupTail?: boolean + groupParts?: { part: ToolPart; message: AssistantMessage }[] + } + | { + type: "context" + groupKey: string + parts: { part: ToolPart; message: AssistantMessage }[] + tail: boolean + afterTool: boolean + } + > = {} + const push = (key: string, item: (typeof items)[string]) => { + keys.push(key) + items[key] = item + } + const id = (part: PartType) => { + if (part.type === "tool") return part.callID || part.id + return part.id + } + const parts = props.messages.flatMap((message) => + list(data.store.part?.[message.id], emptyParts) + .filter((part) => renderable(part, props.showReasoningSummaries ?? true)) + .map((part) => ({ message, part })), + ) - const grouped = createMemo( - () => - groupParts( - props.messages.flatMap((message) => - list(data.store.part?.[message.id], emptyParts) - .filter((part) => renderable(part, props.showReasoningSummaries ?? true)) - .map((part) => ({ - messageID: message.id, - part, - })), - ), - ), - [] as PartGroup[], - { equals: sameGroups }, - ) + let start = -1 - const last = createMemo(() => grouped().at(-1)?.key) + const flush = (end: number, tail: boolean, afterTool: boolean) => { + if (start < 0) return + const group = parts + .slice(start, end + 1) + .filter((entry): entry is { part: ToolPart; message: AssistantMessage } => isContextGroupTool(entry.part)) + if (!group.length) { + start = -1 + return + } + const groupKey = `context:${group[0].message.id}:${id(group[0].part)}` + push(groupKey, { + type: "context", + groupKey, + parts: group, + tail, + afterTool, + }) + group.forEach((entry) => { + push(`part:${entry.message.id}:${id(entry.part)}`, { + type: "part", + part: entry.part, + message: entry.message, + context: true, + groupKey, + afterTool, + groupTail: tail, + groupParts: group, + }) + }) + start = -1 + } + parts.forEach((item, index) => { + if (isContextGroupTool(item.part)) { + if (start < 0) start = index + return + } + + flush(index - 1, false, (item as { part: PartType }).part.type === "tool") + push(`part:${item.message.id}:${id(item.part)}`, { type: "part", part: item.part, message: item.message }) + }) + + flush(parts.length - 1, true, false) + return { keys, items } + }) + + const last = createMemo(() => grouped().keys.at(-1)) return ( - - {(entryAccessor) => { - const entryType = createMemo(() => entryAccessor().type) +
    + + {(key) => { + const item = createMemo(() => grouped().items[key]) + const ctx = createMemo(() => { + const value = item() + if (!value) return + if (value.type !== "context") return + return value + }) + const part = createMemo(() => { + const value = item() + if (!value) return + if (value.type !== "part") return + return value + }) + const tail = createMemo(() => last() === key) + const tool = createMemo(() => { + const value = part() + if (!value) return false + return value.part.type === "tool" + }) + const context = createMemo(() => !!part()?.context) + const contextSpring = createMemo(() => { + const entry = part() + if (!entry?.context) return undefined + if (!groupState.controlled(entry.groupKey)) return undefined + return COLLAPSIBLE_SPRING + }) + const contextOpen = createMemo(() => { + const value = ctx() + if (value) return groupState.read(value.groupKey, true) + return groupState.read(part()?.groupKey, true) + }) + const visible = createMemo(() => { + if (!context()) return true + if (ctx()) return true + return false + }) - return ( - - - {(() => { - const parts = createMemo( - () => { - const entry = entryAccessor() as { type: "context"; refs: PartRef[] } - return entry.refs - .map((ref) => partByID(list(data.store.part?.[ref.messageID], emptyParts), ref.partID)) - .filter((part): part is ToolPart => !!part && isContextGroupTool(part)) - }, - emptyTools, - { equals: same }, - ) - const busy = createMemo(() => props.working && last() === entryAccessor().key) - - return ( - 0}> - - - ) - })()} - - - {(() => { - const message = createMemo(() => { - const entry = entryAccessor() as { type: "part"; ref: PartRef } - return props.messages.find((item) => item.id === entry.ref.messageID) - }) - const part = createMemo(() => { - const entry = entryAccessor() as { type: "part"; ref: PartRef } - return partByID(list(data.store.part?.[entry.ref.messageID], emptyParts), entry.ref.partID) - }) - - return ( - - {(msg) => ( - - {(p) => ( + const turnSummary = createMemo(() => { + const value = part() + if (!value) return false + if (value.part.type !== "text") return false + if (!props.showTurnDiffSummary) return false + return props.showAssistantCopyPartID === value.part.id + }) + const fade = createMemo(() => { + if (ctx()) return true + return tool() + }) + const edge = createMemo(() => { + const entry = part() + if (!entry) return false + if (entry.part.type !== "text") return false + if (!props.working) return false + return tail() + }) + const watch = createMemo(() => !context() && !tool() && tail() && !turnSummary()) + const ctxPartsCache = new Map() + let ctxPartsPrev: ToolPart[] = [] + const ctxParts = createMemo(() => { + const parts = ctx()?.parts ?? [] + if (parts.length === 0 && ctxPartsPrev.length > 0) return ctxPartsPrev + const result: ToolPart[] = [] + for (const item of parts) { + const k = item.part.callID || item.part.id + const cached = ctxPartsCache.get(k) + if (cached) { + result.push(cached) + } else { + ctxPartsCache.set(k, item.part) + result.push(item.part) + } + } + ctxPartsPrev = result + return result + }) + const ctxPending = useContextToolPending(ctxParts, () => !!(props.working && ctx()?.tail)) + const shell = createMemo(() => { + const value = part() + if (!value) return + if (value.part.type !== "tool") return + if (value.part.tool !== "bash") return + return value.part + }) + const kind = createMemo(() => { + if (ctx()) return "context" + if (shell()) return "shell" + const value = part() + if (!value) return "part" + return value.part.type + }) + const shown = createMemo(() => { + if (ctx()) return true + if (shell()) return true + const entry = part() + if (!entry) return false + return !entry.context + }) + const partGrowProps = () => ({ + animate: props.animate, + gap: 0, + fade: fade(), + edge: edge(), + edgeHeight: 20, + edgeOpacity: 0.95, + edgeIdle: 100, + edgeFade: 0.6, + edgeRise: 0.1, + grow: true, + watch: watch(), + animateToggle: true, + open: visible(), + toggleSpring: contextSpring(), + }) + return ( + +
    + + {(entry) => ( + <> + + groupState.write(entry().groupKey, value)} + /> + + + + + )} + + + {(value) => ( + + )} + + + {(entry) => ( + + +
    - )} - - )} - - ) - })()} - - - ) - }} - +
    +
    +
    + )} +
    +
    +
    + ) + }} +
    +
    ) } @@ -552,76 +618,6 @@ function isContextGroupTool(part: PartType): part is ToolPart { return part.type === "tool" && CONTEXT_GROUP_TOOLS.has(part.tool) } -function contextToolDetail(part: ToolPart): string | undefined { - const info = getToolInfo(part.tool, part.state.input ?? {}) - if (info.subtitle) return info.subtitle - if (part.state.status === "error") return part.state.error - if ((part.state.status === "running" || part.state.status === "completed") && part.state.title) - return part.state.title - const description = part.state.input?.description - if (typeof description === "string") return description - return undefined -} - -function contextToolTrigger(part: ToolPart, i18n: ReturnType) { - const input = (part.state.input ?? {}) as Record - const path = typeof input.path === "string" ? input.path : "/" - const filePath = typeof input.filePath === "string" ? input.filePath : undefined - const pattern = typeof input.pattern === "string" ? input.pattern : undefined - const include = typeof input.include === "string" ? input.include : undefined - const offset = typeof input.offset === "number" ? input.offset : undefined - const limit = typeof input.limit === "number" ? input.limit : undefined - - switch (part.tool) { - case "read": { - const args: string[] = [] - if (offset !== undefined) args.push("offset=" + offset) - if (limit !== undefined) args.push("limit=" + limit) - return { - title: i18n.t("ui.tool.read"), - subtitle: filePath ? getFilename(filePath) : "", - args, - } - } - case "list": - return { - title: i18n.t("ui.tool.list"), - subtitle: getDirectory(path), - } - case "glob": - return { - title: i18n.t("ui.tool.glob"), - subtitle: getDirectory(path), - args: pattern ? ["pattern=" + pattern] : [], - } - case "grep": { - const args: string[] = [] - if (pattern) args.push("pattern=" + pattern) - if (include) args.push("include=" + include) - return { - title: i18n.t("ui.tool.grep"), - subtitle: getDirectory(path), - args, - } - } - default: { - const info = getToolInfo(part.tool, input) - return { - title: info.title, - subtitle: info.subtitle || contextToolDetail(part), - args: [], - } - } - } -} - -function contextToolSummary(parts: ToolPart[]) { - const read = parts.filter((part) => part.tool === "read").length - const search = parts.filter((part) => part.tool === "glob" || part.tool === "grep").length - const list = parts.filter((part) => part.tool === "list").length - return { read, search, list } -} - function ExaOutput(props: { output?: string }) { const links = createMemo(() => urls(props.output)) @@ -652,210 +648,11 @@ export function registerPartComponent(type: string, component: PartComponent) { PART_MAPPING[type] = component } -export function Message(props: MessageProps) { - return ( - - - {(userMessage) => ( - - )} - - - {(assistantMessage) => ( - - )} - - - ) -} - -export function AssistantMessageDisplay(props: { - message: AssistantMessage - parts: PartType[] - showAssistantCopyPartID?: string | null - showReasoningSummaries?: boolean -}) { - const emptyTools: ToolPart[] = [] - const grouped = createMemo( - () => - groupParts( - props.parts - .filter((part) => renderable(part, props.showReasoningSummaries ?? true)) - .map((part) => ({ - messageID: props.message.id, - part, - })), - ), - [] as PartGroup[], - { equals: sameGroups }, - ) - - return ( - - {(entryAccessor) => { - const entryType = createMemo(() => entryAccessor().type) - - return ( - - - {(() => { - const parts = createMemo( - () => { - const entry = entryAccessor() as { type: "context"; refs: PartRef[] } - return entry.refs - .map((ref) => partByID(props.parts, ref.partID)) - .filter((part): part is ToolPart => !!part && isContextGroupTool(part)) - }, - emptyTools, - { equals: same }, - ) - - return ( - 0}> - - - ) - })()} - - - {(() => { - const part = createMemo(() => { - const entry = entryAccessor() as { type: "part"; ref: PartRef } - return partByID(props.parts, entry.ref.partID) - }) - - return ( - - {(p) => ( - - )} - - ) - })()} - - - ) - }} - - ) -} - -function ContextToolGroup(props: { parts: ToolPart[]; busy?: boolean }) { - const i18n = useI18n() - const [open, setOpen] = createSignal(false) - const pending = createMemo( - () => - !!props.busy || props.parts.some((part) => part.state.status === "pending" || part.state.status === "running"), - ) - const summary = createMemo(() => contextToolSummary(props.parts)) - - return ( - - -
    - - - - - - - - - -
    -
    - -
    - - {(partAccessor) => { - const trigger = createMemo(() => contextToolTrigger(partAccessor(), i18n)) - const running = createMemo( - () => partAccessor().state.status === "pending" || partAccessor().state.status === "running", - ) - return ( -
    -
    -
    -
    -
    -
    - - - - - {trigger().subtitle} - - - - {(arg) => {arg}} - - -
    -
    -
    -
    -
    -
    - ) - }} -
    -
    -
    -
    - ) -} - export function UserMessageDisplay(props: { message: UserMessage parts: PartType[] interrupted?: boolean + animate?: boolean queued?: boolean }) { const data = useData() @@ -905,14 +702,9 @@ export function UserMessageDisplay(props: { return `${hour12}:${minute} ${hours < 12 ? "AM" : "PM"}` }) - const metaHead = createMemo(() => { + const userMeta = createMemo(() => { const agent = props.message.agent - const items = [agent ? agent[0]?.toUpperCase() + agent.slice(1) : "", model()] - return items.filter((x) => !!x).join("\u00A0\u00B7\u00A0") - }) - - const metaTail = createMemo(() => { - const items = [stamp(), props.interrupted ? i18n.t("ui.message.interrupted") : ""] + const items = [agent ? agent[0]?.toUpperCase() + agent.slice(1) : "", model(), stamp()] return items.filter((x) => !!x).join("\u00A0\u00B7\u00A0") }) @@ -929,93 +721,83 @@ export function UserMessageDisplay(props: { } return ( -
    - 0}> -
    - - {(file) => ( -
    { - if (file.mime.startsWith("image/") && file.url) { - openImagePreview(file.url, file.filename) - } - }} - > - - -
    - } - > - {file.filename - -
    - )} - -
    - - - <> -
    -
    - + +
    +
    + 0}> +
    + + {(file) => ( +
    { + if (file.mime.startsWith("image/") && file.url) { + openImagePreview(file.url, file.filename) + } + }} + > + + +
    + } + > + {file.filename + +
    + )} +
    - -
    - + + + <> +
    +
    + +
    + +
    + +
    +
    -
    -
    -
    - - - +
    + - {metaHead()} + {userMeta()} - - - {"\u00A0\u00B7\u00A0"} - - - - - {metaTail()} - - - - - - e.preventDefault()} - onClick={(event) => { - event.stopPropagation() - handleCopy() - }} - aria-label={copied() ? i18n.t("ui.message.copied") : i18n.t("ui.message.copyMessage")} - /> - -
    - -
    -
    + + e.preventDefault()} + onClick={(event) => { + event.stopPropagation() + handleCopy() + }} + aria-label={copied() ? i18n.t("ui.message.copied") : i18n.t("ui.message.copyMessage")} + /> + +
    + + +
    +
    + ) } @@ -1069,7 +851,10 @@ export function Part(props: MessagePartProps) { hideDetails={props.hideDetails} defaultOpen={props.defaultOpen} showAssistantCopyPartID={props.showAssistantCopyPartID} - turnDurationMs={props.turnDurationMs} + showTurnDiffSummary={props.showTurnDiffSummary} + turnDiffSummary={props.turnDiffSummary} + animate={props.animate} + working={props.working} />
    ) @@ -1079,12 +864,16 @@ export interface ToolProps { input: Record metadata: Record tool: string + partID?: string + callID?: string output?: string status?: string hideDetails?: boolean defaultOpen?: boolean forceOpen?: boolean locked?: boolean + animate?: boolean + reveal?: boolean } export type ToolComponent = Component @@ -1118,7 +907,7 @@ function ToolFileAccordion(props: { path: string; actions?: JSX.Element; childre @@ -1149,30 +938,26 @@ function ToolFileAccordion(props: { path: string; actions?: JSX.Element; childre PART_MAPPING["tool"] = function ToolPartDisplay(props) { const i18n = useI18n() - const part = () => props.part as ToolPart - if (part().tool === "todowrite" || part().tool === "todoread") return null - - const hideQuestion = createMemo( - () => part().tool === "question" && (part().state.status === "pending" || part().state.status === "running"), - ) + const part = props.part as ToolPart + const hideQuestion = createMemo(() => part.tool === "question" && busy(part.state.status)) const emptyInput: Record = {} const emptyMetadata: Record = {} - const input = () => part().state?.input ?? emptyInput + const input = () => part.state?.input ?? emptyInput // @ts-expect-error - const partMetadata = () => part().state?.metadata ?? emptyMetadata + const partMetadata = () => part.state?.metadata ?? emptyMetadata - const render = createMemo(() => ToolRegistry.render(part().tool) ?? GenericTool) + const render = createMemo(() => ToolRegistry.render(part.tool) ?? GenericTool) return ( -
    +
    - + {(error) => { const cleaned = error().replace("Error: ", "") - if (part().tool === "question" && cleaned.includes("dismissed this question")) { + if (part.tool === "question" && cleaned.includes("dismissed this question")) { return (
    @@ -1206,13 +991,17 @@ PART_MAPPING["tool"] = function ToolPartDisplay(props) { @@ -1237,74 +1026,16 @@ PART_MAPPING["compaction"] = function CompactionPartDisplay() { } PART_MAPPING["text"] = function TextPartDisplay(props) { - const data = useData() - const i18n = useI18n() const part = () => props.part as TextPart - const interrupted = createMemo( - () => - props.message.role === "assistant" && (props.message as AssistantMessage).error?.name === "MessageAbortedError", - ) - - const model = createMemo(() => { - if (props.message.role !== "assistant") return "" - const message = props.message as AssistantMessage - const match = data.store.provider?.all?.find((p) => p.id === message.providerID) - return match?.models?.[message.modelID]?.name ?? message.modelID - }) - - const duration = createMemo(() => { - if (props.message.role !== "assistant") return "" - const message = props.message as AssistantMessage - const completed = message.time.completed - const ms = - typeof props.turnDurationMs === "number" - ? props.turnDurationMs - : typeof completed === "number" - ? completed - message.time.created - : -1 - if (!(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(() => { - if (props.message.role !== "assistant") return "" - const agent = (props.message as AssistantMessage).agent - const items = [ - agent ? agent[0]?.toUpperCase() + agent.slice(1) : "", - model(), - duration(), - interrupted() ? i18n.t("ui.message.interrupted") : "", - ] - return items.filter((x) => !!x).join(" \u00B7 ") - }) const displayText = () => (part().text ?? "").trim() const throttledText = createThrottledValue(displayText) - const isLastTextPart = createMemo(() => { - const last = (data.store.part?.[props.message.id] ?? []) - .filter((item): item is TextPart => item?.type === "text" && !!item.text?.trim()) - .at(-1) - return last?.id === part().id + const summary = createMemo(() => { + if (props.message.role !== "assistant") return + if (!props.showTurnDiffSummary) return + if (props.showAssistantCopyPartID !== part().id) return + return props.turnDiffSummary }) - const showCopy = createMemo(() => { - if (props.message.role !== "assistant") return isLastTextPart() - if (props.showAssistantCopyPartID === null) return false - if (typeof props.showAssistantCopyPartID === "string") return props.showAssistantCopyPartID === part().id - return isLastTextPart() - }) - const [copied, setCopied] = createSignal(false) - - const handleCopy = async () => { - const content = displayText() - if (!content) return - await navigator.clipboard.writeText(content) - setCopied(true) - setTimeout(() => setCopied(false), 2000) - } return ( @@ -1312,28 +1043,12 @@ PART_MAPPING["text"] = function TextPartDisplay(props) {
    - -
    - - e.preventDefault()} - onClick={handleCopy} - aria-label={copied() ? i18n.t("ui.message.copied") : i18n.t("ui.message.copyResponse")} - /> - - - - {meta()} - - -
    + + {(render) => ( + +
    {render()()}
    +
    + )}
    @@ -1363,30 +1078,33 @@ ToolRegistry.register({ if (props.input.offset) args.push("offset=" + props.input.offset) if (props.input.limit) args.push("limit=" + props.input.limit) const loaded = createMemo(() => { - if (props.status !== "completed") return [] const value = props.metadata.loaded if (!value || !Array.isArray(value)) return [] return value.filter((p): p is string => typeof p === "string") }) + const pending = createMemo(() => busy(props.status)) return ( <> - + } /> {(filepath) => ( -
    - - - {i18n.t("ui.tool.loaded")} {relativizeProjectPath(filepath, data.directory)} - -
    + )}
    @@ -1398,11 +1116,20 @@ ToolRegistry.register({ name: "list", render(props) { const i18n = useI18n() + const pending = createMemo(() => busy(props.status)) return ( - + } > {(output) => ( @@ -1411,7 +1138,7 @@ ToolRegistry.register({
    )} - + ) }, }) @@ -1420,15 +1147,21 @@ ToolRegistry.register({ name: "glob", render(props) { const i18n = useI18n() + const pending = createMemo(() => busy(props.status)) return ( - + } > {(output) => ( @@ -1466,25 +1205,191 @@ ToolRegistry.register({
    )}
    - + ) }, }) +function useToolReveal(pending: () => boolean, animate?: () => boolean) { + const enabled = () => animate?.() ?? true + const [live, setLive] = createSignal(pending() || enabled()) + createEffect(() => { + if (pending()) setLive(true) + }) + return () => enabled() && live() +} + +function WebfetchMeta(props: { url: string; animate?: boolean }) { + let ref: HTMLSpanElement | undefined + useToolFade(() => ref, { wipe: true, animate: props.animate }) + + return ( + + event.stopPropagation()} + > + {props.url} + +
    + +
    +
    + ) +} + +function TaskLink(props: { href: string; text: string; onClick: (e: MouseEvent) => void; animate?: boolean }) { + let ref: HTMLAnchorElement | undefined + useToolFade(() => ref, { wipe: true, animate: props.animate }) + + return ( + + {props.text} + + ) +} + +function ToolText(props: { text: string; delay?: number; animate?: boolean }) { + let ref: HTMLSpanElement | undefined + useToolFade(() => ref, { delay: props.delay, wipe: true, animate: props.animate }) + + return ( + + {props.text} + + ) +} + +function ToolLoadedFile(props: { text: string; animate?: boolean }) { + let ref: HTMLDivElement | undefined + useToolFade(() => ref, { delay: 0.02, wipe: true, animate: props.animate }) + + return ( + +
    + + {props.text} +
    +
    + ) +} + +function ToolTriggerRow(props: { + title: string + pending: boolean + subtitle?: string + args?: string[] + action?: JSX.Element + animate?: boolean + revealOnMount?: boolean +}) { + const reveal = useToolReveal( + () => props.pending, + () => props.animate !== false, + ) + const detail = createMemo(() => [props.subtitle, ...(props.args ?? [])].filter((x): x is string => !!x).join(" ")) + const detailAnimate = createMemo(() => { + if (props.animate === false) return false + if (props.revealOnMount) return true + if (!props.pending && !reveal()) return true + return reveal() + }) + + return ( +
    +
    + + + + {(text) => } +
    + {props.action} +
    + ) +} + +type DiffValue = { additions: number; deletions: number } | { additions: number; deletions: number }[] + +function ToolMetaLine(props: { + filename: string + path?: string + changes?: DiffValue + delay?: number + animate?: boolean + soft?: boolean +}) { + let ref: HTMLSpanElement | undefined + useToolFade(() => ref, { delay: props.delay ?? 0.02, wipe: true, animate: props.animate }) + + return ( + + {props.filename} + + {props.path} + + {(changes) => } + + ) +} + +function ToolChanges(props: { changes: DiffValue; animate?: boolean }) { + let ref: HTMLDivElement | undefined + useToolFade(() => ref, { delay: 0.04, animate: props.animate }) + + return ( +
    + +
    + ) +} + +function ShellText(props: { text: string; animate?: boolean }) { + let ref: HTMLSpanElement | undefined + useToolFade(() => ref, { wipe: true, animate: props.animate }) + + return ( + + + + {props.text} + + + + ) +} + ToolRegistry.register({ name: "webfetch", render(props) { const i18n = useI18n() - const pending = createMemo(() => props.status === "pending" || props.status === "running") + const pending = createMemo(() => busy(props.status)) + const reveal = useToolReveal(pending, () => props.reveal !== false) const url = createMemo(() => { const value = props.input.url if (typeof value !== "string") return "" return value }) return ( - @@ -1492,24 +1397,8 @@ ToolRegistry.register({ - - event.stopPropagation()} - > - {url()} - - + {(value) => }
    - -
    - -
    -
    } /> @@ -1528,7 +1417,8 @@ ToolRegistry.register({ }) return ( - - + ) }, }) @@ -1554,7 +1444,8 @@ ToolRegistry.register({ }) return ( - - + ) }, }) @@ -1574,15 +1465,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 +1487,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 +1537,7 @@ ToolRegistry.register({
    ) - return + return }, }) @@ -1636,13 +1545,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 +1576,20 @@ ToolRegistry.register({ } return ( -
    - - - + {(text) => }
    } @@ -1693,7 +1617,7 @@ ToolRegistry.register({
    - + ) }, }) @@ -1706,10 +1630,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 +1665,9 @@ ToolRegistry.register({ {(diff) => }
    + + {(diff) => } + } >
    @@ -1762,7 +1687,7 @@ ToolRegistry.register({ - +
    ) }, @@ -1776,10 +1701,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 +1748,7 @@ ToolRegistry.register({ - +
    ) }, @@ -1845,7 +1772,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 +1781,6 @@ ToolRegistry.register({ }) const [expanded, setExpanded] = createSignal([]) let seeded = false - createEffect(() => { const list = files() if (list.length === 0) return @@ -1861,7 +1788,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 +1795,44 @@ ToolRegistry.register({ }) return ( - - +
    + +
    +
    + + + + + {(file) => ( + + )} + + {(text) => } +
    +
    +
    + } + > + 0}> setExpanded(Array.isArray(value) ? value : value ? [value] : [])} > @@ -1894,13 +1840,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 +1909,9 @@ ToolRegistry.register({ -
    -
    - } - > - {(file) => ( -
    - -
    -
    - - - - - {getFilename(file().relativePath)} - -
    - -
    - {getDirectory(file().relativePath)} -
    -
    -
    -
    - - - -
    -
    - } - > + } + > + {(file) => ( - + } @@ -2034,10 +1949,10 @@ ToolRegistry.register({ />
    - -
    - )} - + )} + + +
    ) }, }) @@ -2055,6 +1970,7 @@ ToolRegistry.register({ return [] }) + const pending = createMemo(() => busy(props.status)) const subtitle = createMemo(() => { const list = todos() @@ -2063,14 +1979,19 @@ ToolRegistry.register({ }) return ( - + } >
    @@ -2088,7 +2009,7 @@ ToolRegistry.register({
    -
    + ) }, }) @@ -2100,6 +2021,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 +2031,19 @@ ToolRegistry.register({ }) return ( - + } >
    @@ -2133,7 +2060,7 @@ ToolRegistry.register({
    -
    + ) }, }) @@ -2141,21 +2068,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..c7ff1fbcd2 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 { useReducedMotion } 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 = useReducedMotion() 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..77ffdb1b34 --- /dev/null +++ b/packages/ui/src/components/rolling-results.tsx @@ -0,0 +1,325 @@ +import { For, Show, batch, createEffect, createMemo, createSignal, on, onCleanup, onMount, type JSX } from "solid-js" +import { useReducedMotion } from "../hooks/use-reduced-motion" +import { animate, clearMaskStyles, GROW_SPRING, type AnimationPlaybackControls, type SpringConfig } from "./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 reduce = useReducedMotion() + + 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) && !reduce()) + 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..0210e46e0e --- /dev/null +++ b/packages/ui/src/components/shell-rolling-results.tsx @@ -0,0 +1,291 @@ +import { createEffect, createMemo, createSignal, onCleanup, onMount, Show } from "solid-js" +import stripAnsi from "strip-ansi" +import type { ToolPart } from "@opencode-ai/sdk/v2" +import { useReducedMotion } 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, 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; defaultOpen?: boolean }) { + const i18n = useI18n() + const reduce = useReducedMotion() + const wiped = new Set() + const [mounted, setMounted] = createSignal(false) + const [open, setOpen] = createSignal(props.defaultOpen ?? true) + onMount(() => setMounted(true)) + const state = createMemo(() => props.part.state as Record) + const pending = createMemo(() => busy(props.part.state.status)) + const expanded = createMemo(() => open() && !pending()) + const previewOpen = createMemo(() => open() && pending()) + 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 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 = () => { + const el = headerClipRef + const viewport = el?.closest(".scroll-view__viewport") as HTMLElement | null + const beforeY = el?.getBoundingClientRect().top ?? 0 + setOpen((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..edf5dbf837 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 { useReducedMotion } from "../hooks/use-reduced-motion" +import { + animate, + type AnimationPlaybackControls, + clearFadeStyles, + clearMaskStyles, + GROW_SPRING, + WIPE_MASK, +} from "./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,95 @@ 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 reduce = useReducedMotion() + + const run = () => { + if (props.animate === false) return + const el = ref + if (!el || !props.text || typeof window === "undefined") return + if (reduce()) 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..444955af98 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 { useReducedMotion } from "../hooks/use-reduced-motion" +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" function contentWidth(el: HTMLSpanElement | undefined) { if (!el) return 0 @@ -27,25 +18,58 @@ export function ToolStatusTitle(props: { class?: string split?: boolean }) { - const split = createMemo(() => common(props.activeText, props.doneText)) + const reduce = useReducedMotion() + 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 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 +114,7 @@ export function ToolStatusTitle(props: { onCleanup(() => { if (frame !== undefined) cancelAnimationFrame(frame) if (readyFrame !== undefined) cancelAnimationFrame(readyFrame) + widthAnim?.stop() }) return ( @@ -104,7 +129,7 @@ export function ToolStatusTitle(props: { + @@ -118,7 +143,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..4d57c626e8 --- /dev/null +++ b/packages/ui/src/components/tool-utils.ts @@ -0,0 +1,336 @@ +import type { ToolPart } from "@opencode-ai/sdk/v2" +import { createEffect, createMemo, createSignal, on, onCleanup, onMount } from "solid-js" +import { useReducedMotion } from "../hooks/use-reduced-motion" +import { + animate, + type AnimationPlaybackControls, + clearFadeStyles, + clearMaskStyles, + COLLAPSIBLE_SPRING, + GROW_SPRING, + WIPE_MASK, +} from "./motion" + +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 +}) { + const reduce = useReducedMotion() + 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() + if (reduce()) { + body.style.opacity = "" + body.style.filter = "" + if (isOpen) { + content.style.display = "" + content.style.height = "auto" + options.onOpen?.() + return + } + content.style.height = "0px" + content.style.display = "none" + return + } + 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" + }, + () => {}, + ) + }), + ) + + 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 = useReducedMotion() + + 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 + const reduce = useReducedMotion() + + onMount(() => { + if (!active) return + + const el = ref() + if (!el || typeof window === "undefined") return + if (reduce()) 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..0fcf6f086c 100644 --- a/packages/ui/src/hooks/index.ts +++ b/packages/ui/src/hooks/index.ts @@ -1,2 +1,3 @@ export * from "./use-filtered-list" export * from "./create-auto-scroll" +export * from "./use-reduced-motion" 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..0038760ec8 --- /dev/null +++ b/packages/ui/src/hooks/use-reduced-motion.ts @@ -0,0 +1,10 @@ +import { isHydrated } from "@solid-primitives/lifecycle" +import { createMediaQuery } from "@solid-primitives/media" +import { createHydratableSingletonRoot } from "@solid-primitives/rootless" + +const query = "(prefers-reduced-motion: reduce)" + +export const useReducedMotion = createHydratableSingletonRoot(() => { + const value = createMediaQuery(query) + return () => !isHydrated() || value() +}) 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..702d1e4e67 100644 --- a/packages/ui/src/styles/theme.css +++ b/packages/ui/src/styles/theme.css @@ -89,205 +89,214 @@ color-scheme: light; --text-mix-blend-mode: multiply; - /* OC-1 fallback variables (light) */ - --background-base: #f8f7f7; - --background-weak: var(--smoke-light-3); - --background-strong: var(--smoke-light-1); + /* OC-2 fallback variables (light) */ + --background-base: #f8f8f8; + --background-weak: #f3f3f3; + --background-strong: #fcfcfc; --background-stronger: #fcfcfc; - --surface-base: var(--smoke-light-alpha-2); - --base: var(--smoke-light-alpha-2); - --surface-base-hover: #0500000f; - --surface-base-active: var(--smoke-light-alpha-3); - --surface-base-interactive-active: var(--cobalt-light-alpha-3); - --base2: var(--smoke-light-alpha-2); - --base3: var(--smoke-light-alpha-2); - --surface-inset-base: var(--smoke-light-alpha-2); - --surface-inset-base-hover: var(--smoke-light-alpha-3); - --surface-inset-strong: #1f000017; - --surface-inset-strong-hover: #1f000017; - --surface-raised-base: var(--smoke-light-alpha-1); - --surface-float-base: var(--smoke-dark-1); - --surface-float-base-hover: var(--smoke-dark-2); - --surface-raised-base-hover: var(--smoke-light-alpha-2); - --surface-raised-base-active: var(--smoke-light-alpha-3); - --surface-raised-strong: var(--smoke-light-1); - --surface-raised-strong-hover: var(--white); - --surface-raised-stronger: var(--white); - --surface-raised-stronger-hover: var(--white); - --surface-weak: var(--smoke-light-alpha-3); - --surface-weaker: var(--smoke-light-alpha-4); + --surface-base: rgba(0, 0, 0, 0.031); + --base: rgba(0, 0, 0, 0.034); + --surface-base-hover: rgba(0, 0, 0, 0.059); + --surface-base-active: rgba(0, 0, 0, 0.051); + --surface-base-interactive-active: rgba(3, 76, 255, 0.09); + --base2: rgba(0, 0, 0, 0.034); + --base3: rgba(0, 0, 0, 0.034); + --surface-inset-base: rgba(0, 0, 0, 0.034); + --surface-inset-base-hover: rgba(0, 0, 0, 0.055); + --surface-inset-strong: rgba(0, 0, 0, 0.09); + --surface-inset-strong-hover: rgba(0, 0, 0, 0.09); + --surface-raised-base: rgba(0, 0, 0, 0.031); + --surface-float-base: #161616; + --surface-float-base-hover: #1c1c1c; + --surface-raised-base-hover: rgba(0, 0, 0, 0.051); + --surface-raised-base-active: rgba(0, 0, 0, 0.09); + --surface-raised-strong: #fcfcfc; + --surface-raised-strong-hover: #ffffff; + --surface-raised-stronger: #ffffff; + --surface-raised-stronger-hover: #ffffff; + --surface-weak: rgba(0, 0, 0, 0.051); + --surface-weaker: rgba(0, 0, 0, 0.071); --surface-strong: #ffffff; --surface-stronger-non-alpha: var(--surface-raised-stronger-non-alpha); - --surface-raised-stronger-non-alpha: var(--white); - --surface-brand-base: var(--yuzu-light-9); - --surface-brand-hover: var(--yuzu-light-10); - --surface-interactive-base: var(--cobalt-light-3); - --surface-interactive-hover: #e5f0ff; - --surface-interactive-weak: var(--cobalt-light-2); - --surface-interactive-weak-hover: var(--cobalt-light-3); - --surface-success-base: var(--apple-light-3); - --surface-success-weak: var(--apple-light-2); - --surface-success-strong: var(--apple-light-9); - --surface-warning-base: var(--solaris-light-3); - --surface-warning-weak: var(--solaris-light-2); - --surface-warning-strong: var(--solaris-light-9); - --surface-critical-base: var(--ember-light-3); - --surface-critical-weak: var(--ember-light-2); - --surface-critical-strong: var(--ember-light-9); - --surface-info-base: var(--lilac-light-3); - --surface-info-weak: var(--lilac-light-2); - --surface-info-strong: var(--lilac-light-9); + --surface-raised-stronger-non-alpha: #ffffff; + --surface-brand-base: #dcde8d; + --surface-brand-hover: #d0d283; + --surface-interactive-base: #ecf3ff; + --surface-interactive-hover: #e0eaff; + --surface-interactive-weak: #f7faff; + --surface-interactive-weak-hover: #ecf3ff; + --surface-success-base: #dbfed7; + --surface-success-weak: #f0feee; + --surface-success-strong: #12c905; + --surface-warning-base: #fcf3cb; + --surface-warning-weak: #fdfaec; + --surface-warning-strong: #fbdd46; + --surface-critical-base: #feefeb; + --surface-critical-weak: #fff8f6; + --surface-critical-strong: #fc533a; + --surface-info-base: #fdecfe; + --surface-info-weak: #fef7ff; + --surface-info-strong: #a753ae; --surface-diff-unchanged-base: #ffffff00; - --surface-diff-skip-base: var(--smoke-light-2); - --surface-diff-hidden-base: var(--blue-light-3); - --surface-diff-hidden-weak: var(--blue-light-2); - --surface-diff-hidden-weaker: var(--blue-light-1); - --surface-diff-hidden-strong: var(--blue-light-5); - --surface-diff-hidden-stronger: var(--blue-light-9); - --surface-diff-add-base: #dafbe0; - --surface-diff-add-weak: var(--mint-light-2); - --surface-diff-add-weaker: var(--mint-light-1); - --surface-diff-add-strong: var(--mint-light-5); - --surface-diff-add-stronger: var(--mint-light-9); - --surface-diff-delete-base: var(--ember-light-3); - --surface-diff-delete-weak: var(--ember-light-2); - --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(--smoke-light-1); - --input-hover: var(--smoke-light-2); - --input-active: var(--cobalt-light-1); - --input-selected: var(--cobalt-light-4); - --input-focus: var(--cobalt-light-1); - --input-disabled: var(--smoke-light-4); - --text-base: var(--smoke-light-11); - --text-weak: var(--smoke-light-9); - --text-weaker: var(--smoke-light-8); - --text-strong: var(--smoke-light-12); - --text-invert-base: var(--smoke-dark-alpha-11); - --text-invert-weak: var(--smoke-dark-alpha-9); - --text-invert-weaker: var(--smoke-dark-alpha-8); - --text-invert-strong: var(--smoke-dark-alpha-12); - --text-interactive-base: var(--cobalt-light-9); - --text-on-brand-base: var(--smoke-light-alpha-11); - --text-on-interactive-base: var(--smoke-light-1); - --text-on-interactive-weak: var(--smoke-dark-alpha-11); - --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(--smoke-dark-alpha-11); - --text-on-info-base: var(--smoke-dark-alpha-11); - --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(--smoke-dark-alpha-9); - --text-on-info-strong: var(--smoke-dark-alpha-12); - --text-on-warning-weak: var(--smoke-dark-alpha-9); - --text-on-warning-strong: var(--smoke-dark-alpha-12); - --text-on-success-weak: var(--apple-light-6); - --text-on-success-strong: var(--apple-light-12); - --text-on-brand-weak: var(--smoke-light-alpha-9); - --text-on-brand-weaker: var(--smoke-light-alpha-8); - --text-on-brand-strong: var(--smoke-light-alpha-12); - --button-primary-base: var(--smoke-light-12); - --button-secondary-base: #fdfcfc; - --button-secondary-hover: #faf9f9; - --border-base: var(--smoke-light-alpha-7); - --border-hover: var(--smoke-light-alpha-8); - --border-active: var(--smoke-light-alpha-9); - --border-selected: var(--cobalt-light-alpha-9); - --border-disabled: var(--smoke-light-alpha-8); - --border-focus: var(--smoke-light-alpha-9); - --border-weak-base: var(--smoke-light-alpha-5); - --border-strong-base: var(--smoke-light-alpha-7); - --border-strong-hover: var(--smoke-light-alpha-8); - --border-strong-active: var(--smoke-light-alpha-7); - --border-strong-selected: var(--cobalt-light-alpha-6); - --border-strong-disabled: var(--smoke-light-alpha-6); - --border-strong-focus: var(--smoke-light-alpha-7); - --border-weak-hover: var(--smoke-light-alpha-6); - --border-weak-active: var(--smoke-light-alpha-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-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(--smoke-light-8); - --border-interactive-focus: var(--cobalt-light-9); - --border-success-base: var(--apple-light-6); - --border-success-hover: var(--apple-light-7); - --border-success-selected: var(--apple-light-9); - --border-warning-base: var(--solaris-light-6); - --border-warning-hover: var(--solaris-light-7); - --border-warning-selected: var(--solaris-light-9); - --border-critical-base: var(--ember-light-6); - --border-critical-hover: var(--ember-light-7); - --border-critical-selected: var(--ember-light-9); - --border-info-base: var(--lilac-light-6); - --border-info-hover: var(--lilac-light-7); - --border-info-selected: var(--lilac-light-9); - --icon-base: var(--smoke-light-9); - --icon-hover: var(--smoke-light-11); - --icon-active: var(--smoke-light-12); - --icon-selected: var(--smoke-light-12); - --icon-disabled: var(--smoke-light-8); - --icon-focus: var(--smoke-light-12); + --surface-diff-skip-base: #f8f8f8; + --surface-diff-hidden-base: #eaf4ff; + --surface-diff-hidden-weak: #f6faff; + --surface-diff-hidden-weaker: #fbfdff; + --surface-diff-hidden-strong: #cae3ff; + --surface-diff-hidden-stronger: #2090f5; + --surface-diff-add-base: #e3fae1; + --surface-diff-add-weak: #f4fcf3; + --surface-diff-add-weaker: #fbfefb; + --surface-diff-add-strong: #c2eebf; + --surface-diff-add-stronger: #9ff29a; + --surface-diff-delete-base: #feefeb; + --surface-diff-delete-weak: #fff8f6; + --surface-diff-delete-weaker: #fffcfb; + --surface-diff-delete-strong: #fdc3b7; + --surface-diff-delete-stronger: #fc533a; + --input-base: #fcfcfc; + --input-hover: #f8f8f8; + --input-active: #fcfdff; + --input-selected: #e0eaff; + --input-focus: #fcfdff; + --input-disabled: #ededed; + --text-base: #6f6f6f; + --text-weak: #8f8f8f; + --text-weaker: #c7c7c7; + --text-strong: #171717; + --text-invert-base: #f8f8f8; + --text-invert-weak: #f3f3f3; + --text-invert-weaker: #ededed; + --text-invert-strong: #fcfcfc; + --text-interactive-base: #034cff; + --text-on-brand-base: rgba(0, 0, 0, 0.574); + --text-on-interactive-base: #fcfcfc; + --text-on-interactive-weak: rgba(0, 0, 0, 0.574); + --text-on-success-base: #2dba26; + --text-on-critical-base: #ed4831; + --text-on-critical-weak: #fe806a; + --text-on-critical-strong: #601a0f; + --text-on-warning-base: rgba(0, 0, 0, 0.574); + --text-on-info-base: rgba(0, 0, 0, 0.574); + --text-diff-add-base: #3a8437; + --text-diff-delete-base: #ed4831; + --text-diff-delete-strong: #601a0f; + --text-diff-add-strong: #1d3e1c; + --text-on-info-weak: rgba(0, 0, 0, 0.453); + --text-on-info-strong: rgba(0, 0, 0, 0.915); + --text-on-warning-weak: rgba(0, 0, 0, 0.453); + --text-on-warning-strong: rgba(0, 0, 0, 0.915); + --text-on-success-weak: #96ec8e; + --text-on-success-strong: #044202; + --text-on-brand-weak: rgba(0, 0, 0, 0.453); + --text-on-brand-weaker: rgba(0, 0, 0, 0.232); + --text-on-brand-strong: rgba(0, 0, 0, 0.915); + --button-primary-base: #171717; + --button-secondary-base: #fcfcfc; + --button-secondary-hover: #f8f8f8; + --button-ghost-hover: rgba(0, 0, 0, 0.031); + --button-ghost-hover2: rgba(0, 0, 0, 0.051); + --border-base: rgba(0, 0, 0, 0.162); + --border-hover: rgba(0, 0, 0, 0.236); + --border-active: rgba(0, 0, 0, 0.46); + --border-selected: rgba(3, 76, 255, 0.99); + --border-disabled: rgba(0, 0, 0, 0.236); + --border-focus: rgba(0, 0, 0, 0.46); + --border-weak-base: #e5e5e5; + --border-strong-base: rgba(0, 0, 0, 0.151); + --border-strong-hover: rgba(0, 0, 0, 0.232); + --border-strong-active: rgba(0, 0, 0, 0.151); + --border-strong-selected: rgba(3, 76, 255, 0.31); + --border-strong-disabled: rgba(0, 0, 0, 0.118); + --border-strong-focus: rgba(0, 0, 0, 0.151); + --border-weak-hover: rgba(0, 0, 0, 0.118); + --border-weak-active: rgba(0, 0, 0, 0.151); + --border-weak-selected: rgba(3, 76, 255, 0.24); + --border-weak-disabled: rgba(0, 0, 0, 0.118); + --border-weak-focus: rgba(0, 0, 0, 0.151); + --border-weaker-base: #f0f0f0; + --border-weaker-hover: rgba(0, 0, 0, 0.075); + --border-weaker-active: rgba(0, 0, 0, 0.118); + --border-weaker-selected: rgba(3, 76, 255, 0.16); + --border-weaker-disabled: rgba(0, 0, 0, 0.034); + --border-weaker-focus: rgba(0, 0, 0, 0.118); + --border-interactive-base: #a3c1fd; + --border-interactive-hover: #7ea9ff; + --border-interactive-active: #034cff; + --border-interactive-selected: #034cff; + --border-interactive-disabled: #c7c7c7; + --border-interactive-focus: #034cff; + --border-success-base: #96ec8e; + --border-success-hover: #7add71; + --border-success-selected: #12c905; + --border-warning-base: #e8d479; + --border-warning-hover: #d8c158; + --border-warning-selected: #fbdd46; + --border-critical-base: #fdc3b7; + --border-critical-hover: #ffa796; + --border-critical-selected: #fc533a; + --border-info-base: #f4bdf8; + --border-info-hover: #e6a8ea; + --border-info-selected: #a753ae; + --border-color: #ffffff; + --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(--smoke-light-7); - --icon-weak-hover: var(--smoke-light-8); - --icon-weak-active: var(--smoke-light-9); - --icon-weak-selected: var(--smoke-light-10); - --icon-weak-disabled: var(--smoke-light-6); - --icon-weak-focus: var(--smoke-light-9); - --icon-strong-base: var(--smoke-light-12); + --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: #151313; --icon-strong-active: #020202; --icon-strong-selected: #020202; - --icon-strong-disabled: var(--smoke-light-8); + --icon-strong-disabled: #c7c7c7; --icon-strong-focus: #020202; - --icon-brand-base: var(--smoke-light-12); - --icon-interactive-base: var(--cobalt-light-9); - --icon-success-base: var(--apple-light-7); - --icon-success-hover: var(--apple-light-8); - --icon-success-active: var(--apple-light-11); - --icon-warning-base: var(--amber-light-9); - --icon-warning-hover: var(--amber-light-8); - --icon-warning-active: var(--amber-light-11); - --icon-critical-base: var(--ember-light-10); - --icon-critical-hover: var(--ember-light-11); - --icon-critical-active: var(--ember-light-12); - --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(--smoke-light-alpha-11); - --icon-on-brand-hover: var(--smoke-light-alpha-12); - --icon-on-brand-selected: var(--smoke-light-alpha-12); - --icon-on-interactive-base: var(--smoke-light-1); - --icon-agent-plan-base: var(--purple-light-9); - --icon-agent-docs-base: var(--amber-light-9); - --icon-agent-ask-base: var(--cyan-light-9); - --icon-agent-build-base: var(--cobalt-light-9); - --icon-on-success-base: var(--apple-light-alpha-9); - --icon-on-success-hover: var(--apple-light-alpha-10); - --icon-on-success-selected: var(--apple-light-alpha-11); - --icon-on-warning-base: var(--amber-lightalpha-9); - --icon-on-warning-hover: var(--amber-lightalpha-10); - --icon-on-warning-selected: var(--amber-lightalpha-11); - --icon-on-critical-base: var(--ember-light-alpha-9); - --icon-on-critical-hover: var(--ember-light-alpha-10); - --icon-on-critical-selected: var(--ember-light-alpha-11); - --icon-on-info-base: var(--lilac-light-9); - --icon-on-info-hover: var(--lilac-light-alpha-10); - --icon-on-info-selected: var(--lilac-light-alpha-11); - --icon-diff-add-base: var(--mint-light-11); - --icon-diff-add-hover: var(--mint-light-12); - --icon-diff-add-active: var(--mint-light-12); - --icon-diff-delete-base: var(--ember-light-10); - --icon-diff-delete-hover: var(--ember-light-11); + --icon-brand-base: #171717; + --icon-interactive-base: #034cff; + --icon-success-base: #7add71; + --icon-success-hover: #4cc944; + --icon-success-active: #078901; + --icon-warning-base: #ebb76e; + --icon-warning-hover: #da9e40; + --icon-warning-active: #95671b; + --icon-critical-base: #ed4831; + --icon-critical-hover: #ca2d17; + --icon-critical-active: #601a0f; + --icon-info-base: #e6a8ea; + --icon-info-hover: #d58cda; + --icon-info-active: #9b4da1; + --icon-on-brand-base: rgba(0, 0, 0, 0.574); + --icon-on-brand-hover: rgba(0, 0, 0, 0.915); + --icon-on-brand-selected: rgba(0, 0, 0, 0.915); + --icon-on-interactive-base: #fcfcfc; + --icon-agent-plan-base: #a753ae; + --icon-agent-docs-base: #fcb239; + --icon-agent-ask-base: #2090f5; + --icon-agent-build-base: #034cff; + --icon-on-success-base: rgba(18, 201, 5, 0.9); + --icon-on-success-hover: rgba(45, 186, 38, 0.9); + --icon-on-success-selected: rgba(7, 137, 1, 0.9); + --icon-on-warning-base: rgba(252, 178, 57, 0.9); + --icon-on-warning-hover: rgba(239, 167, 46, 0.9); + --icon-on-warning-selected: rgba(149, 103, 27, 0.9); + --icon-on-critical-base: rgba(252, 83, 58, 0.9); + --icon-on-critical-hover: rgba(237, 72, 49, 0.9); + --icon-on-critical-selected: rgba(202, 45, 23, 0.9); + --icon-on-info-base: #a753ae; + --icon-on-info-hover: rgba(155, 73, 162, 0.9); + --icon-on-info-selected: rgba(155, 77, 161, 0.9); + --icon-diff-add-base: #3a8437; + --icon-diff-add-hover: #1d3e1c; + --icon-diff-add-active: #1d3e1c; + --icon-diff-delete-base: #ed4831; + --icon-diff-delete-hover: #ca2d17; --icon-diff-modified-base: #ff8c00; --syntax-comment: var(--text-weak); --syntax-regexp: var(--text-base); @@ -301,12 +310,12 @@ --syntax-constant: #007b80; --syntax-punctuation: var(--text-base); --syntax-object: var(--text-strong); - --syntax-success: var(--apple-light-10); - --syntax-warning: var(--amber-light-10); - --syntax-critical: var(--ember-light-10); + --syntax-success: #2dba26; + --syntax-warning: #efa72e; + --syntax-critical: #ed4831; --syntax-info: #0092a8; - --syntax-diff-add: var(--mint-light-11); - --syntax-diff-delete: var(--ember-light-11); + --syntax-diff-add: #3a8437; + --syntax-diff-delete: #ca2d17; --syntax-diff-unknown: #ff0000; --markdown-heading: #d68c27; --markdown-text: #1a1a1a; @@ -322,15 +331,6 @@ --markdown-image: #3b7dd8; --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; --avatar-background-mint: #e1fbf4; --avatar-background-orange: #fff1e7; @@ -343,210 +343,220 @@ --avatar-text-purple: #8445bc; --avatar-text-cyan: #0894b3; --avatar-text-lime: #5d770d; + --text-stronger: #171717; @media (prefers-color-scheme: dark) { color-scheme: dark; --text-mix-blend-mode: plus-lighter; - /* OC-1 fallback variables (dark) */ - --background-base: var(--smoke-dark-1); - --background-weak: #1c1717; - --background-strong: #151313; - --background-stronger: #191515; - --surface-base: var(--smoke-dark-alpha-2); - --base: var(--smoke-dark-alpha-2); - --surface-base-hover: #e0b7b716; - --surface-base-active: var(--smoke-dark-alpha-3); - --surface-base-interactive-active: var(--cobalt-dark-alpha-2); - --base2: var(--smoke-dark-alpha-2); - --base3: var(--smoke-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(--smoke-dark-alpha-3); - --surface-float-base: var(--smoke-dark-1); - --surface-float-base-hover: var(--smoke-dark-2); - --surface-raised-base-hover: var(--smoke-dark-alpha-4); - --surface-raised-base-active: var(--smoke-dark-alpha-5); - --surface-raised-strong: var(--smoke-dark-alpha-4); - --surface-raised-strong-hover: var(--smoke-dark-alpha-6); - --surface-raised-stronger: var(--smoke-dark-alpha-6); - --surface-raised-stronger-hover: var(--smoke-dark-alpha-7); - --surface-weak: var(--smoke-dark-alpha-4); - --surface-weaker: var(--smoke-dark-alpha-5); - --surface-strong: var(--smoke-dark-alpha-7); + /* OC-2 fallback variables (dark) */ + --background-base: #101010; + --background-weak: #1e1e1e; + --background-strong: #121212; + --background-stronger: #151515; + --surface-base: rgba(255, 255, 255, 0.031); + --base: rgba(255, 255, 255, 0.034); + --surface-base-hover: rgba(255, 255, 255, 0.039); + --surface-base-active: rgba(255, 255, 255, 0.059); + --surface-base-interactive-active: rgba(3, 76, 255, 0.125); + --base2: rgba(255, 255, 255, 0.034); + --base3: rgba(255, 255, 255, 0.034); + --surface-inset-base: rgba(0, 0, 0, 0.5); + --surface-inset-base-hover: rgba(0, 0, 0, 0.5); + --surface-inset-strong: rgba(0, 0, 0, 0.8); + --surface-inset-strong-hover: rgba(0, 0, 0, 0.8); + --surface-raised-base: rgba(255, 255, 255, 0.059); + --surface-float-base: #161616; + --surface-float-base-hover: #1c1c1c; + --surface-raised-base-hover: rgba(255, 255, 255, 0.078); + --surface-raised-base-active: rgba(255, 255, 255, 0.102); + --surface-raised-strong: rgba(255, 255, 255, 0.078); + --surface-raised-strong-hover: rgba(255, 255, 255, 0.129); + --surface-raised-stronger: rgba(255, 255, 255, 0.129); + --surface-raised-stronger-hover: rgba(255, 255, 255, 0.169); + --surface-weak: rgba(255, 255, 255, 0.078); + --surface-weaker: rgba(255, 255, 255, 0.102); + --surface-strong: rgba(255, 255, 255, 0.169); --surface-stronger-non-alpha: var(--surface-raised-stronger-non-alpha); - --surface-raised-stronger-non-alpha: var(--smoke-dark-3); - --surface-brand-base: var(--yuzu-light-9); - --surface-brand-hover: var(--yuzu-light-10); - --surface-interactive-base: var(--cobalt-dark-3); - --surface-interactive-hover: #0a1d4d; - --surface-interactive-weak: var(--cobalt-dark-2); - --surface-interactive-weak-hover: var(--cobalt-light-3); - --surface-success-base: var(--apple-light-3); - --surface-success-weak: var(--apple-light-2); - --surface-success-strong: var(--apple-light-9); - --surface-warning-base: var(--solaris-light-3); - --surface-warning-weak: var(--solaris-light-2); - --surface-warning-strong: var(--solaris-light-9); - --surface-critical-base: var(--ember-dark-3); - --surface-critical-weak: var(--ember-dark-2); - --surface-critical-strong: var(--ember-dark-9); - --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(--smoke-dark-1); - --surface-diff-skip-base: var(--smoke-dark-alpha-1); - --surface-diff-hidden-base: var(--blue-dark-2); - --surface-diff-hidden-weak: var(--blue-dark-1); - --surface-diff-hidden-weaker: var(--blue-dark-3); - --surface-diff-hidden-strong: var(--blue-dark-5); - --surface-diff-hidden-stronger: var(--blue-dark-11); - --surface-diff-add-base: var(--mint-dark-3); - --surface-diff-add-weak: var(--mint-dark-4); - --surface-diff-add-weaker: var(--mint-dark-3); - --surface-diff-add-strong: var(--mint-dark-5); - --surface-diff-add-stronger: var(--mint-dark-11); - --surface-diff-delete-base: var(--ember-dark-3); - --surface-diff-delete-weak: var(--ember-dark-4); - --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(--smoke-dark-2); - --input-hover: var(--smoke-dark-2); - --input-active: var(--cobalt-dark-1); - --input-selected: var(--cobalt-dark-2); - --input-focus: var(--cobalt-dark-1); - --input-disabled: var(--smoke-dark-4); - --text-base: var(--smoke-dark-alpha-11); - --text-weak: var(--smoke-dark-alpha-9); - --text-weaker: var(--smoke-dark-alpha-8); - --text-strong: var(--smoke-dark-alpha-12); - --text-invert-base: var(--smoke-dark-alpha-11); - --text-invert-weak: var(--smoke-dark-alpha-9); - --text-invert-weaker: var(--smoke-dark-alpha-8); - --text-invert-strong: var(--smoke-dark-alpha-12); - --text-interactive-base: var(--cobalt-dark-11); - --text-on-brand-base: var(--smoke-dark-alpha-11); - --text-on-interactive-base: var(--smoke-dark-12); - --text-on-interactive-weak: var(--smoke-dark-alpha-11); - --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(--smoke-dark-alpha-11); - --text-on-info-base: var(--smoke-dark-alpha-11); - --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(--smoke-dark-alpha-9); - --text-on-info-strong: var(--smoke-dark-alpha-12); - --text-on-warning-weak: var(--smoke-dark-alpha-9); - --text-on-warning-strong: var(--smoke-dark-alpha-12); - --text-on-success-weak: var(--apple-dark-8); - --text-on-success-strong: var(--apple-dark-12); - --text-on-brand-weak: var(--smoke-dark-alpha-9); - --text-on-brand-weaker: var(--smoke-dark-alpha-8); - --text-on-brand-strong: var(--smoke-dark-alpha-12); - --button-primary-base: var(--smoke-dark-12); - --button-secondary-base: #231f1f; - --button-secondary-hover: #2a2727; - --border-base: var(--smoke-dark-alpha-7); - --border-hover: var(--smoke-dark-alpha-8); - --border-active: var(--smoke-dark-alpha-9); - --border-selected: var(--cobalt-dark-alpha-11); - --border-disabled: var(--smoke-dark-alpha-8); - --border-focus: var(--smoke-dark-alpha-9); - --border-weak-base: var(--smoke-dark-alpha-6); - --border-strong-base: var(--smoke-dark-alpha-8); - --border-strong-hover: var(--smoke-dark-alpha-7); - --border-strong-active: var(--smoke-dark-alpha-8); - --border-strong-selected: var(--cobalt-dark-alpha-6); - --border-strong-disabled: var(--smoke-dark-alpha-6); - --border-strong-focus: var(--smoke-dark-alpha-8); - --border-weak-hover: var(--smoke-dark-alpha-7); - --border-weak-active: var(--smoke-dark-alpha-8); - --border-weak-selected: var(--cobalt-dark-alpha-6); - --border-weak-disabled: var(--smoke-dark-alpha-6); - --border-weak-focus: var(--smoke-dark-alpha-8); - --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(--smoke-light-8); - --border-interactive-focus: var(--cobalt-light-9); - --border-success-base: var(--apple-light-6); - --border-success-hover: var(--apple-light-7); - --border-success-selected: var(--apple-light-9); - --border-warning-base: var(--solaris-light-6); - --border-warning-hover: var(--solaris-light-7); - --border-warning-selected: var(--solaris-light-9); - --border-critical-base: var(--ember-dark-5); - --border-critical-hover: var(--ember-dark-7); - --border-critical-selected: var(--ember-dark-9); - --border-info-base: var(--lilac-light-6); - --border-info-hover: var(--lilac-light-7); - --border-info-selected: var(--lilac-light-9); - --icon-base: var(--smoke-dark-9); - --icon-hover: var(--smoke-dark-10); - --icon-active: var(--smoke-dark-11); - --icon-selected: var(--smoke-dark-12); - --icon-disabled: var(--smoke-dark-7); - --icon-focus: var(--smoke-dark-12); - --icon-invert-base: var(--smoke-dark-1); - --icon-weak-base: var(--smoke-dark-6); - --icon-weak-hover: var(--smoke-light-7); - --icon-weak-active: var(--smoke-light-8); - --icon-weak-selected: var(--smoke-light-9); - --icon-weak-disabled: var(--smoke-light-4); - --icon-weak-focus: var(--smoke-light-9); - --icon-strong-base: var(--smoke-dark-12); + --surface-raised-stronger-non-alpha: #1c1c1c; + --surface-brand-base: #fab283; + --surface-brand-hover: #eda779; + --surface-interactive-base: #091f52; + --surface-interactive-hover: #091f52; + --surface-interactive-weak: #0b1730; + --surface-interactive-weak-hover: #ecf3ff; + --surface-success-base: #062d04; + --surface-success-weak: #0a1e08; + --surface-success-strong: #12c905; + --surface-warning-base: #fdf3cf; + --surface-warning-weak: #fdfaed; + --surface-warning-strong: #fcd53a; + --surface-critical-base: #42120b; + --surface-critical-weak: #28110c; + --surface-critical-strong: #fc533a; + --surface-info-base: #feecfe; + --surface-info-weak: #fdf7fe; + --surface-info-strong: #edb2f1; + --surface-diff-unchanged-base: #161616; + --surface-diff-skip-base: #00000000; + --surface-diff-hidden-base: #0c1928; + --surface-diff-hidden-weak: #09131d; + --surface-diff-hidden-weaker: #082542; + --surface-diff-hidden-strong: #073966; + --surface-diff-hidden-stronger: #8ec2fc; + --surface-diff-add-base: #1a2919; + --surface-diff-add-weak: #1f351e; + --surface-diff-add-weaker: #1a2919; + --surface-diff-add-strong: #264024; + --surface-diff-add-stronger: #9bcd97; + --surface-diff-delete-base: #42120b; + --surface-diff-delete-weak: #580f06; + --surface-diff-delete-weaker: #42120b; + --surface-diff-delete-strong: #6a1206; + --surface-diff-delete-stronger: #faa494; + --input-base: #1c1c1c; + --input-hover: #1c1c1c; + --input-active: #091123; + --input-selected: #0b1730; + --input-focus: #091123; + --input-disabled: #282828; + --text-base: rgba(255, 255, 255, 0.618); + --text-weak: rgba(255, 255, 255, 0.422); + --text-weaker: rgba(255, 255, 255, 0.284); + --text-strong: rgba(255, 255, 255, 0.936); + --text-invert-base: #a0a0a0; + --text-invert-weak: #707070; + --text-invert-weaker: #505050; + --text-invert-strong: #ededed; + --text-interactive-base: #9dbefe; + --text-on-brand-base: rgba(255, 255, 255, 0.603); + --text-on-interactive-base: #ededed; + --text-on-interactive-weak: rgba(255, 255, 255, 0.603); + --text-on-success-base: #12c905; + --text-on-critical-base: #fc533a; + --text-on-critical-weak: #b72d1a; + --text-on-critical-strong: #ffe0da; + --text-on-warning-base: rgba(255, 255, 255, 0.603); + --text-on-info-base: rgba(255, 255, 255, 0.603); + --text-diff-add-base: #9bcd97; + --text-diff-delete-base: #fc533a; + --text-diff-delete-strong: #ffe0da; + --text-diff-add-strong: #4a7348; + --text-on-info-weak: rgba(255, 255, 255, 0.404); + --text-on-info-strong: rgba(255, 255, 255, 0.928); + --text-on-warning-weak: rgba(255, 255, 255, 0.404); + --text-on-warning-strong: rgba(255, 255, 255, 0.928); + --text-on-success-weak: #127d0d; + --text-on-success-strong: #bafdb3; + --text-on-brand-weak: rgba(255, 255, 255, 0.404); + --text-on-brand-weaker: rgba(255, 255, 255, 0.266); + --text-on-brand-strong: rgba(255, 255, 255, 0.928); + --button-primary-base: #ededed; + --button-secondary-base: #1c1c1c; + --button-secondary-hover: rgba(255, 255, 255, 0.039); + --button-ghost-hover: rgba(255, 255, 255, 0.031); + --button-ghost-hover2: rgba(255, 255, 255, 0.059); + --border-base: rgba(255, 255, 255, 0.195); + --border-hover: rgba(255, 255, 255, 0.284); + --border-active: rgba(255, 255, 255, 0.418); + --border-selected: #9dbefe; + --border-disabled: rgba(255, 255, 255, 0.284); + --border-focus: rgba(255, 255, 255, 0.418); + --border-weak-base: #282828; + --border-strong-base: rgba(255, 255, 255, 0.266); + --border-strong-hover: rgba(255, 255, 255, 0.266); + --border-strong-active: rgba(255, 255, 255, 0.266); + --border-strong-selected: rgba(3, 76, 255, 0.62); + --border-strong-disabled: rgba(255, 255, 255, 0.138); + --border-strong-focus: rgba(255, 255, 255, 0.266); + --border-weak-hover: rgba(255, 255, 255, 0.181); + --border-weak-active: rgba(255, 255, 255, 0.266); + --border-weak-selected: rgba(3, 76, 255, 0.62); + --border-weak-disabled: rgba(255, 255, 255, 0.138); + --border-weak-focus: rgba(255, 255, 255, 0.266); + --border-weaker-base: #202020; + --border-weaker-hover: rgba(255, 255, 255, 0.084); + --border-weaker-active: rgba(255, 255, 255, 0.138); + --border-weaker-selected: rgba(3, 76, 255, 0.32); + --border-weaker-disabled: rgba(255, 255, 255, 0.034); + --border-weaker-focus: rgba(255, 255, 255, 0.138); + --border-interactive-base: #a3c1fd; + --border-interactive-hover: #7ea9ff; + --border-interactive-active: #034cff; + --border-interactive-selected: #034cff; + --border-interactive-disabled: #505050; + --border-interactive-focus: #034cff; + --border-success-base: #96ec8e; + --border-success-hover: #7add71; + --border-success-selected: #12c905; + --border-warning-base: #e9d282; + --border-warning-hover: #dac063; + --border-warning-selected: #fcd53a; + --border-critical-base: #6a1206; + --border-critical-hover: #952414; + --border-critical-selected: #fc533a; + --border-info-base: #eac5ec; + --border-info-hover: #dab1dd; + --border-info-selected: #edb2f1; + --border-color: #ffffff; + --icon-base: #7e7e7e; + --icon-hover: #a0a0a0; + --icon-active: #ededed; + --icon-selected: #ededed; + --icon-disabled: #3e3e3e; + --icon-focus: #ededed; + --icon-invert-base: #161616; + --icon-weak-base: #343434; + --icon-weak-hover: #d9d9d9; + --icon-weak-active: #c8c8c8; + --icon-weak-selected: #707070; + --icon-weak-disabled: #ededed; + --icon-weak-focus: #707070; + --icon-strong-base: #ededed; --icon-strong-hover: #f6f3f3; --icon-strong-active: #fcfcfc; --icon-strong-selected: #fdfcfc; - --icon-strong-disabled: var(--smoke-dark-8); + --icon-strong-disabled: #3e3e3e; --icon-strong-focus: #fdfcfc; - --icon-brand-base: var(--white); - --icon-interactive-base: var(--cobalt-dark-11); - --icon-success-base: var(--apple-dark-7); - --icon-success-hover: var(--apple-dark-8); - --icon-success-active: var(--apple-dark-11); - --icon-warning-base: var(--amber-dark-9); - --icon-warning-hover: var(--amber-dark-8); - --icon-warning-active: var(--amber-dark-11); - --icon-critical-base: var(--ember-dark-9); - --icon-critical-hover: var(--ember-dark-11); - --icon-critical-active: var(--ember-dark-12); - --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(--smoke-light-alpha-11); - --icon-on-brand-hover: var(--smoke-light-alpha-12); - --icon-on-brand-selected: var(--smoke-light-alpha-12); - --icon-on-interactive-base: var(--smoke-dark-12); - --icon-agent-plan-base: var(--purple-dark-9); - --icon-agent-docs-base: var(--amber-dark-9); - --icon-agent-ask-base: var(--cyan-dark-9); - --icon-agent-build-base: var(--cobalt-dark-11); - --icon-on-success-base: var(--apple-dark-alpha-9); - --icon-on-success-hover: var(--apple-dark-alpha-10); - --icon-on-success-selected: var(--apple-dark-alpha-11); - --icon-on-warning-base: var(--amber-darkalpha-9); - --icon-on-warning-hover: var(--amber-darkalpha-10); - --icon-on-warning-selected: var(--amber-darkalpha-11); - --icon-on-critical-base: var(--ember-dark-alpha-9); - --icon-on-critical-hover: var(--ember-dark-alpha-10); - --icon-on-critical-selected: var(--ember-dark-alpha-11); - --icon-on-info-base: var(--lilac-dark-9); - --icon-on-info-hover: var(--lilac-dark-alpha-10); - --icon-on-info-selected: var(--lilac-dark-alpha-11); - --icon-diff-add-base: var(--mint-dark-11); - --icon-diff-add-hover: var(--mint-dark-10); - --icon-diff-add-active: var(--mint-dark-11); - --icon-diff-delete-base: var(--ember-dark-9); - --icon-diff-delete-hover: var(--ember-dark-10); + --icon-brand-base: #ffffff; + --icon-interactive-base: #034cff; + --icon-success-base: #12c905; + --icon-success-hover: #35c02d; + --icon-success-active: #4de144; + --icon-warning-base: #fbb73c; + --icon-warning-hover: #885e08; + --icon-warning-active: #f1b13f; + --icon-critical-base: #fc533a; + --icon-critical-hover: #faa494; + --icon-critical-active: #ffe0da; + --icon-info-base: #68446b; + --icon-info-hover: #815484; + --icon-info-active: #dfa7e3; + --icon-on-brand-base: rgba(255, 255, 255, 0.603); + --icon-on-brand-hover: rgba(255, 255, 255, 0.928); + --icon-on-brand-selected: rgba(255, 255, 255, 0.928); + --icon-on-interactive-base: #ededed; + --icon-agent-plan-base: #edb2f1; + --icon-agent-docs-base: #fbb73c; + --icon-agent-ask-base: #2090f5; + --icon-agent-build-base: #9dbefe; + --icon-on-success-base: rgba(18, 201, 5, 0.9); + --icon-on-success-hover: rgba(53, 192, 45, 0.9); + --icon-on-success-selected: rgba(77, 225, 68, 0.9); + --icon-on-warning-base: rgba(251, 183, 60, 0.9); + --icon-on-warning-hover: rgba(245, 178, 56, 0.9); + --icon-on-warning-selected: rgba(241, 177, 63, 0.9); + --icon-on-critical-base: rgba(252, 83, 58, 0.9); + --icon-on-critical-hover: rgba(245, 79, 54, 0.9); + --icon-on-critical-selected: rgba(250, 164, 148, 0.9); + --icon-on-info-base: #edb2f1; + --icon-on-info-hover: rgba(231, 173, 235, 0.9); + --icon-on-info-selected: rgba(223, 167, 227, 0.9); + --icon-diff-add-base: #9bcd97; + --icon-diff-add-hover: #c3f9bf; + --icon-diff-add-active: #9bcd97; + --icon-diff-delete-base: #fc533a; + --icon-diff-delete-hover: #f54f36; --icon-diff-modified-base: #ffba92; --syntax-comment: var(--text-weak); --syntax-regexp: var(--text-base); @@ -560,12 +570,12 @@ --syntax-constant: #93e9f6; --syntax-punctuation: var(--text-weak); --syntax-object: var(--text-strong); - --syntax-success: var(--apple-dark-10); - --syntax-warning: var(--amber-dark-10); - --syntax-critical: var(--ember-dark-10); + --syntax-success: #35c02d; + --syntax-warning: #f5b238; + --syntax-critical: #f54f36; --syntax-info: #93e9f6; - --syntax-diff-add: var(--mint-dark-11); - --syntax-diff-delete: var(--ember-dark-11); + --syntax-diff-add: #9bcd97; + --syntax-diff-delete: #faa494; --syntax-diff-unknown: #ff0000; --markdown-heading: #9d7cd8; --markdown-text: #eeeeee; @@ -581,15 +591,6 @@ --markdown-image: #fab283; --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); - --button-ghost-hover: var(--smoke-dark-alpha-2); - --button-ghost-hover2: var(--smoke-dark-alpha-3); --avatar-background-pink: #501b3f; --avatar-background-mint: #033a34; --avatar-background-orange: #5f2a06; @@ -602,5 +603,6 @@ --avatar-text-purple: #9d5bd2; --avatar-text-cyan: #369eff; --avatar-text-lime: #c4f042; + --text-stronger: rgba(255, 255, 255, 0.936); } } diff --git a/packages/ui/src/theme/color.ts b/packages/ui/src/theme/color.ts index f0e15211e9..89d9a653d7 100644 --- a/packages/ui/src/theme/color.ts +++ b/packages/ui/src/theme/color.ts @@ -1,16 +1,25 @@ import type { HexColor, OklchColor } from "./types" +function clamp(v: number, min: number, max: number) { + return Math.max(min, Math.min(max, v)) +} + +function hue(v: number) { + return ((v % 360) + 360) % 360 +} + export function hexToRgb(hex: HexColor): { r: number; g: number; b: number } { const h = hex.replace("#", "") const full = - h.length === 3 + h.length === 3 || h.length === 4 ? h .split("") .map((c) => c + c) .join("") : h + const rgb = full.length === 8 ? full.slice(0, 6) : full - const num = parseInt(full, 16) + const num = parseInt(rgb, 16) return { r: ((num >> 16) & 255) / 255, g: ((num >> 8) & 255) / 255, @@ -20,7 +29,7 @@ export function hexToRgb(hex: HexColor): { r: number; g: number; b: number } { export function rgbToHex(r: number, g: number, b: number): HexColor { const toHex = (v: number) => { - const clamped = Math.max(0, Math.min(1, v)) + const clamped = clamp(v, 0, 1) const int = Math.round(clamped * 255) return int.toString(16).padStart(2, "0") } @@ -91,8 +100,33 @@ export function hexToOklch(hex: HexColor): OklchColor { return rgbToOklch(r, g, b) } +export function fitOklch(oklch: OklchColor): OklchColor { + const base = { + l: clamp(oklch.l, 0, 1), + c: Math.max(0, oklch.c), + h: hue(oklch.h), + } + + const rgb = oklchToRgb(base) + if (rgb.r >= 0 && rgb.r <= 1 && rgb.g >= 0 && rgb.g <= 1 && rgb.b >= 0 && rgb.b <= 1) { + return base + } + + let c = base.c + for (let i = 0; i < 24; i++) { + c *= 0.9 + const next = { ...base, c } + const out = oklchToRgb(next) + if (out.r >= 0 && out.r <= 1 && out.g >= 0 && out.g <= 1 && out.b >= 0 && out.b <= 1) { + return next + } + } + + return { ...base, c: 0 } +} + export function oklchToHex(oklch: OklchColor): HexColor { - const { r, g, b } = oklchToRgb(oklch) + const { r, g, b } = oklchToRgb(fitOklch(oklch)) return rgbToHex(r, g, b) } @@ -101,12 +135,12 @@ export function generateScale(seed: HexColor, isDark: boolean): HexColor[] { const scale: HexColor[] = [] const lightSteps = isDark - ? [0.15, 0.18, 0.22, 0.26, 0.32, 0.38, 0.46, 0.56, base.l, base.l - 0.05, 0.75, 0.93] - : [0.99, 0.97, 0.94, 0.9, 0.85, 0.79, 0.72, 0.64, base.l, base.l + 0.05, 0.45, 0.25] + ? [0.182, 0.21, 0.261, 0.302, 0.341, 0.387, 0.443, 0.514, base.l, Math.max(0, base.l - 0.017), 0.8, 0.93] + : [0.993, 0.983, 0.962, 0.936, 0.906, 0.866, 0.811, 0.74, base.l, Math.max(0, base.l - 0.036), 0.548, 0.33] const chromaMultipliers = isDark - ? [0.15, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.85, 1, 1, 0.9, 0.6] - : [0.1, 0.15, 0.25, 0.35, 0.45, 0.55, 0.7, 0.85, 1, 1, 0.95, 0.85] + ? [0.205, 0.275, 0.46, 0.62, 0.71, 0.79, 0.87, 0.97, 1.04, 1.03, 1, 0.58] + : [0.045, 0.128, 0.34, 0.5, 0.61, 0.69, 0.77, 0.89, 1, 1, 0.97, 0.56] for (let i = 0; i < 12; i++) { scale.push( @@ -127,8 +161,8 @@ export function generateNeutralScale(seed: HexColor, isDark: boolean): HexColor[ const neutralChroma = Math.min(base.c, 0.02) const lightSteps = isDark - ? [0.13, 0.16, 0.2, 0.24, 0.28, 0.33, 0.4, 0.52, 0.58, 0.66, 0.82, 0.96] - : [0.995, 0.98, 0.96, 0.94, 0.91, 0.88, 0.84, 0.78, 0.62, 0.56, 0.46, 0.2] + ? [0.2, 0.226, 0.256, 0.277, 0.301, 0.325, 0.364, 0.431, base.l, 0.593, 0.706, 0.946] + : [0.991, 0.979, 0.964, 0.946, 0.931, 0.913, 0.891, 0.83, base.l, 0.617, 0.542, 0.205] for (let i = 0; i < 12; i++) { scale.push( @@ -164,19 +198,39 @@ export function generateAlphaScale(scale: HexColor[], isDark: boolean): HexColor export function mixColors(color1: HexColor, color2: HexColor, amount: number): HexColor { const c1 = hexToOklch(color1) const c2 = hexToOklch(color2) + const delta = ((((c2.h - c1.h) % 360) + 540) % 360) - 180 return oklchToHex({ l: c1.l + (c2.l - c1.l) * amount, c: c1.c + (c2.c - c1.c) * amount, - h: c1.h + (c2.h - c1.h) * amount, + h: c1.h + delta * amount, }) } +export function shift(color: HexColor, value: { l?: number; c?: number; h?: number }): HexColor { + const base = hexToOklch(color) + return oklchToHex({ + l: base.l + (value.l ?? 0), + c: base.c * (value.c ?? 1), + h: base.h + (value.h ?? 0), + }) +} + +export function blend(color: HexColor, background: HexColor, alpha: number): HexColor { + const fg = hexToRgb(color) + const bg = hexToRgb(background) + return rgbToHex( + fg.r * alpha + bg.r * (1 - alpha), + fg.g * alpha + bg.g * (1 - alpha), + fg.b * alpha + bg.b * (1 - alpha), + ) +} + export function lighten(color: HexColor, amount: number): HexColor { const oklch = hexToOklch(color) return oklchToHex({ ...oklch, - l: Math.min(1, oklch.l + amount), + l: clamp(oklch.l + amount, 0, 1), }) } @@ -184,7 +238,7 @@ export function darken(color: HexColor, amount: number): HexColor { const oklch = hexToOklch(color) return oklchToHex({ ...oklch, - l: Math.max(0, oklch.l - amount), + l: clamp(oklch.l - amount, 0, 1), }) } diff --git a/packages/ui/src/theme/context.tsx b/packages/ui/src/theme/context.tsx index c1c1637d67..600c6121c3 100644 --- a/packages/ui/src/theme/context.tsx +++ b/packages/ui/src/theme/context.tsx @@ -35,7 +35,7 @@ function applyThemeCss(theme: DesktopTheme, themeId: string, mode: "light" | "da const tokens = resolveThemeVariant(variant, isDark) const css = themeToCss(tokens) - if (themeId !== "oc-1") { + if (themeId !== "oc-2") { try { localStorage.setItem(isDark ? STORAGE_KEYS.THEME_CSS_DARK : STORAGE_KEYS.THEME_CSS_LIGHT, css) } catch {} @@ -54,7 +54,7 @@ function applyThemeCss(theme: DesktopTheme, themeId: string, mode: "light" | "da } function cacheThemeVariants(theme: DesktopTheme, themeId: string) { - if (themeId === "oc-1") return + if (themeId === "oc-2") return for (const mode of ["light", "dark"] as const) { const isDark = mode === "dark" const variant = isDark ? theme.dark : theme.light @@ -71,7 +71,7 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({ init: (props: { defaultTheme?: string }) => { const [store, setStore] = createStore({ themes: DEFAULT_THEMES as Record, - themeId: props.defaultTheme ?? "oc-1", + themeId: props.defaultTheme ?? "oc-2", colorScheme: "system" as ColorScheme, mode: getSystemMode(), previewThemeId: null as string | null, diff --git a/packages/ui/src/theme/default-themes.ts b/packages/ui/src/theme/default-themes.ts index 52b2b42eba..657d21c3cc 100644 --- a/packages/ui/src/theme/default-themes.ts +++ b/packages/ui/src/theme/default-themes.ts @@ -34,8 +34,8 @@ export const gruvboxTheme = gruvboxThemeJson as DesktopTheme export const auraTheme = auraThemeJson as DesktopTheme export const DEFAULT_THEMES: Record = { - "oc-1": oc1Theme, "oc-2": oc2Theme, + "oc-1": oc1Theme, aura: auraTheme, ayu: ayuTheme, carbonfox: carbonfoxTheme, diff --git a/packages/ui/src/theme/desktop-theme.schema.json b/packages/ui/src/theme/desktop-theme.schema.json index b60a8f37ca..d4f1ffd21f 100644 --- a/packages/ui/src/theme/desktop-theme.schema.json +++ b/packages/ui/src/theme/desktop-theme.schema.json @@ -36,12 +36,13 @@ }, "ColorValue": { "type": "string", - "pattern": "^(#([0-9a-fA-F]{3}|[0-9a-fA-F]{4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})|var\(--[a-z0-9-]+\))$", + "pattern": "^(#([0-9a-fA-F]{3}|[0-9a-fA-F]{4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})|var\\(--[a-z0-9-]+\\))$", "description": "Either a hex color value (#rgb/#rgba/#rrggbb/#rrggbbaa) or a CSS variable reference" }, "ThemeSeedColors": { "type": "object", - "description": "The minimum set of colors needed to generate a theme", + "description": "The legacy semantic seed set used to generate a theme", + "additionalProperties": false, "required": ["neutral", "primary", "success", "warning", "error", "info", "interactive", "diffAdd", "diffDelete"], "properties": { "neutral": { @@ -82,14 +83,70 @@ } } }, + "ThemePaletteColors": { + "type": "object", + "description": "A compact semantic palette used to derive the full theme programmatically", + "additionalProperties": false, + "required": ["neutral", "primary", "success", "warning", "error", "info"], + "properties": { + "neutral": { + "$ref": "#/definitions/HexColor", + "description": "Base neutral color for generating the gray scale" + }, + "ink": { + "$ref": "#/definitions/HexColor", + "description": "Optional foreground or chrome color used to derive text and border tones" + }, + "primary": { + "$ref": "#/definitions/HexColor", + "description": "Primary brand color used for brand surfaces and strong emphasis" + }, + "success": { + "$ref": "#/definitions/HexColor", + "description": "Success state color" + }, + "warning": { + "$ref": "#/definitions/HexColor", + "description": "Warning state color" + }, + "error": { + "$ref": "#/definitions/HexColor", + "description": "Error or critical state color" + }, + "info": { + "$ref": "#/definitions/HexColor", + "description": "Informational state color" + }, + "accent": { + "$ref": "#/definitions/HexColor", + "description": "Optional extra expressive accent for syntax and rich content" + }, + "interactive": { + "$ref": "#/definitions/HexColor", + "description": "Optional dedicated interactive color; falls back to primary" + }, + "diffAdd": { + "$ref": "#/definitions/HexColor", + "description": "Optional diff-add seed; falls back to a softened success color" + }, + "diffDelete": { + "$ref": "#/definitions/HexColor", + "description": "Optional diff-delete seed; falls back to error" + } + } + }, "ThemeVariant": { "type": "object", - "description": "A theme variant (light or dark) with seed colors and optional overrides", - "required": ["seeds"], + "description": "A theme variant (light or dark) with either a compact palette or legacy seeds and optional overrides", + "oneOf": [{ "required": ["seeds"] }, { "required": ["palette"] }], "properties": { "seeds": { "$ref": "#/definitions/ThemeSeedColors", - "description": "Seed colors used to generate the full palette" + "description": "Legacy seed colors used to generate the full palette" + }, + "palette": { + "$ref": "#/definitions/ThemePaletteColors", + "description": "Compact palette used to derive the full token set" }, "overrides": { "type": "object", diff --git a/packages/ui/src/theme/index.ts b/packages/ui/src/theme/index.ts index d2c60179ec..1e6fb79324 100644 --- a/packages/ui/src/theme/index.ts +++ b/packages/ui/src/theme/index.ts @@ -1,5 +1,6 @@ export type { DesktopTheme, + ThemePaletteColors, ThemeSeedColors, ThemeVariant, HexColor, @@ -19,7 +20,10 @@ export { generateScale, generateNeutralScale, generateAlphaScale, + fitOklch, + blend, mixColors, + shift, lighten, darken, withAlpha, diff --git a/packages/ui/src/theme/loader.ts b/packages/ui/src/theme/loader.ts index 0f61076a00..4d48000daf 100644 --- a/packages/ui/src/theme/loader.ts +++ b/packages/ui/src/theme/loader.ts @@ -27,7 +27,7 @@ export function applyTheme(theme: DesktopTheme, themeId?: string): void { } function buildThemeCss(light: ResolvedTheme, dark: ResolvedTheme, themeId: string): string { - const isDefaultTheme = themeId === "oc-1" + const isDefaultTheme = themeId === "oc-2" const lightCss = themeToCss(light) const darkCss = themeToCss(dark) diff --git a/packages/ui/src/theme/resolve.ts b/packages/ui/src/theme/resolve.ts index f098e8028a..722648dabc 100644 --- a/packages/ui/src/theme/resolve.ts +++ b/packages/ui/src/theme/resolve.ts @@ -1,27 +1,131 @@ import type { ColorValue, DesktopTheme, HexColor, ResolvedTheme, ThemeVariant } from "./types" -import { generateNeutralScale, generateScale, hexToOklch, oklchToHex, withAlpha } from "./color" +import { blend, generateNeutralScale, generateScale, hexToOklch, oklchToHex, shift, withAlpha } from "./color" export function resolveThemeVariant(variant: ThemeVariant, isDark: boolean): ResolvedTheme { - const { seeds, overrides = {} } = variant + const colors = getColors(variant) + const { overrides = {} } = variant - const neutral = generateNeutralScale(seeds.neutral, isDark) - const primary = generateScale(seeds.primary, isDark) - const success = generateScale(seeds.success, isDark) - const warning = generateScale(seeds.warning, isDark) - const error = generateScale(seeds.error, isDark) - const info = generateScale(seeds.info, isDark) - const interactive = generateScale(seeds.interactive, isDark) - const diffAdd = generateScale(seeds.diffAdd, isDark) - const diffDelete = generateScale(seeds.diffDelete, isDark) + const neutral = generateNeutralScale(colors.neutral, isDark) + const primary = generateScale(colors.primary, isDark) + const accent = generateScale(colors.accent, isDark) + const success = generateScale(colors.success, isDark) + const warning = generateScale(colors.warning, isDark) + const error = generateScale(colors.error, isDark) + const info = generateScale(colors.info, isDark) + const interactive = generateScale(colors.interactive, isDark) + const hasInk = colors.compact && Boolean(colors.ink) + const noInk = colors.compact && !hasInk + const shadow = noInk && !isDark ? generateNeutralScale(colors.neutral, true) : neutral + const amber = generateScale( + shift(colors.warning, isDark ? { h: -16, l: -0.058, c: 1.14 } : { h: -22, l: -0.082, c: 0.94 }), + isDark, + ) + const blue = generateScale(shift(colors.interactive, { h: -12, l: 0.128, c: 1.12 }), isDark) + const brandl = noInk && isDark ? generateScale(colors.primary, false) : primary + const successl = noInk && isDark ? generateScale(colors.success, false) : success + const warningl = noInk && isDark ? generateScale(colors.warning, false) : warning + const infol = noInk && isDark ? generateScale(colors.info, false) : info + const interl = noInk && isDark ? generateScale(colors.interactive, false) : interactive + const diffAdd = generateScale( + colors.diffAdd ?? + (noInk + ? shift(colors.success, { c: isDark ? 0.54 : 0.6, l: isDark ? 0.22 : 0.16 }) + : shift(colors.success, { c: isDark ? 0.7 : 0.55, l: isDark ? -0.18 : 0.14 })), + isDark, + ) + const diffDelete = generateScale( + colors.diffDelete ?? + (noInk ? colors.error : shift(colors.error, { c: isDark ? 0.82 : 0.7, l: isDark ? -0.08 : 0.08 })), + isDark, + ) + const ink = colors.ink ?? colors.neutral + const backgroundOverride = overrides["background-base"] + const backgroundHex = getHex(backgroundOverride) + const overlay = noInk || (Boolean(backgroundOverride) && !backgroundHex) + const content = (seed: HexColor, scale: HexColor[]) => { + const value = isDark ? seed : hexToOklch(seed).l > 0.82 ? scale[10] : seed + return shift(value, { c: isDark ? 1.16 : 1.1 }) + } + const modified = () => { + if (!colors.compact) return isDark ? "#ffba92" : "#FF8C00" + if (!hasInk) return isDark ? "#ffba92" : "#FF8C00" + const warningHue = hexToOklch(colors.warning).h + const deleteHue = hexToOklch(colors.diffDelete ?? colors.error).h + const delta = Math.abs(((((deleteHue - warningHue) % 360) + 540) % 360) - 180) + if (delta < 48) return isDark ? "#ffba92" : "#FF8C00" + return content(colors.warning, warning) + } + const surface = ( + seed: HexColor, + alpha: { base: number; weak: number; weaker: number; strong: number; stronger: number }, + ) => { + const base = alphaTone(seed, alpha.base) + return { + base, + weak: alphaTone(seed, alpha.weak), + weaker: alphaTone(seed, alpha.weaker), + strong: alphaTone(seed, alpha.strong), + stronger: alphaTone(seed, alpha.stronger), + } + } + const compactBackground = + colors.compact && !hasInk + ? isDark + ? { + base: shift(blend(colors.neutral, "#000000", 0.145), { c: 0 }), + weak: shift(blend(colors.neutral, "#000000", 0.27), { c: 0 }), + strong: shift(blend(colors.neutral, "#000000", 0.165), { c: 0 }), + stronger: shift(blend(colors.neutral, "#000000", 0.19), { c: 0 }), + } + : { + base: blend(colors.neutral, "#ffffff", 0.066), + weak: blend(colors.neutral, "#ffffff", 0.11), + strong: blend(colors.neutral, "#ffffff", 0.024), + stronger: blend(colors.neutral, "#ffffff", 0.024), + } + : undefined + const compactInkBackground = + colors.compact && hasInk && isDark + ? { + base: neutral[2], + weak: neutral[3], + strong: neutral[1], + stronger: neutral[2], + } + : undefined - const neutralAlpha = generateNeutralAlphaScale(neutral, isDark) + const background = backgroundHex ?? compactInkBackground?.base ?? compactBackground?.base ?? neutral[0] + const alphaTone = (color: HexColor, alpha: number) => + overlay ? (withAlpha(color, alpha) as ColorValue) : blend(color, background, alpha) + const borderTone = (light: number, dark: number) => + alphaTone( + ink, + isDark ? Math.min(1, dark + 0.024 + (colors.compact && hasInk ? 0.08 : 0)) : Math.min(1, light + 0.024), + ) + const diffHiddenSurface = noInk + ? { + base: blue[isDark ? 1 : 2], + weak: blue[isDark ? 0 : 1], + weaker: blue[isDark ? 2 : 0], + strong: blue[4], + stronger: blue[isDark ? 10 : 8], + } + : surface( + isDark ? shift(colors.interactive, { c: 0.55, l: 0 }) : shift(colors.interactive, { c: 0.45, l: 0.08 }), + isDark + ? { base: 0.14, weak: 0.08, weaker: 0.18, strong: 0.26, stronger: 0.42 } + : { base: 0.12, weak: 0.08, weaker: 0.16, strong: 0.24, stronger: 0.36 }, + ) + + const neutralAlpha = noInk ? generateNeutralOverlayScale(neutral, isDark) : generateNeutralAlphaScale(neutral, isDark) const tokens: ResolvedTheme = {} - tokens["background-base"] = neutral[0] - tokens["background-weak"] = neutral[2] - tokens["background-strong"] = neutral[0] - tokens["background-stronger"] = isDark ? neutral[1] : "#fcfcfc" + tokens["background-base"] = compactInkBackground?.base ?? compactBackground?.base ?? neutral[0] + tokens["background-weak"] = compactInkBackground?.weak ?? compactBackground?.weak ?? neutral[2] + tokens["background-strong"] = compactInkBackground?.strong ?? compactBackground?.strong ?? neutral[0] + tokens["background-stronger"] = + compactInkBackground?.stronger ?? compactBackground?.stronger ?? (isDark ? neutral[1] : "#fcfcfc") tokens["surface-base"] = neutralAlpha[1] tokens["base"] = neutralAlpha[1] @@ -37,8 +141,8 @@ export function resolveThemeVariant(variant: ThemeVariant, isDark: boolean): Res : (withAlpha(neutral[3], 0.09) as ColorValue) tokens["surface-inset-strong-hover"] = tokens["surface-inset-strong"] tokens["surface-raised-base"] = neutralAlpha[0] - tokens["surface-float-base"] = isDark ? neutral[0] : neutral[11] - tokens["surface-float-base-hover"] = isDark ? neutral[1] : neutral[10] + tokens["surface-float-base"] = isDark ? neutral[0] : noInk ? shadow[0] : neutral[11] + tokens["surface-float-base-hover"] = isDark ? neutral[1] : noInk ? shadow[1] : neutral[10] tokens["surface-raised-base-hover"] = neutralAlpha[1] tokens["surface-raised-base-active"] = neutralAlpha[2] tokens["surface-raised-strong"] = isDark ? neutralAlpha[3] : neutral[0] @@ -50,34 +154,34 @@ export function resolveThemeVariant(variant: ThemeVariant, isDark: boolean): Res tokens["surface-strong"] = isDark ? neutralAlpha[6] : "#ffffff" tokens["surface-raised-stronger-non-alpha"] = isDark ? neutral[2] : "#ffffff" - tokens["surface-brand-base"] = primary[8] - tokens["surface-brand-hover"] = primary[9] + tokens["surface-brand-base"] = brandl[8] + tokens["surface-brand-hover"] = brandl[9] - tokens["surface-interactive-base"] = interactive[2] - tokens["surface-interactive-hover"] = interactive[3] - tokens["surface-interactive-weak"] = interactive[1] - tokens["surface-interactive-weak-hover"] = interactive[2] + tokens["surface-interactive-base"] = interactive[isDark ? 4 : 3] + tokens["surface-interactive-hover"] = interactive[isDark ? 5 : 4] + tokens["surface-interactive-weak"] = interactive[isDark ? 3 : 2] + tokens["surface-interactive-weak-hover"] = noInk && isDark ? interl[4] : interactive[isDark ? 4 : 3] - tokens["surface-success-base"] = success[2] - tokens["surface-success-weak"] = success[1] - tokens["surface-success-strong"] = success[8] - tokens["surface-warning-base"] = warning[2] - tokens["surface-warning-weak"] = warning[1] - tokens["surface-warning-strong"] = warning[8] - tokens["surface-critical-base"] = error[2] - tokens["surface-critical-weak"] = error[1] - tokens["surface-critical-strong"] = error[8] - tokens["surface-info-base"] = info[2] - tokens["surface-info-weak"] = info[1] - tokens["surface-info-strong"] = info[8] + tokens["surface-success-base"] = success[isDark ? 4 : 3] + tokens["surface-success-weak"] = success[isDark ? 3 : 2] + tokens["surface-success-strong"] = success[9] + tokens["surface-warning-base"] = (noInk && isDark ? warningl : warning)[isDark ? 4 : 3] + tokens["surface-warning-weak"] = (noInk && isDark ? warningl : warning)[isDark ? 3 : 2] + tokens["surface-warning-strong"] = (noInk && isDark ? warningl : warning)[9] + tokens["surface-critical-base"] = error[isDark ? 4 : 3] + tokens["surface-critical-weak"] = error[isDark ? 3 : 2] + tokens["surface-critical-strong"] = error[9] + tokens["surface-info-base"] = (noInk && isDark ? infol : info)[isDark ? 4 : 3] + tokens["surface-info-weak"] = (noInk && isDark ? infol : info)[isDark ? 3 : 2] + tokens["surface-info-strong"] = (noInk && isDark ? infol : info)[9] tokens["surface-diff-unchanged-base"] = isDark ? neutral[0] : "#ffffff00" tokens["surface-diff-skip-base"] = isDark ? neutralAlpha[0] : neutral[1] - tokens["surface-diff-hidden-base"] = interactive[isDark ? 1 : 2] - tokens["surface-diff-hidden-weak"] = interactive[isDark ? 0 : 1] - tokens["surface-diff-hidden-weaker"] = interactive[isDark ? 2 : 0] - tokens["surface-diff-hidden-strong"] = interactive[4] - tokens["surface-diff-hidden-stronger"] = interactive[isDark ? 10 : 8] + tokens["surface-diff-hidden-base"] = diffHiddenSurface.base + tokens["surface-diff-hidden-weak"] = diffHiddenSurface.weak + tokens["surface-diff-hidden-weaker"] = diffHiddenSurface.weaker + tokens["surface-diff-hidden-strong"] = diffHiddenSurface.strong + tokens["surface-diff-hidden-stronger"] = diffHiddenSurface.stronger tokens["surface-diff-add-base"] = diffAdd[2] tokens["surface-diff-add-weak"] = diffAdd[isDark ? 3 : 1] tokens["surface-diff-add-weaker"] = diffAdd[isDark ? 2 : 0] @@ -96,10 +200,36 @@ export function resolveThemeVariant(variant: ThemeVariant, isDark: boolean): Res tokens["input-focus"] = interactive[0] tokens["input-disabled"] = neutral[3] - tokens["text-base"] = neutral[10] - tokens["text-weak"] = neutral[8] - tokens["text-weaker"] = neutral[7] - tokens["text-strong"] = neutral[11] + tokens["text-base"] = hasInk ? ink : noInk ? (isDark ? neutralAlpha[10] : neutral[10]) : neutral[10] + tokens["text-weak"] = hasInk + ? shift(ink, { l: isDark ? -0.18 : 0.16, c: 0.88 }) + : noInk + ? isDark + ? neutralAlpha[8] + : neutral[8] + : neutral[8] + tokens["text-weaker"] = hasInk + ? shift(ink, { l: isDark ? -0.3 : 0.26, c: isDark ? 0.74 : 0.68 }) + : noInk + ? isDark + ? neutralAlpha[7] + : neutral[7] + : neutral[7] + tokens["text-strong"] = hasInk + ? isDark && colors.compact + ? blend("#ffffff", ink, 0.82) + : shift(ink, { l: isDark ? 0.06 : -0.09, c: 1 }) + : noInk + ? isDark + ? neutralAlpha[11] + : neutral[11] + : neutral[11] + if (noInk && isDark) { + tokens["text-base"] = withAlpha("#ffffff", 0.618) as ColorValue + tokens["text-weak"] = withAlpha("#ffffff", 0.422) as ColorValue + tokens["text-weaker"] = withAlpha("#ffffff", 0.284) as ColorValue + tokens["text-strong"] = withAlpha("#ffffff", 0.936) as ColorValue + } tokens["text-invert-base"] = isDark ? neutral[10] : neutral[1] tokens["text-invert-weak"] = isDark ? neutral[8] : neutral[2] tokens["text-invert-weaker"] = isDark ? neutral[7] : neutral[3] @@ -128,84 +258,166 @@ export function resolveThemeVariant(variant: ThemeVariant, isDark: boolean): Res tokens["text-on-brand-weaker"] = neutralAlpha[7] tokens["text-on-brand-strong"] = neutralAlpha[11] - tokens["button-secondary-base"] = isDark ? neutral[2] : neutral[0] - tokens["button-secondary-hover"] = isDark ? neutral[3] : neutral[1] + tokens["button-primary-base"] = neutral[11] + tokens["button-secondary-base"] = noInk ? (isDark ? neutral[1] : neutral[0]) : isDark ? neutral[2] : neutral[0] + tokens["button-secondary-hover"] = noInk ? (isDark ? neutral[1] : neutral[1]) : isDark ? neutral[3] : neutral[1] tokens["button-ghost-hover"] = neutralAlpha[1] tokens["button-ghost-hover2"] = neutralAlpha[2] - tokens["border-base"] = neutralAlpha[6] - tokens["border-hover"] = neutralAlpha[7] - tokens["border-active"] = neutralAlpha[8] - tokens["border-selected"] = withAlpha(interactive[8], isDark ? 0.9 : 0.99) as ColorValue - tokens["border-disabled"] = neutralAlpha[7] - tokens["border-focus"] = neutralAlpha[8] - tokens["border-weak-base"] = neutralAlpha[isDark ? 5 : 4] - tokens["border-strong-base"] = neutralAlpha[isDark ? 7 : 6] - tokens["border-strong-hover"] = neutralAlpha[7] - tokens["border-strong-active"] = neutralAlpha[isDark ? 7 : 6] - tokens["border-strong-selected"] = withAlpha(interactive[5], 0.6) as ColorValue - tokens["border-strong-disabled"] = neutralAlpha[5] - tokens["border-strong-focus"] = neutralAlpha[isDark ? 7 : 6] - tokens["border-weak-hover"] = neutralAlpha[isDark ? 6 : 5] - tokens["border-weak-active"] = neutralAlpha[isDark ? 7 : 6] - tokens["border-weak-selected"] = withAlpha(interactive[4], isDark ? 0.6 : 0.5) as ColorValue - 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] + if (noInk) { + const tone = (alpha: number) => alphaTone((isDark ? "#ffffff" : "#000000") as HexColor, alpha) + if (isDark) { + tokens["surface-base"] = tone(0.031) + tokens["surface-base-hover"] = tone(0.039) + tokens["surface-base-active"] = tone(0.059) + tokens["surface-raised-base"] = tone(0.059) + tokens["surface-raised-base-hover"] = tone(0.078) + tokens["surface-raised-base-active"] = tone(0.102) + tokens["surface-raised-strong"] = tone(0.078) + tokens["surface-raised-strong-hover"] = tone(0.129) + tokens["surface-raised-stronger"] = tone(0.129) + tokens["surface-raised-stronger-hover"] = tone(0.169) + tokens["surface-weak"] = tone(0.078) + tokens["surface-weaker"] = tone(0.102) + tokens["surface-strong"] = tone(0.169) + tokens["surface-raised-stronger-non-alpha"] = neutral[1] + tokens["surface-inset-base"] = withAlpha("#000000", 0.5) as ColorValue + tokens["surface-inset-base-hover"] = tokens["surface-inset-base"] + tokens["surface-inset-strong"] = withAlpha("#000000", 0.8) as ColorValue + tokens["surface-inset-strong-hover"] = tokens["surface-inset-strong"] + tokens["button-secondary-hover"] = tone(0.039) + tokens["button-ghost-hover"] = tone(0.031) + tokens["button-ghost-hover2"] = tone(0.059) + tokens["input-base"] = neutral[1] + tokens["input-hover"] = neutral[1] + tokens["input-selected"] = interactive[1] + tokens["surface-diff-skip-base"] = "#00000000" + } - tokens["border-interactive-base"] = interactive[6] - tokens["border-interactive-hover"] = interactive[7] - tokens["border-interactive-active"] = interactive[8] - tokens["border-interactive-selected"] = interactive[8] + if (!isDark) { + tokens["surface-base"] = tone(0.031) + tokens["surface-base-hover"] = tone(0.059) + tokens["surface-base-active"] = tone(0.051) + tokens["surface-raised-base"] = tone(0.031) + tokens["surface-raised-base-hover"] = tone(0.051) + tokens["surface-raised-base-active"] = tone(0.09) + tokens["surface-raised-strong"] = neutral[0] + tokens["surface-raised-strong-hover"] = "#ffffff" + tokens["surface-raised-stronger"] = "#ffffff" + tokens["surface-raised-stronger-hover"] = "#ffffff" + tokens["surface-weak"] = tone(0.051) + tokens["surface-weaker"] = tone(0.071) + tokens["surface-strong"] = "#ffffff" + tokens["surface-raised-stronger-non-alpha"] = "#ffffff" + tokens["surface-inset-strong"] = tone(0.09) + tokens["surface-inset-strong-hover"] = tokens["surface-inset-strong"] + tokens["button-secondary-hover"] = blend("#ffffff", background, 0.04) + tokens["button-ghost-hover"] = tone(0.031) + tokens["button-ghost-hover2"] = tone(0.051) + tokens["input-base"] = neutral[0] + tokens["input-hover"] = neutral[1] + } + + tokens["surface-base-interactive-active"] = withAlpha(colors.interactive, isDark ? 0.125 : 0.09) as ColorValue + } + + tokens["border-base"] = hasInk ? borderTone(0.22, 0.16) : neutralAlpha[6] + tokens["border-hover"] = hasInk ? borderTone(0.28, 0.2) : neutralAlpha[7] + tokens["border-active"] = hasInk ? borderTone(0.34, 0.24) : neutralAlpha[8] + tokens["border-selected"] = noInk + ? isDark + ? interactive[10] + : (withAlpha(colors.interactive, 0.99) as ColorValue) + : (withAlpha(interactive[8], isDark ? 0.9 : 0.99) as ColorValue) + tokens["border-disabled"] = hasInk ? borderTone(0.18, 0.12) : neutralAlpha[7] + tokens["border-focus"] = hasInk ? borderTone(0.34, 0.24) : neutralAlpha[8] + tokens["border-weak-base"] = hasInk + ? borderTone(0.1, 0.08) + : noInk + ? isDark + ? neutral[3] + : blend(neutral[4], neutral[5], 0.5) + : neutralAlpha[isDark ? 5 : 4] + tokens["border-strong-base"] = hasInk ? borderTone(0.34, 0.24) : neutralAlpha[isDark ? 7 : 6] + tokens["border-strong-hover"] = hasInk ? borderTone(0.4, 0.28) : neutralAlpha[7] + tokens["border-strong-active"] = hasInk ? borderTone(0.46, 0.32) : neutralAlpha[isDark ? 7 : 6] + tokens["border-strong-selected"] = noInk + ? (withAlpha(colors.interactive, isDark ? 0.62 : 0.31) as ColorValue) + : (withAlpha(interactive[5], 0.6) as ColorValue) + tokens["border-strong-disabled"] = hasInk ? borderTone(0.14, 0.1) : neutralAlpha[5] + tokens["border-strong-focus"] = hasInk ? borderTone(0.46, 0.32) : neutralAlpha[isDark ? 7 : 6] + tokens["border-weak-hover"] = hasInk ? borderTone(0.16, 0.12) : neutralAlpha[isDark ? 6 : 5] + tokens["border-weak-active"] = hasInk ? borderTone(0.22, 0.16) : neutralAlpha[isDark ? 7 : 6] + tokens["border-weak-selected"] = noInk + ? (withAlpha(colors.interactive, isDark ? 0.62 : 0.24) as ColorValue) + : (withAlpha(interactive[4], isDark ? 0.6 : 0.5) as ColorValue) + tokens["border-weak-disabled"] = hasInk ? borderTone(0.08, 0.06) : neutralAlpha[5] + tokens["border-weak-focus"] = hasInk ? borderTone(0.22, 0.16) : neutralAlpha[isDark ? 7 : 6] + tokens["border-weaker-base"] = hasInk + ? borderTone(0.06, 0.04) + : noInk + ? isDark + ? blend(neutral[1], neutral[2], 0.5) + : blend(neutral[2], neutral[3], 0.5) + : neutralAlpha[2] + + if (noInk) { + const line = (l: number, d: number) => alphaTone((isDark ? "#ffffff" : "#000000") as HexColor, isDark ? d : l) + tokens["border-base"] = line(0.162, 0.195) + tokens["border-hover"] = line(0.236, 0.284) + tokens["border-active"] = line(0.46, 0.418) + tokens["border-disabled"] = tokens["border-hover"] + tokens["border-focus"] = tokens["border-active"] + } + + tokens["border-interactive-base"] = (noInk && isDark ? interl : interactive)[6] + tokens["border-interactive-hover"] = (noInk && isDark ? interl : interactive)[7] + tokens["border-interactive-active"] = (noInk && isDark ? interl : interactive)[8] + tokens["border-interactive-selected"] = (noInk && isDark ? interl : interactive)[8] tokens["border-interactive-disabled"] = neutral[7] - tokens["border-interactive-focus"] = interactive[8] + tokens["border-interactive-focus"] = (noInk && isDark ? interl : interactive)[8] - tokens["border-success-base"] = success[5] - tokens["border-success-hover"] = success[6] - tokens["border-success-selected"] = success[8] - tokens["border-warning-base"] = warning[5] - tokens["border-warning-hover"] = warning[6] - tokens["border-warning-selected"] = warning[8] + tokens["border-success-base"] = (noInk && isDark ? successl : success)[5] + tokens["border-success-hover"] = (noInk && isDark ? successl : success)[6] + tokens["border-success-selected"] = (noInk && isDark ? successl : success)[8] + tokens["border-warning-base"] = (noInk && isDark ? warningl : warning)[5] + tokens["border-warning-hover"] = (noInk && isDark ? warningl : warning)[6] + tokens["border-warning-selected"] = (noInk && isDark ? warningl : warning)[8] tokens["border-critical-base"] = error[isDark ? 4 : 5] tokens["border-critical-hover"] = error[6] tokens["border-critical-selected"] = error[8] - tokens["border-info-base"] = info[5] - tokens["border-info-hover"] = info[6] - tokens["border-info-selected"] = info[8] + tokens["border-info-base"] = (noInk && isDark ? infol : info)[5] + tokens["border-info-hover"] = (noInk && isDark ? infol : info)[6] + tokens["border-info-selected"] = (noInk && isDark ? infol : info)[8] tokens["border-color"] = "#ffffff" - tokens["icon-base"] = neutral[8] - tokens["icon-hover"] = neutral[isDark ? 9 : 10] - tokens["icon-active"] = neutral[isDark ? 10 : 11] - tokens["icon-selected"] = neutral[11] + tokens["icon-base"] = hasInk && !isDark ? tokens["text-weak"] : neutral[isDark ? 9 : 8] + tokens["icon-hover"] = hasInk && !isDark ? tokens["text-base"] : neutral[10] + tokens["icon-active"] = hasInk && !isDark ? tokens["text-strong"] : neutral[11] + tokens["icon-selected"] = hasInk && !isDark ? tokens["text-strong"] : neutral[11] tokens["icon-disabled"] = neutral[isDark ? 6 : 7] - tokens["icon-focus"] = neutral[11] + tokens["icon-focus"] = hasInk && !isDark ? tokens["text-strong"] : neutral[11] tokens["icon-invert-base"] = isDark ? neutral[0] : "#ffffff" tokens["icon-weak-base"] = neutral[isDark ? 5 : 6] - tokens["icon-weak-hover"] = neutral[6] - tokens["icon-weak-active"] = neutral[7] - tokens["icon-weak-selected"] = neutral[8] - tokens["icon-weak-disabled"] = neutral[isDark ? 3 : 5] + tokens["icon-weak-hover"] = noInk && isDark ? blend(neutral[11], neutral[10], 0.74) : neutral[isDark ? 11 : 7] + tokens["icon-weak-active"] = noInk && isDark ? blend(neutral[11], neutral[10], 0.52) : neutral[8] + tokens["icon-weak-selected"] = neutral[isDark ? 8 : 9] + tokens["icon-weak-disabled"] = noInk && isDark ? neutral[11] : neutral[isDark ? 3 : 5] tokens["icon-weak-focus"] = neutral[8] tokens["icon-strong-base"] = neutral[11] tokens["icon-strong-hover"] = isDark ? "#f6f3f3" : "#151313" tokens["icon-strong-active"] = isDark ? "#fcfcfc" : "#020202" tokens["icon-strong-selected"] = isDark ? "#fdfcfc" : "#020202" - tokens["icon-strong-disabled"] = neutral[7] + tokens["icon-strong-disabled"] = noInk && isDark ? neutral[6] : neutral[7] tokens["icon-strong-focus"] = isDark ? "#fdfcfc" : "#020202" tokens["icon-brand-base"] = isDark ? "#ffffff" : neutral[11] tokens["icon-interactive-base"] = interactive[8] - tokens["icon-success-base"] = success[isDark ? 6 : 6] - tokens["icon-success-hover"] = success[7] + tokens["icon-success-base"] = success[isDark ? 8 : 6] + tokens["icon-success-hover"] = success[isDark ? 9 : 7] tokens["icon-success-active"] = success[10] - tokens["icon-warning-base"] = warning[6] - tokens["icon-warning-hover"] = warning[7] - tokens["icon-warning-active"] = warning[10] + tokens["icon-warning-base"] = amber[isDark ? 8 : 6] + tokens["icon-warning-hover"] = amber[7] + tokens["icon-warning-active"] = amber[10] tokens["icon-critical-base"] = error[isDark ? 8 : 9] tokens["icon-critical-hover"] = error[10] tokens["icon-critical-active"] = error[11] @@ -218,16 +430,16 @@ export function resolveThemeVariant(variant: ThemeVariant, isDark: boolean): Res tokens["icon-on-interactive-base"] = isDark ? neutral[11] : neutral[0] tokens["icon-agent-plan-base"] = info[8] - tokens["icon-agent-docs-base"] = warning[8] - tokens["icon-agent-ask-base"] = interactive[8] + tokens["icon-agent-docs-base"] = amber[8] + tokens["icon-agent-ask-base"] = blue[8] tokens["icon-agent-build-base"] = interactive[isDark ? 10 : 8] tokens["icon-on-success-base"] = withAlpha(success[8], 0.9) as ColorValue tokens["icon-on-success-hover"] = withAlpha(success[9], 0.9) as ColorValue tokens["icon-on-success-selected"] = withAlpha(success[10], 0.9) as ColorValue - tokens["icon-on-warning-base"] = withAlpha(warning[8], 0.9) as ColorValue - tokens["icon-on-warning-hover"] = withAlpha(warning[9], 0.9) as ColorValue - tokens["icon-on-warning-selected"] = withAlpha(warning[10], 0.9) as ColorValue + tokens["icon-on-warning-base"] = withAlpha(amber[8], 0.9) as ColorValue + tokens["icon-on-warning-hover"] = withAlpha(amber[9], 0.9) as ColorValue + tokens["icon-on-warning-selected"] = withAlpha(amber[10], 0.9) as ColorValue tokens["icon-on-critical-base"] = withAlpha(error[8], 0.9) as ColorValue tokens["icon-on-critical-hover"] = withAlpha(error[9], 0.9) as ColorValue tokens["icon-on-critical-selected"] = withAlpha(error[10], 0.9) as ColorValue @@ -240,42 +452,120 @@ export function resolveThemeVariant(variant: ThemeVariant, isDark: boolean): Res tokens["icon-diff-add-active"] = diffAdd[isDark ? 10 : 11] tokens["icon-diff-delete-base"] = diffDelete[isDark ? 8 : 9] tokens["icon-diff-delete-hover"] = diffDelete[isDark ? 9 : 10] - tokens["icon-diff-modified-base"] = isDark ? "#ffba92" : "#FF8C00" + tokens["icon-diff-modified-base"] = modified() - tokens["syntax-comment"] = "var(--text-weak)" - tokens["syntax-regexp"] = "var(--text-base)" - tokens["syntax-string"] = isDark ? "#00ceb9" : "#006656" - tokens["syntax-keyword"] = "var(--text-weak)" - tokens["syntax-primitive"] = isDark ? "#ffba92" : "#fb4804" - tokens["syntax-operator"] = isDark ? "var(--text-weak)" : "var(--text-base)" - tokens["syntax-variable"] = "var(--text-strong)" - tokens["syntax-property"] = isDark ? "#ff9ae2" : "#ed6dc8" - tokens["syntax-type"] = isDark ? "#ecf58c" : "#596600" - tokens["syntax-constant"] = isDark ? "#93e9f6" : "#007b80" - tokens["syntax-punctuation"] = isDark ? "var(--text-weak)" : "var(--text-base)" - tokens["syntax-object"] = "var(--text-strong)" - tokens["syntax-success"] = success[9] - tokens["syntax-warning"] = warning[9] - tokens["syntax-critical"] = error[isDark ? 9 : 9] - tokens["syntax-info"] = isDark ? "#93e9f6" : "#0092a8" - tokens["syntax-diff-add"] = diffAdd[10] - tokens["syntax-diff-delete"] = diffDelete[10] - tokens["syntax-diff-unknown"] = "#ff0000" + if (colors.compact) { + if (!hasInk) { + tokens["syntax-comment"] = "var(--text-weak)" + tokens["syntax-regexp"] = "var(--text-base)" + tokens["syntax-string"] = isDark ? "#00ceb9" : "#006656" + tokens["syntax-keyword"] = "var(--text-weak)" + tokens["syntax-primitive"] = isDark ? "#ffba92" : "#fb4804" + tokens["syntax-operator"] = isDark ? "var(--text-weak)" : "var(--text-base)" + tokens["syntax-variable"] = "var(--text-strong)" + tokens["syntax-property"] = isDark ? "#ff9ae2" : "#ed6dc8" + tokens["syntax-type"] = isDark ? "#ecf58c" : "#596600" + tokens["syntax-constant"] = isDark ? "#93e9f6" : "#007b80" + tokens["syntax-punctuation"] = isDark ? "var(--text-weak)" : "var(--text-base)" + tokens["syntax-object"] = "var(--text-strong)" + tokens["syntax-success"] = success[9] + tokens["syntax-warning"] = amber[9] + tokens["syntax-critical"] = error[9] + tokens["syntax-info"] = isDark ? "#93e9f6" : "#0092a8" + tokens["syntax-diff-add"] = diffAdd[10] + tokens["syntax-diff-delete"] = diffDelete[10] + tokens["syntax-diff-unknown"] = "#ff0000" - tokens["markdown-heading"] = isDark ? "#9d7cd8" : "#d68c27" - tokens["markdown-text"] = isDark ? "#eeeeee" : "#1a1a1a" - tokens["markdown-link"] = isDark ? "#fab283" : "#3b7dd8" - tokens["markdown-link-text"] = isDark ? "#56b6c2" : "#318795" - tokens["markdown-code"] = isDark ? "#7fd88f" : "#3d9a57" - tokens["markdown-block-quote"] = isDark ? "#e5c07b" : "#b0851f" - tokens["markdown-emph"] = isDark ? "#e5c07b" : "#b0851f" - tokens["markdown-strong"] = isDark ? "#f5a742" : "#d68c27" - tokens["markdown-horizontal-rule"] = isDark ? "#808080" : "#8a8a8a" - tokens["markdown-list-item"] = isDark ? "#fab283" : "#3b7dd8" - tokens["markdown-list-enumeration"] = isDark ? "#56b6c2" : "#318795" - tokens["markdown-image"] = isDark ? "#fab283" : "#3b7dd8" - tokens["markdown-image-text"] = isDark ? "#56b6c2" : "#318795" - tokens["markdown-code-block"] = isDark ? "#eeeeee" : "#1a1a1a" + tokens["markdown-heading"] = isDark ? "#9d7cd8" : "#d68c27" + tokens["markdown-text"] = isDark ? "#eeeeee" : "#1a1a1a" + tokens["markdown-link"] = isDark ? "#fab283" : "#3b7dd8" + tokens["markdown-link-text"] = isDark ? "#56b6c2" : "#318795" + tokens["markdown-code"] = isDark ? "#7fd88f" : "#3d9a57" + tokens["markdown-block-quote"] = isDark ? "#e5c07b" : "#b0851f" + tokens["markdown-emph"] = isDark ? "#e5c07b" : "#b0851f" + tokens["markdown-strong"] = isDark ? "#f5a742" : "#d68c27" + tokens["markdown-horizontal-rule"] = isDark ? "#808080" : "#8a8a8a" + tokens["markdown-list-item"] = isDark ? "#fab283" : "#3b7dd8" + tokens["markdown-list-enumeration"] = isDark ? "#56b6c2" : "#318795" + tokens["markdown-image"] = isDark ? "#fab283" : "#3b7dd8" + tokens["markdown-image-text"] = isDark ? "#56b6c2" : "#318795" + tokens["markdown-code-block"] = isDark ? "#eeeeee" : "#1a1a1a" + } + + if (hasInk) { + tokens["syntax-comment"] = "var(--text-weak)" + tokens["syntax-regexp"] = "var(--text-base)" + tokens["syntax-string"] = content(colors.success, success) + tokens["syntax-keyword"] = "var(--text-weak)" + tokens["syntax-primitive"] = content(colors.accent, accent) + tokens["syntax-operator"] = isDark ? "var(--text-weak)" : "var(--text-base)" + tokens["syntax-variable"] = "var(--text-strong)" + tokens["syntax-property"] = content(colors.primary, primary) + tokens["syntax-type"] = content(colors.warning, warning) + tokens["syntax-constant"] = content(colors.info, info) + tokens["syntax-punctuation"] = isDark ? "var(--text-weak)" : "var(--text-base)" + tokens["syntax-object"] = "var(--text-strong)" + tokens["syntax-success"] = success[9] + tokens["syntax-warning"] = amber[9] + tokens["syntax-critical"] = error[9] + tokens["syntax-info"] = content(colors.info, info) + tokens["syntax-diff-add"] = diffAdd[10] + tokens["syntax-diff-delete"] = diffDelete[10] + tokens["syntax-diff-unknown"] = "#ff0000" + + tokens["markdown-heading"] = content(colors.primary, primary) + tokens["markdown-text"] = tokens["text-base"] + tokens["markdown-link"] = content(colors.interactive, interactive) + tokens["markdown-link-text"] = content(colors.info, info) + tokens["markdown-code"] = content(colors.success, success) + tokens["markdown-block-quote"] = content(colors.warning, warning) + tokens["markdown-emph"] = content(colors.warning, warning) + tokens["markdown-strong"] = content(colors.accent, accent) + tokens["markdown-horizontal-rule"] = tokens["border-base"] + tokens["markdown-list-item"] = content(colors.interactive, interactive) + tokens["markdown-list-enumeration"] = content(colors.info, info) + tokens["markdown-image"] = content(colors.interactive, interactive) + tokens["markdown-image-text"] = content(colors.info, info) + tokens["markdown-code-block"] = tokens["text-base"] + } + } + + if (!colors.compact) { + tokens["syntax-comment"] = "var(--text-weak)" + tokens["syntax-regexp"] = "var(--text-base)" + tokens["syntax-string"] = isDark ? "#00ceb9" : "#006656" + tokens["syntax-keyword"] = "var(--text-weak)" + tokens["syntax-primitive"] = isDark ? "#ffba92" : "#fb4804" + tokens["syntax-operator"] = isDark ? "var(--text-weak)" : "var(--text-base)" + tokens["syntax-variable"] = "var(--text-strong)" + tokens["syntax-property"] = isDark ? "#ff9ae2" : "#ed6dc8" + tokens["syntax-type"] = isDark ? "#ecf58c" : "#596600" + tokens["syntax-constant"] = isDark ? "#93e9f6" : "#007b80" + tokens["syntax-punctuation"] = isDark ? "var(--text-weak)" : "var(--text-base)" + tokens["syntax-object"] = "var(--text-strong)" + tokens["syntax-success"] = success[9] + tokens["syntax-warning"] = amber[9] + tokens["syntax-critical"] = error[9] + tokens["syntax-info"] = isDark ? "#93e9f6" : "#0092a8" + tokens["syntax-diff-add"] = diffAdd[10] + tokens["syntax-diff-delete"] = diffDelete[10] + tokens["syntax-diff-unknown"] = "#ff0000" + + tokens["markdown-heading"] = isDark ? "#9d7cd8" : "#d68c27" + tokens["markdown-text"] = isDark ? "#eeeeee" : "#1a1a1a" + tokens["markdown-link"] = isDark ? "#fab283" : "#3b7dd8" + tokens["markdown-link-text"] = isDark ? "#56b6c2" : "#318795" + tokens["markdown-code"] = isDark ? "#7fd88f" : "#3d9a57" + tokens["markdown-block-quote"] = isDark ? "#e5c07b" : "#b0851f" + tokens["markdown-emph"] = isDark ? "#e5c07b" : "#b0851f" + tokens["markdown-strong"] = isDark ? "#f5a742" : "#d68c27" + tokens["markdown-horizontal-rule"] = isDark ? "#808080" : "#8a8a8a" + tokens["markdown-list-item"] = isDark ? "#fab283" : "#3b7dd8" + tokens["markdown-list-enumeration"] = isDark ? "#56b6c2" : "#318795" + tokens["markdown-image"] = isDark ? "#fab283" : "#3b7dd8" + tokens["markdown-image-text"] = isDark ? "#56b6c2" : "#318795" + tokens["markdown-code-block"] = isDark ? "#eeeeee" : "#1a1a1a" + } tokens["avatar-background-pink"] = isDark ? "#501b3f" : "#feeef8" tokens["avatar-background-mint"] = isDark ? "#033a34" : "#e1fbf4" @@ -294,13 +584,101 @@ export function resolveThemeVariant(variant: ThemeVariant, isDark: boolean): Res tokens[key] = value } + if (hasInk && "text-weak" in overrides && !("text-weaker" in overrides)) { + const weak = tokens["text-weak"] + if (weak.startsWith("#")) { + tokens["text-weaker"] = shift(weak as HexColor, { l: isDark ? -0.12 : 0.12, c: 0.75 }) + } else { + tokens["text-weaker"] = weak + } + } + + if (colors.compact && hasInk) { + if (!("markdown-text" in overrides)) { + tokens["markdown-text"] = tokens["text-base"] + } + if (!("markdown-code-block" in overrides)) { + tokens["markdown-code-block"] = tokens["text-base"] + } + } + + if (!("text-stronger" in overrides)) { + tokens["text-stronger"] = tokens["text-strong"] + } + return tokens } +interface ThemeColors { + compact: boolean + neutral: HexColor + ink?: HexColor + primary: HexColor + accent: HexColor + success: HexColor + warning: HexColor + error: HexColor + info: HexColor + interactive: HexColor + diffAdd?: HexColor + diffDelete?: HexColor +} + +function getColors(variant: ThemeVariant): ThemeColors { + const input = variant as { palette?: unknown; seeds?: unknown } + if (input.palette && input.seeds) { + throw new Error("Theme variant cannot define both `palette` and `seeds`") + } + + if (variant.palette) { + return { + compact: true, + neutral: variant.palette.neutral, + ink: variant.palette.ink, + primary: variant.palette.primary, + accent: variant.palette.accent ?? variant.palette.info, + success: variant.palette.success, + warning: variant.palette.warning, + error: variant.palette.error, + info: variant.palette.info, + interactive: variant.palette.interactive ?? variant.palette.primary, + diffAdd: variant.palette.diffAdd, + diffDelete: variant.palette.diffDelete, + } + } + + if (variant.seeds) { + return { + compact: false, + neutral: variant.seeds.neutral, + ink: undefined, + primary: variant.seeds.primary, + accent: variant.seeds.info, + success: variant.seeds.success, + warning: variant.seeds.warning, + error: variant.seeds.error, + info: variant.seeds.info, + interactive: variant.seeds.interactive, + diffAdd: variant.seeds.diffAdd, + diffDelete: variant.seeds.diffDelete, + } + } + + throw new Error("Theme variant requires `palette` or `seeds`") +} + +function generateNeutralOverlayScale(neutralScale: HexColor[], isDark: boolean): ColorValue[] { + const alphas = isDark + ? [0, 0.034, 0.063, 0.084, 0.109, 0.138, 0.181, 0.266, 0.404, 0.468, 0.603, 0.928] + : [0.014, 0.034, 0.055, 0.075, 0.096, 0.118, 0.151, 0.232, 0.453, 0.492, 0.574, 0.915] + const color = (isDark ? "#ffffff" : "#000000") as HexColor + return alphas.map((alpha) => withAlpha(color, alpha) as ColorValue) +} + function generateNeutralAlphaScale(neutralScale: HexColor[], isDark: boolean): HexColor[] { const alphas = isDark - ? [0.02, 0.04, 0.08, 0.12, 0.16, 0.2, 0.26, 0.36, 0.44, 0.52, 0.72, 0.94] - : [0.01, 0.03, 0.06, 0.09, 0.12, 0.15, 0.2, 0.27, 0.46, 0.61, 0.5, 0.87] + ? [0.024, 0.048, 0.088, 0.128, 0.17, 0.215, 0.275, 0.38, 0.46, 0.54, 0.74, 0.95] + : [0.014, 0.034, 0.066, 0.098, 0.128, 0.158, 0.208, 0.282, 0.47, 0.625, 0.515, 0.88] return neutralScale.map((hex, i) => { const baseOklch = hexToOklch(hex) @@ -312,6 +690,11 @@ function generateNeutralAlphaScale(neutralScale: HexColor[], isDark: boolean): H }) } +function getHex(value: ColorValue | undefined): HexColor | undefined { + if (!value?.startsWith("#")) return + return value as HexColor +} + export function resolveTheme(theme: DesktopTheme): { light: ResolvedTheme; dark: ResolvedTheme } { return { light: resolveThemeVariant(theme.light, false), diff --git a/packages/ui/src/theme/themes/aura.json b/packages/ui/src/theme/themes/aura.json index 874939fd4d..e65eb4b0aa 100644 --- a/packages/ui/src/theme/themes/aura.json +++ b/packages/ui/src/theme/themes/aura.json @@ -3,129 +3,37 @@ "name": "Aura", "id": "aura", "light": { - "seeds": { + "palette": { "neutral": "#f5f0ff", + "ink": "#2d2640", "primary": "#a277ff", + "accent": "#d94f4f", "success": "#40bf7a", "warning": "#d9a24a", "error": "#d94f4f", "info": "#5bb8d9", - "interactive": "#a277ff", "diffAdd": "#b3e6cc", "diffDelete": "#f5b3b3" }, "overrides": { - "background-base": "#f5f0ff", - "background-weak": "#efe8fc", - "background-strong": "#faf7ff", - "background-stronger": "#fdfcff", - "border-weak-base": "#e0d6f2", - "border-weak-hover": "#d5c9eb", - "border-weak-active": "#cbbee3", - "border-weak-selected": "#c0b3dc", - "border-weak-disabled": "#f9f6ff", - "border-weak-focus": "#c5b8df", - "border-base": "#b5a6d4", - "border-hover": "#aa99cc", - "border-active": "#9f8dc4", - "border-selected": "#9480bc", - "border-disabled": "#ede7f9", - "border-focus": "#a593c8", - "border-strong-base": "#8068a8", - "border-strong-hover": "#735a9c", - "border-strong-active": "#664d90", - "border-strong-selected": "#5a4184", - "border-strong-disabled": "#d4c8ed", - "border-strong-focus": "#6d5396", - "surface-diff-add-base": "#e8f5ed", - "surface-diff-delete-base": "#fae8e8", - "surface-diff-hidden-base": "#e8e4f5", - "text-base": "#2d2640", - "text-weak": "#5c5270", - "text-strong": "#15101f", - "syntax-string": "#40bf7a", - "syntax-primitive": "#d94f4f", - "syntax-property": "#a277ff", - "syntax-type": "#d9a24a", - "syntax-constant": "#5bb8d9", - "syntax-info": "#5bb8d9", - "markdown-heading": "#a277ff", - "markdown-text": "#2d2640", - "markdown-link": "#c17ac8", - "markdown-link-text": "#a277ff", - "markdown-code": "#40bf7a", - "markdown-block-quote": "#6d6d6d", - "markdown-emph": "#d9a24a", - "markdown-strong": "#a277ff", - "markdown-horizontal-rule": "#d4c8ed", - "markdown-list-item": "#a277ff", - "markdown-list-enumeration": "#a277ff", - "markdown-image": "#c17ac8", - "markdown-image-text": "#a277ff", - "markdown-code-block": "#5bb8d9" + "syntax-keyword": "#7b5ae0" } }, "dark": { - "seeds": { + "palette": { "neutral": "#15141b", + "ink": "#edecee", "primary": "#a277ff", + "accent": "#ff6767", "success": "#61ffca", "warning": "#ffca85", "error": "#ff6767", "info": "#82e2ff", - "interactive": "#a277ff", "diffAdd": "#61ffca", "diffDelete": "#ff6767" }, "overrides": { - "background-base": "#15141b", - "background-weak": "#1a1921", - "background-strong": "#121118", - "background-stronger": "#0f0e14", - "border-weak-base": "#2d2b38", - "border-weak-hover": "#332f42", - "border-weak-active": "#38354c", - "border-weak-selected": "#3e3a56", - "border-weak-disabled": "#1a1921", - "border-weak-focus": "#363350", - "border-base": "#433f5a", - "border-hover": "#4a4565", - "border-active": "#514c70", - "border-selected": "#58527b", - "border-disabled": "#1f1e28", - "border-focus": "#4e496c", - "border-strong-base": "#635c8a", - "border-strong-hover": "#6d6597", - "border-strong-active": "#776fa4", - "border-strong-selected": "#8179b1", - "border-strong-disabled": "#2a283a", - "border-strong-focus": "#716a9e", - "surface-diff-add-base": "#162620", - "surface-diff-delete-base": "#26161a", - "surface-diff-hidden-base": "#1e1d2a", - "text-base": "#edecee", - "text-weak": "#6d6d6d", - "text-strong": "#ffffff", - "syntax-string": "#61ffca", - "syntax-primitive": "#ff6767", - "syntax-property": "#a277ff", - "syntax-type": "#ffca85", - "syntax-constant": "#82e2ff", - "syntax-info": "#82e2ff", - "markdown-heading": "#a277ff", - "markdown-text": "#edecee", - "markdown-link": "#f694ff", - "markdown-link-text": "#a277ff", - "markdown-code": "#61ffca", - "markdown-block-quote": "#6d6d6d", - "markdown-emph": "#ffca85", - "markdown-strong": "#a277ff", - "markdown-horizontal-rule": "#2d2b38", - "markdown-list-item": "#a277ff", - "markdown-list-enumeration": "#a277ff", - "markdown-image": "#f694ff", - "markdown-image-text": "#a277ff", - "markdown-code-block": "#edecee" + "syntax-keyword": "#a277ff" } } } diff --git a/packages/ui/src/theme/themes/ayu.json b/packages/ui/src/theme/themes/ayu.json index eac9e0491f..f459489035 100644 --- a/packages/ui/src/theme/themes/ayu.json +++ b/packages/ui/src/theme/themes/ayu.json @@ -3,131 +3,37 @@ "name": "Ayu", "id": "ayu", "light": { - "seeds": { + "palette": { "neutral": "#fdfaf4", + "ink": "#4f5964", "primary": "#4aa8c8", + "accent": "#ef7d71", "success": "#5fb978", "warning": "#ea9f41", "error": "#e6656a", "info": "#2f9bce", - "interactive": "#4aa8c8", "diffAdd": "#b1d780", "diffDelete": "#e6656a" }, "overrides": { - "background-base": "#fdfaf4", - "background-weak": "#fcf9f3", - "background-strong": "#fbf8f2", - "background-stronger": "#faf7f1", - "surface-raised-base-hover": "#f4f0e9", - "border-weak-base": "#e6ddcf", - "border-weak-hover": "#dcd3c5", - "border-weak-active": "#d1c9ba", - "border-weak-selected": "#c6bfaf", - "border-weak-disabled": "#f7f0e6", - "border-weak-focus": "#cbc4b6", - "border-base": "#bfb3a3", - "border-hover": "#b4a898", - "border-active": "#a99e8e", - "border-selected": "#9e9383", - "border-disabled": "#efe5d8", - "border-focus": "#b09f8f", - "border-strong-base": "#837765", - "border-strong-hover": "#7a6f5f", - "border-strong-active": "#716655", - "border-strong-selected": "#685e4e", - "border-strong-disabled": "#d8cabc", - "border-strong-focus": "#766b5c", - "surface-diff-add-base": "#eef5e4", - "surface-diff-delete-base": "#fde5e5", - "surface-diff-hidden-base": "#e3edf3", - "text-base": "#4f5964", - "text-weak": "#77818d", - "text-strong": "#1b232b", - "syntax-string": "#7fad00", - "syntax-primitive": "#ef7d71", - "syntax-property": "#4aa8c8", - "syntax-type": "#ed982e", - "syntax-constant": "#2f9bce", - "syntax-info": "#2f9bce", - "markdown-heading": "#4aa8c8", - "markdown-text": "#4f5964", - "markdown-link": "#4aa8c8", - "markdown-link-text": "#2f9bce", - "markdown-code": "#7fad00", - "markdown-block-quote": "#ed982e", - "markdown-emph": "#ed982e", - "markdown-strong": "#f07f72", - "markdown-horizontal-rule": "#d7cec0", - "markdown-list-item": "#4aa8c8", - "markdown-list-enumeration": "#2f9bce", - "markdown-image": "#4aa8c8", - "markdown-image-text": "#2f9bce", - "markdown-code-block": "#4aa8c8" + "syntax-keyword": "#ea9f41" } }, "dark": { - "seeds": { + "palette": { "neutral": "#0f1419", + "ink": "#d6dae0", "primary": "#3fb7e3", + "accent": "#f2856f", "success": "#78d05c", "warning": "#e4a75c", "error": "#f58572", "info": "#66c6f1", - "interactive": "#3fb7e3", "diffAdd": "#59c57c", "diffDelete": "#f58572" }, "overrides": { - "background-base": "#0f1419", - "background-weak": "#18222c", - "background-strong": "#0b1015", - "background-stronger": "#080c10", - "surface-raised-base-hover": "#0f1419", - "border-weak-base": "#2b3440", - "border-weak-hover": "#323c49", - "border-weak-active": "#394454", - "border-weak-selected": "#415063", - "border-weak-disabled": "#0a0e12", - "border-weak-focus": "#374453", - "border-base": "#475367", - "border-hover": "#515f75", - "border-active": "#5d6b83", - "border-selected": "#687795", - "border-disabled": "#11161d", - "border-focus": "#56647c", - "border-strong-base": "#73819b", - "border-strong-hover": "#7f8da8", - "border-strong-active": "#8b99b5", - "border-strong-selected": "#98a6c3", - "border-strong-disabled": "#1b222c", - "border-strong-focus": "#8391ad", - "surface-diff-add-base": "#132f27", - "surface-diff-delete-base": "#361d20", - "surface-diff-hidden-base": "#1b2632", - "text-base": "#d6dae0", - "text-weak": "#a3adba", - "text-strong": "#fbfbfd", - "syntax-string": "#b1c74a", - "syntax-primitive": "#f2856f", - "syntax-property": "#3fb7e3", - "syntax-type": "#e4a75c", - "syntax-constant": "#66c6f1", - "syntax-info": "#66c6f1", - "markdown-heading": "#3fb7e3", - "markdown-text": "#d6dae0", - "markdown-link": "#3fb7e3", - "markdown-link-text": "#66c6f1", - "markdown-code": "#b1c74a", - "markdown-block-quote": "#e4a75c", - "markdown-emph": "#e4a75c", - "markdown-strong": "#f2856f", - "markdown-horizontal-rule": "#2b3542", - "markdown-list-item": "#3fb7e3", - "markdown-list-enumeration": "#66c6f1", - "markdown-image": "#3fb7e3", - "markdown-image-text": "#66c6f1", - "markdown-code-block": "#d6dae0" + "syntax-keyword": "#ffad66" } } } diff --git a/packages/ui/src/theme/themes/carbonfox.json b/packages/ui/src/theme/themes/carbonfox.json index e2fa20d803..54e55cdeae 100644 --- a/packages/ui/src/theme/themes/carbonfox.json +++ b/packages/ui/src/theme/themes/carbonfox.json @@ -3,9 +3,11 @@ "name": "Carbonfox", "id": "carbonfox", "light": { - "seeds": { + "palette": { "neutral": "#8e8e8e", + "ink": "#161616", "primary": "#0072c3", + "accent": "#da1e28", "success": "#198038", "warning": "#f1c21b", "error": "#da1e28", @@ -15,44 +17,15 @@ "diffDelete": "#da1e28" }, "overrides": { - "background-base": "#ffffff", - "background-weak": "#f4f4f4", - "background-strong": "#e8e8e8", - "background-stronger": "#dcdcdc", - "surface-raised-strong": "#ffffff", - "surface-raised-stronger": "#ffffff", - "surface-float-base": "#161616", - "surface-float-base-hover": "#262626", - "text-base": "#161616", - "text-weak": "#525252", - "text-strong": "#000000", - "syntax-string": "#198038", - "syntax-primitive": "#da1e28", - "syntax-property": "#0043ce", - "syntax-type": "#007d79", - "syntax-constant": "#6929c4", - "syntax-keyword": "#525252", - "syntax-info": "#0043ce", - "markdown-heading": "#0043ce", - "markdown-text": "#161616", - "markdown-link": "#0043ce", - "markdown-link-text": "#0072c3", - "markdown-code": "#198038", - "markdown-block-quote": "#525252", - "markdown-emph": "#6929c4", - "markdown-strong": "#161616", - "markdown-horizontal-rule": "#c6c6c6", - "markdown-list-item": "#0072c3", - "markdown-list-enumeration": "#0072c3", - "markdown-image": "#0043ce", - "markdown-image-text": "#0072c3", - "markdown-code-block": "#393939" + "syntax-keyword": "#8a3ffc" } }, "dark": { - "seeds": { + "palette": { "neutral": "#393939", + "ink": "#f2f4f8", "primary": "#33b1ff", + "accent": "#ff8389", "success": "#42be65", "warning": "#f1c21b", "error": "#ff8389", @@ -62,61 +35,7 @@ "diffDelete": "#ff8389" }, "overrides": { - "background-base": "#161616", - "background-weak": "#262626", - "background-strong": "#0d0d0d", - "background-stronger": "#000000", - "surface-raised-base": "#1c1c1c", - "surface-raised-base-hover": "#262626", - "surface-raised-strong": "#262626", - "surface-raised-strong-hover": "#303030", - "surface-raised-stronger": "#303030", - "surface-raised-stronger-hover": "#393939", - "surface-raised-stronger-non-alpha": "#303030", - "surface-float-base": "#0d0d0d", - "surface-float-base-hover": "#1a1a1a", - "surface-inset-base": "#0d0d0d", - "surface-inset-strong": "#000000", - "surface-base": "#1e1e1e", - "surface-base-hover": "#262626", - "surface-diff-add-base": "#0e3a22", - "surface-diff-delete-base": "#4d1a1f", - "input-base": "#262626", - "input-hover": "#303030", - "button-secondary-base": "#393939", - "button-secondary-hover": "#4c4c4c", - "border-weak-base": "#393939", - "border-weak-hover": "#4c4c4c", - "border-base": "#525252", - "border-hover": "#636363", - "border-strong-base": "#6f6f6f", - "text-base": "#f2f4f8", - "text-weak": "#8d8d8d", - "text-weaker": "#6f6f6f", - "text-strong": "#ffffff", - "icon-base": "#8d8d8d", - "icon-weak-base": "#6f6f6f", - "syntax-string": "#42be65", - "syntax-primitive": "#ff8389", - "syntax-property": "#78a9ff", - "syntax-type": "#08bdba", - "syntax-constant": "#be95ff", - "syntax-keyword": "#8d8d8d", - "syntax-info": "#78a9ff", - "markdown-heading": "#82cfff", - "markdown-text": "#f2f4f8", - "markdown-link": "#78a9ff", - "markdown-link-text": "#33b1ff", - "markdown-code": "#42be65", - "markdown-block-quote": "#8d8d8d", - "markdown-emph": "#be95ff", - "markdown-strong": "#ffffff", - "markdown-horizontal-rule": "#393939", - "markdown-list-item": "#33b1ff", - "markdown-list-enumeration": "#33b1ff", - "markdown-image": "#78a9ff", - "markdown-image-text": "#33b1ff", - "markdown-code-block": "#c6c6c6" + "syntax-keyword": "#be95ff" } } } diff --git a/packages/ui/src/theme/themes/catppuccin.json b/packages/ui/src/theme/themes/catppuccin.json index 2a32df0984..66fd37e26b 100644 --- a/packages/ui/src/theme/themes/catppuccin.json +++ b/packages/ui/src/theme/themes/catppuccin.json @@ -3,129 +3,39 @@ "name": "Catppuccin", "id": "catppuccin", "light": { - "seeds": { + "palette": { "neutral": "#f5e0dc", + "ink": "#4c4f69", "primary": "#7287fd", + "accent": "#d20f39", "success": "#40a02b", "warning": "#df8e1d", "error": "#d20f39", "info": "#04a5e5", - "interactive": "#7287fd", "diffAdd": "#a6d189", "diffDelete": "#e78284" }, "overrides": { - "background-base": "#f5e0dc", - "background-weak": "#f2d8d4", - "background-strong": "#f9e8e4", - "background-stronger": "#fdeeee", - "border-weak-base": "#e0cfd3", - "border-weak-hover": "#d6c4c8", - "border-weak-active": "#cdb9be", - "border-weak-selected": "#c2aeb4", - "border-weak-disabled": "#fbeff2", - "border-weak-focus": "#c7b4ba", - "border-base": "#bca6b2", - "border-hover": "#b19ca8", - "border-active": "#a6929e", - "border-selected": "#9a8894", - "border-disabled": "#f3e4e7", - "border-focus": "#ab97a1", - "border-strong-base": "#83677f", - "border-strong-hover": "#775b73", - "border-strong-active": "#6b5068", - "border-strong-selected": "#5f465d", - "border-strong-disabled": "#d9c5cf", - "border-strong-focus": "#714f66", - "surface-diff-add-base": "#edf5e6", - "surface-diff-delete-base": "#fde1e3", - "surface-diff-hidden-base": "#e4e2f6", - "text-base": "#4c4f69", - "text-weak": "#6c6f85", - "text-strong": "#1f1f2a", - "syntax-string": "#40a02b", - "syntax-primitive": "#d20f39", - "syntax-property": "#7287fd", - "syntax-type": "#df8e1d", - "syntax-constant": "#04a5e5", - "syntax-info": "#04a5e5", - "markdown-heading": "#7287fd", - "markdown-text": "#4c4f69", - "markdown-link": "#7287fd", - "markdown-link-text": "#04a5e5", - "markdown-code": "#40a02b", - "markdown-block-quote": "#df8e1d", - "markdown-emph": "#df8e1d", - "markdown-strong": "#d20f39", - "markdown-horizontal-rule": "#d4c5cf", - "markdown-list-item": "#7287fd", - "markdown-list-enumeration": "#04a5e5", - "markdown-image": "#7287fd", - "markdown-image-text": "#04a5e5", - "markdown-code-block": "#7287fd" + "syntax-keyword": "#8839ef", + "syntax-primitive": "#fe640b" } }, "dark": { - "seeds": { + "palette": { "neutral": "#1e1e2e", + "ink": "#cdd6f4", "primary": "#b4befe", + "accent": "#f38ba8", "success": "#a6d189", "warning": "#f4b8e4", "error": "#f38ba8", "info": "#89dceb", - "interactive": "#b4befe", "diffAdd": "#94e2d5", "diffDelete": "#f38ba8" }, "overrides": { - "background-base": "#1e1e2e", - "background-weak": "#211f31", - "background-strong": "#1c1c29", - "background-stronger": "#191926", - "border-weak-base": "#35324a", - "border-weak-hover": "#393655", - "border-weak-active": "#403c61", - "border-weak-selected": "#47436d", - "border-weak-disabled": "#141426", - "border-weak-focus": "#3d3a63", - "border-base": "#4a4763", - "border-hover": "#524f70", - "border-active": "#5a577d", - "border-selected": "#625f8a", - "border-disabled": "#1b1a2c", - "border-focus": "#575379", - "border-strong-base": "#6e6a8c", - "border-strong-hover": "#787497", - "border-strong-active": "#8380a2", - "border-strong-selected": "#8d8bad", - "border-strong-disabled": "#232237", - "border-strong-focus": "#7b779b", - "surface-diff-add-base": "#1d2c30", - "surface-diff-delete-base": "#2c1f2a", - "surface-diff-hidden-base": "#232538", - "text-base": "#cdd6f4", - "text-weak": "#a6adc8", - "text-strong": "#f4f2ff", - "syntax-string": "#a6e3a1", - "syntax-primitive": "#f38ba8", - "syntax-property": "#b4befe", - "syntax-type": "#f9e2af", - "syntax-constant": "#89dceb", - "syntax-info": "#89dceb", - "markdown-heading": "#b4befe", - "markdown-text": "#cdd6f4", - "markdown-link": "#b4befe", - "markdown-link-text": "#89dceb", - "markdown-code": "#a6e3a1", - "markdown-block-quote": "#f9e2af", - "markdown-emph": "#f9e2af", - "markdown-strong": "#f38ba8", - "markdown-horizontal-rule": "#2e2d45", - "markdown-list-item": "#b4befe", - "markdown-list-enumeration": "#89dceb", - "markdown-image": "#b4befe", - "markdown-image-text": "#89dceb", - "markdown-code-block": "#cdd6f4" + "syntax-keyword": "#cba6f7", + "syntax-primitive": "#fab387" } } } diff --git a/packages/ui/src/theme/themes/dracula.json b/packages/ui/src/theme/themes/dracula.json index 696f1060c7..495042ca7b 100644 --- a/packages/ui/src/theme/themes/dracula.json +++ b/packages/ui/src/theme/themes/dracula.json @@ -3,129 +3,41 @@ "name": "Dracula", "id": "dracula", "light": { - "seeds": { + "palette": { "neutral": "#f8f8f2", + "ink": "#1f1f2f", "primary": "#7c6bf5", + "accent": "#d16090", "success": "#2fbf71", "warning": "#f7a14d", "error": "#d9536f", "info": "#1d7fc5", - "interactive": "#7c6bf5", "diffAdd": "#9fe3b3", "diffDelete": "#f8a1b8" }, "overrides": { - "background-base": "#f8f8f2", - "background-weak": "#f1f2ed", - "background-strong": "#f6f6f1", - "background-stronger": "#f2f2ec", - "border-weak-base": "#e2e3da", - "border-weak-hover": "#d8d9d0", - "border-weak-active": "#cfd0c7", - "border-weak-selected": "#c4c6bc", - "border-weak-disabled": "#eceee3", - "border-weak-focus": "#c9cabf", - "border-base": "#c4c6ba", - "border-hover": "#b8baae", - "border-active": "#abada3", - "border-selected": "#979a90", - "border-disabled": "#e5e7dd", - "border-focus": "#b0b2a7", - "border-strong-base": "#9fa293", - "border-strong-hover": "#8e9185", - "border-strong-active": "#7e8176", - "border-strong-selected": "#6f7268", - "border-strong-disabled": "#c7c9be", - "border-strong-focus": "#878b7f", - "surface-diff-add-base": "#e4f5e6", - "surface-diff-delete-base": "#fae4eb", - "surface-diff-hidden-base": "#dedfe9", - "text-base": "#1f1f2f", - "text-weak": "#52526b", - "text-strong": "#05040c", - "syntax-string": "#2fbf71", - "syntax-primitive": "#d16090", - "syntax-property": "#7c6bf5", - "syntax-type": "#f7a14d", - "syntax-constant": "#1d7fc5", - "syntax-info": "#1d7fc5", - "markdown-heading": "#7c6bf5", - "markdown-text": "#1f1f2f", - "markdown-link": "#7c6bf5", - "markdown-link-text": "#1d7fc5", - "markdown-code": "#2fbf71", - "markdown-block-quote": "#f7a14d", - "markdown-emph": "#f7a14d", - "markdown-strong": "#d16090", - "markdown-horizontal-rule": "#c3c5d4", - "markdown-list-item": "#7c6bf5", - "markdown-list-enumeration": "#1d7fc5", - "markdown-image": "#7c6bf5", - "markdown-image-text": "#1d7fc5", - "markdown-code-block": "#1d7fc5" + "syntax-keyword": "#d16090", + "syntax-string": "#596600", + "syntax-primitive": "#7c6bf5" } }, "dark": { - "seeds": { + "palette": { "neutral": "#1d1e28", + "ink": "#f8f8f2", "primary": "#bd93f9", + "accent": "#ff79c6", "success": "#50fa7b", "warning": "#ffb86c", "error": "#ff5555", "info": "#8be9fd", - "interactive": "#bd93f9", "diffAdd": "#2fb27d", "diffDelete": "#ff6b81" }, "overrides": { - "background-base": "#14151f", - "background-weak": "#181926", - "background-strong": "#161722", - "background-stronger": "#191a26", - "border-weak-base": "#2d2f3c", - "border-weak-hover": "#303244", - "border-weak-active": "#35364c", - "border-weak-selected": "#3b3d55", - "border-weak-disabled": "#1e1f2b", - "border-weak-focus": "#383a50", - "border-base": "#3f415a", - "border-hover": "#464967", - "border-active": "#4d5073", - "border-selected": "#55587f", - "border-disabled": "#272834", - "border-focus": "#4a4d6d", - "border-strong-base": "#606488", - "border-strong-hover": "#6a6e96", - "border-strong-active": "#7378a3", - "border-strong-selected": "#7d82b1", - "border-strong-disabled": "#343649", - "border-strong-focus": "#6f739c", - "surface-diff-add-base": "#1f2a2f", - "surface-diff-delete-base": "#2d1f27", - "surface-diff-hidden-base": "#24253a", - "text-base": "#f8f8f2", - "text-weak": "#b6b9e4", - "text-strong": "#ffffff", - "syntax-string": "#50fa7b", - "syntax-primitive": "#ff79c6", - "syntax-property": "#bd93f9", - "syntax-type": "#ffb86c", - "syntax-constant": "#8be9fd", - "syntax-info": "#8be9fd", - "markdown-heading": "#bd93f9", - "markdown-text": "#f8f8f2", - "markdown-link": "#bd93f9", - "markdown-link-text": "#8be9fd", - "markdown-code": "#50fa7b", - "markdown-block-quote": "#ffb86c", - "markdown-emph": "#ffb86c", - "markdown-strong": "#ff79c6", - "markdown-horizontal-rule": "#44475a", - "markdown-list-item": "#bd93f9", - "markdown-list-enumeration": "#8be9fd", - "markdown-image": "#bd93f9", - "markdown-image-text": "#8be9fd", - "markdown-code-block": "#f8f8f2" + "syntax-keyword": "#ff79c6", + "syntax-string": "#f1fa8c", + "syntax-primitive": "#bd93f9" } } } diff --git a/packages/ui/src/theme/themes/gruvbox.json b/packages/ui/src/theme/themes/gruvbox.json index cf87ccd553..f078db2d4c 100644 --- a/packages/ui/src/theme/themes/gruvbox.json +++ b/packages/ui/src/theme/themes/gruvbox.json @@ -3,130 +3,39 @@ "name": "Gruvbox", "id": "gruvbox", "light": { - "seeds": { + "palette": { "neutral": "#fbf1c7", + "ink": "#3c3836", "primary": "#076678", + "accent": "#9d0006", "success": "#79740e", "warning": "#b57614", "error": "#9d0006", "info": "#8f3f71", - "interactive": "#076678", "diffAdd": "#79740e", "diffDelete": "#9d0006" }, "overrides": { - "background-base": "#fbf1c7", - "background-weak": "#f2e5bc", - "background-strong": "#f9f5d7", - "background-stronger": "#fdf9e8", - "surface-raised-stronger-non-alpha": "#fbfaf5", - "border-weak-base": "#d5c4a1", - "border-weak-hover": "#c9b897", - "border-weak-active": "#bdae93", - "border-weak-selected": "#b0a285", - "border-weak-disabled": "#f0e4b8", - "border-weak-focus": "#c4b590", - "border-base": "#bdae93", - "border-hover": "#b0a285", - "border-active": "#a89984", - "border-selected": "#928374", - "border-disabled": "#e5d9ad", - "border-focus": "#a89984", - "border-strong-base": "#7c6f64", - "border-strong-hover": "#6e6259", - "border-strong-active": "#665c54", - "border-strong-selected": "#5a524b", - "border-strong-disabled": "#c9bda1", - "border-strong-focus": "#665c54", - "surface-diff-add-base": "#dde3b1", - "surface-diff-delete-base": "#e8c7c3", - "surface-diff-hidden-base": "#ebdfb5", - "text-base": "#3c3836", - "text-weak": "#7c6f64", - "text-strong": "#282828", - "syntax-string": "#79740e", - "syntax-primitive": "#9d0006", - "syntax-property": "#076678", - "syntax-type": "#b57614", - "syntax-constant": "#8f3f71", - "syntax-info": "#427b58", - "markdown-heading": "#076678", - "markdown-text": "#3c3836", - "markdown-link": "#076678", - "markdown-link-text": "#427b58", - "markdown-code": "#79740e", - "markdown-block-quote": "#928374", - "markdown-emph": "#8f3f71", - "markdown-strong": "#af3a03", - "markdown-horizontal-rule": "#d5c4a1", - "markdown-list-item": "#076678", - "markdown-list-enumeration": "#427b58", - "markdown-image": "#076678", - "markdown-image-text": "#427b58", - "markdown-code-block": "#3c3836" + "syntax-keyword": "#9d0006", + "syntax-primitive": "#8f3f71" } }, "dark": { - "seeds": { + "palette": { "neutral": "#282828", + "ink": "#ebdbb2", "primary": "#83a598", + "accent": "#fb4934", "success": "#b8bb26", "warning": "#fabd2f", "error": "#fb4934", "info": "#d3869b", - "interactive": "#83a598", "diffAdd": "#b8bb26", "diffDelete": "#fb4934" }, "overrides": { - "background-base": "#282828", - "background-weak": "#32302f", - "background-strong": "#1d2021", - "background-stronger": "#141617", - "border-weak-base": "#504945", - "border-weak-hover": "#5a524b", - "border-weak-active": "#665c54", - "border-weak-selected": "#70665d", - "border-weak-disabled": "#1e1d1c", - "border-weak-focus": "#5e5650", - "border-base": "#665c54", - "border-hover": "#70665d", - "border-active": "#7c6f64", - "border-selected": "#928374", - "border-disabled": "#2a2827", - "border-focus": "#7c6f64", - "border-strong-base": "#928374", - "border-strong-hover": "#9d8e7f", - "border-strong-active": "#a89984", - "border-strong-selected": "#b3a48f", - "border-strong-disabled": "#3c3836", - "border-strong-focus": "#a89984", - "surface-diff-add-base": "#2a3325", - "surface-diff-delete-base": "#3c2222", - "surface-diff-hidden-base": "#32302f", - "text-base": "#ebdbb2", - "text-weak": "#a89984", - "text-strong": "#fbf1c7", - "syntax-string": "#b8bb26", - "syntax-primitive": "#fb4934", - "syntax-property": "#83a598", - "syntax-type": "#fabd2f", - "syntax-constant": "#d3869b", - "syntax-info": "#8ec07c", - "markdown-heading": "#83a598", - "markdown-text": "#ebdbb2", - "markdown-link": "#83a598", - "markdown-link-text": "#8ec07c", - "markdown-code": "#b8bb26", - "markdown-block-quote": "#928374", - "markdown-emph": "#d3869b", - "markdown-strong": "#fe8019", - "markdown-horizontal-rule": "#504945", - "markdown-list-item": "#83a598", - "markdown-list-enumeration": "#8ec07c", - "markdown-image": "#83a598", - "markdown-image-text": "#8ec07c", - "markdown-code-block": "#ebdbb2" + "syntax-keyword": "#fb4934", + "syntax-primitive": "#d3869b" } } } diff --git a/packages/ui/src/theme/themes/monokai.json b/packages/ui/src/theme/themes/monokai.json index d49846ddb3..3a2656b6ea 100644 --- a/packages/ui/src/theme/themes/monokai.json +++ b/packages/ui/src/theme/themes/monokai.json @@ -3,129 +3,41 @@ "name": "Monokai", "id": "monokai", "light": { - "seeds": { + "palette": { "neutral": "#fdf8ec", + "ink": "#292318", "primary": "#bf7bff", + "accent": "#d9487c", "success": "#4fb54b", "warning": "#f1a948", "error": "#e54b4b", "info": "#2d9ad7", - "interactive": "#bf7bff", "diffAdd": "#bfe7a3", "diffDelete": "#f6a3ae" }, "overrides": { - "background-base": "#fdf8ec", - "background-weak": "#f8f2e6", - "background-strong": "#fbf5e8", - "background-stronger": "#f7efdd", - "border-weak-base": "#e9e0cf", - "border-weak-hover": "#dfd5c3", - "border-weak-active": "#d5cab7", - "border-weak-selected": "#cabfad", - "border-weak-disabled": "#f3ebdd", - "border-weak-focus": "#d0c2b1", - "border-base": "#c7b9a5", - "border-hover": "#bcae98", - "border-active": "#b0a28c", - "border-selected": "#a49781", - "border-disabled": "#efe5d6", - "border-focus": "#b6a893", - "border-strong-base": "#998b76", - "border-strong-hover": "#8a7c67", - "border-strong-active": "#7a6d58", - "border-strong-selected": "#6c604c", - "border-strong-disabled": "#d7cabc", - "border-strong-focus": "#82745f", - "surface-diff-add-base": "#e8f7e1", - "surface-diff-delete-base": "#fde5e4", - "surface-diff-hidden-base": "#e9e0d0", - "text-base": "#292318", - "text-weak": "#6d5c40", - "text-strong": "#1c150c", - "syntax-string": "#4fb54b", - "syntax-primitive": "#d9487c", - "syntax-property": "#bf7bff", - "syntax-type": "#f1a948", - "syntax-constant": "#2d9ad7", - "syntax-info": "#2d9ad7", - "markdown-heading": "#bf7bff", - "markdown-text": "#292318", - "markdown-link": "#bf7bff", - "markdown-link-text": "#2d9ad7", - "markdown-code": "#4fb54b", - "markdown-block-quote": "#f1a948", - "markdown-emph": "#f1a948", - "markdown-strong": "#d9487c", - "markdown-horizontal-rule": "#cdbdab", - "markdown-list-item": "#bf7bff", - "markdown-list-enumeration": "#2d9ad7", - "markdown-image": "#bf7bff", - "markdown-image-text": "#2d9ad7", - "markdown-code-block": "#2d9ad7" + "syntax-keyword": "#d9487c", + "syntax-string": "#8a6500", + "syntax-primitive": "#bf7bff" } }, "dark": { - "seeds": { + "palette": { "neutral": "#272822", + "ink": "#f8f8f2", "primary": "#ae81ff", + "accent": "#f92672", "success": "#a6e22e", "warning": "#fd971f", "error": "#f92672", "info": "#66d9ef", - "interactive": "#ae81ff", "diffAdd": "#4d7f2a", "diffDelete": "#f4477c" }, "overrides": { - "background-base": "#23241e", - "background-weak": "#27281f", - "background-strong": "#25261f", - "background-stronger": "#292a23", - "border-weak-base": "#343528", - "border-weak-hover": "#393a2d", - "border-weak-active": "#3f4033", - "border-weak-selected": "#454639", - "border-weak-disabled": "#1d1e16", - "border-weak-focus": "#414235", - "border-base": "#494a3a", - "border-hover": "#50523f", - "border-active": "#585a45", - "border-selected": "#60624b", - "border-disabled": "#23241b", - "border-focus": "#555741", - "border-strong-base": "#6a6c55", - "border-strong-hover": "#73755d", - "border-strong-active": "#7d7f66", - "border-strong-selected": "#878970", - "border-strong-disabled": "#2c2d23", - "border-strong-focus": "#7a7c63", - "surface-diff-add-base": "#1e2a1d", - "surface-diff-delete-base": "#301c24", - "surface-diff-hidden-base": "#2f2f24", - "text-base": "#f8f8f2", - "text-weak": "#c5c5c0", - "text-strong": "#ffffff", - "syntax-string": "#a6e22e", - "syntax-primitive": "#f92672", - "syntax-property": "#ae81ff", - "syntax-type": "#fd971f", - "syntax-constant": "#66d9ef", - "syntax-info": "#66d9ef", - "markdown-heading": "#ae81ff", - "markdown-text": "#f8f8f2", - "markdown-link": "#ae81ff", - "markdown-link-text": "#66d9ef", - "markdown-code": "#a6e22e", - "markdown-block-quote": "#fd971f", - "markdown-emph": "#fd971f", - "markdown-strong": "#f92672", - "markdown-horizontal-rule": "#3b3c34", - "markdown-list-item": "#ae81ff", - "markdown-list-enumeration": "#66d9ef", - "markdown-image": "#ae81ff", - "markdown-image-text": "#66d9ef", - "markdown-code-block": "#f8f8f2" + "syntax-keyword": "#f92672", + "syntax-string": "#e6db74", + "syntax-primitive": "#ae81ff" } } } diff --git a/packages/ui/src/theme/themes/nightowl.json b/packages/ui/src/theme/themes/nightowl.json index 5b0331e5fd..d6b4d4dad2 100644 --- a/packages/ui/src/theme/themes/nightowl.json +++ b/packages/ui/src/theme/themes/nightowl.json @@ -3,129 +3,40 @@ "name": "Night Owl", "id": "nightowl", "light": { - "seeds": { + "palette": { "neutral": "#f0f0f0", + "ink": "#403f53", "primary": "#4876d6", + "accent": "#aa0982", "success": "#2aa298", "warning": "#c96765", "error": "#de3d3b", "info": "#4876d6", - "interactive": "#4876d6", "diffAdd": "#2aa298", "diffDelete": "#de3d3b" }, "overrides": { - "background-base": "#fbfbfb", - "background-weak": "#f0f0f0", - "background-strong": "#ffffff", - "background-stronger": "#ffffff", - "border-weak-base": "#d9d9d9", - "border-weak-hover": "#cccccc", - "border-weak-active": "#bfbfbf", - "border-weak-selected": "#4876d6", - "border-weak-disabled": "#e6e6e6", - "border-weak-focus": "#4876d6", - "border-base": "#c0c0c0", - "border-hover": "#b3b3b3", - "border-active": "#a6a6a6", - "border-selected": "#4876d6", - "border-disabled": "#d9d9d9", - "border-focus": "#4876d6", - "border-strong-base": "#90a7b2", - "border-strong-hover": "#7d9aa6", - "border-strong-active": "#6a8d9a", - "border-strong-selected": "#4876d6", - "border-strong-disabled": "#c0c0c0", - "border-strong-focus": "#4876d6", - "surface-diff-add-base": "#eaf8f6", - "surface-diff-delete-base": "#fbe9e9", - "surface-diff-hidden-base": "#e8f0fc", - "text-base": "#403f53", - "text-weak": "#7a8181", - "text-strong": "#1a1a1a", - "syntax-string": "#c96765", - "syntax-primitive": "#aa0982", - "syntax-property": "#4876d6", - "syntax-type": "#994cc3", - "syntax-constant": "#2aa298", - "syntax-info": "#4876d6", - "markdown-heading": "#4876d6", - "markdown-text": "#403f53", - "markdown-link": "#4876d6", - "markdown-link-text": "#2aa298", - "markdown-code": "#2aa298", - "markdown-block-quote": "#7a8181", - "markdown-emph": "#994cc3", - "markdown-strong": "#c96765", - "markdown-horizontal-rule": "#90a7b2", - "markdown-list-item": "#4876d6", - "markdown-list-enumeration": "#2aa298", - "markdown-image": "#4876d6", - "markdown-image-text": "#2aa298", - "markdown-code-block": "#403f53" + "syntax-keyword": "#994cc3" } }, "dark": { - "seeds": { + "palette": { "neutral": "#011627", + "ink": "#d6deeb", "primary": "#82aaff", + "accent": "#f78c6c", "success": "#c5e478", "warning": "#ecc48d", "error": "#ef5350", "info": "#82aaff", - "interactive": "#82aaff", "diffAdd": "#c5e478", "diffDelete": "#ef5350" }, "overrides": { - "background-base": "#011627", - "background-weak": "#0b253a", - "background-strong": "#001122", - "background-stronger": "#000c17", - "border-weak-base": "#1d3b53", - "border-weak-hover": "#234561", - "border-weak-active": "#2a506f", - "border-weak-selected": "#82aaff", - "border-weak-disabled": "#0f2132", - "border-weak-focus": "#82aaff", - "border-base": "#3a5a75", - "border-hover": "#456785", - "border-active": "#507494", - "border-selected": "#82aaff", - "border-disabled": "#1a3347", - "border-focus": "#82aaff", - "border-strong-base": "#5f7e97", - "border-strong-hover": "#6e8da6", - "border-strong-active": "#7d9cb5", - "border-strong-selected": "#82aaff", - "border-strong-disabled": "#2c4a63", - "border-strong-focus": "#82aaff", - "surface-diff-add-base": "#0a2e1a", - "surface-diff-delete-base": "#2d1b1b", - "surface-diff-hidden-base": "#0b253a", - "text-base": "#d6deeb", - "text-weak": "#5f7e97", - "text-strong": "#ffffff", + "syntax-comment": "#637777", + "syntax-keyword": "#c792ea", "syntax-string": "#ecc48d", - "syntax-primitive": "#f78c6c", - "syntax-property": "#82aaff", - "syntax-type": "#c5e478", - "syntax-constant": "#7fdbca", - "syntax-info": "#82aaff", - "markdown-heading": "#82aaff", - "markdown-text": "#d6deeb", - "markdown-link": "#82aaff", - "markdown-link-text": "#7fdbca", - "markdown-code": "#c5e478", - "markdown-block-quote": "#5f7e97", - "markdown-emph": "#c792ea", - "markdown-strong": "#ecc48d", - "markdown-horizontal-rule": "#5f7e97", - "markdown-list-item": "#82aaff", - "markdown-list-enumeration": "#7fdbca", - "markdown-image": "#82aaff", - "markdown-image-text": "#7fdbca", - "markdown-code-block": "#d6deeb" + "syntax-primitive": "#f78c6c" } } } diff --git a/packages/ui/src/theme/themes/nord.json b/packages/ui/src/theme/themes/nord.json index 44378de06a..05ec4672ec 100644 --- a/packages/ui/src/theme/themes/nord.json +++ b/packages/ui/src/theme/themes/nord.json @@ -3,129 +3,40 @@ "name": "Nord", "id": "nord", "light": { - "seeds": { + "palette": { "neutral": "#eceff4", + "ink": "#2e3440", "primary": "#5e81ac", + "accent": "#bf616a", "success": "#8fbcbb", "warning": "#d08770", "error": "#bf616a", "info": "#81a1c1", - "interactive": "#5e81ac", "diffAdd": "#a3be8c", "diffDelete": "#bf616a" }, "overrides": { - "background-base": "#eceff4", - "background-weak": "#e4e8f0", - "background-strong": "#f1f3f8", - "background-stronger": "#f6f8fc", - "border-weak-base": "#d5dbe7", - "border-weak-hover": "#c9d0de", - "border-weak-active": "#bec5d4", - "border-weak-selected": "#b2bacc", - "border-weak-disabled": "#f0f3fa", - "border-weak-focus": "#b9bfd0", - "border-base": "#afb7cb", - "border-hover": "#a3abc1", - "border-active": "#979fb7", - "border-selected": "#8b94ad", - "border-disabled": "#e5e9f2", - "border-focus": "#9ca4ba", - "border-strong-base": "#757f97", - "border-strong-hover": "#69718a", - "border-strong-active": "#5d647d", - "border-strong-selected": "#525970", - "border-strong-disabled": "#c9cedc", - "border-strong-focus": "#636c84", - "surface-diff-add-base": "#e4f0e4", - "surface-diff-delete-base": "#f4e1e4", - "surface-diff-hidden-base": "#dfe6f2", - "text-base": "#2e3440", - "text-weak": "#4c566a", - "text-strong": "#1f2530", + "syntax-keyword": "#5e81ac", "syntax-string": "#a3be8c", - "syntax-primitive": "#bf616a", - "syntax-property": "#5e81ac", - "syntax-type": "#d08770", - "syntax-constant": "#81a1c1", - "syntax-info": "#81a1c1", - "markdown-heading": "#5e81ac", - "markdown-text": "#2e3440", - "markdown-link": "#5e81ac", - "markdown-link-text": "#81a1c1", - "markdown-code": "#a3be8c", - "markdown-block-quote": "#d08770", - "markdown-emph": "#d08770", - "markdown-strong": "#bf616a", - "markdown-horizontal-rule": "#cbd3e1", - "markdown-list-item": "#5e81ac", - "markdown-list-enumeration": "#81a1c1", - "markdown-image": "#5e81ac", - "markdown-image-text": "#81a1c1", - "markdown-code-block": "#5e81ac" + "syntax-primitive": "#b48ead" } }, "dark": { - "seeds": { + "palette": { "neutral": "#2e3440", + "ink": "#e5e9f0", "primary": "#88c0d0", + "accent": "#d57780", "success": "#a3be8c", "warning": "#d08770", "error": "#bf616a", "info": "#81a1c1", - "interactive": "#88c0d0", "diffAdd": "#81a1c1", "diffDelete": "#bf616a" }, "overrides": { - "background-base": "#1f2430", - "background-weak": "#222938", - "background-strong": "#1c202a", - "background-stronger": "#181c24", - "border-weak-base": "#343a47", - "border-weak-hover": "#383f50", - "border-weak-active": "#3d4458", - "border-weak-selected": "#434a62", - "border-weak-disabled": "#151923", - "border-weak-focus": "#3f4359", - "border-base": "#4a5163", - "border-hover": "#515870", - "border-active": "#585f7c", - "border-selected": "#606889", - "border-disabled": "#1b202a", - "border-focus": "#545b78", - "border-strong-base": "#6a7492", - "border-strong-hover": "#747e9f", - "border-strong-active": "#7e88ac", - "border-strong-selected": "#8993b9", - "border-strong-disabled": "#232836", - "border-strong-focus": "#76819f", - "surface-diff-add-base": "#1f2e33", - "surface-diff-delete-base": "#2e212a", - "surface-diff-hidden-base": "#222b3a", - "text-base": "#e5e9f0", - "text-weak": "#a4adbf", - "text-strong": "#f8fafc", - "syntax-string": "#a3be8c", - "syntax-primitive": "#d57780", - "syntax-property": "#88c0d0", - "syntax-type": "#eac196", - "syntax-constant": "#81a1c1", - "syntax-info": "#81a1c1", - "markdown-heading": "#88c0d0", - "markdown-text": "#e5e9f0", - "markdown-link": "#88c0d0", - "markdown-link-text": "#81a1c1", - "markdown-code": "#a3be8c", - "markdown-block-quote": "#d08770", - "markdown-emph": "#d08770", - "markdown-strong": "#bf616a", - "markdown-horizontal-rule": "#2f384a", - "markdown-list-item": "#88c0d0", - "markdown-list-enumeration": "#81a1c1", - "markdown-image": "#88c0d0", - "markdown-image-text": "#81a1c1", - "markdown-code-block": "#cbd3e1" + "syntax-keyword": "#81a1c1", + "syntax-primitive": "#b48ead" } } } diff --git a/packages/ui/src/theme/themes/oc-1.json b/packages/ui/src/theme/themes/oc-1.json index 03a67ee239..7dec9cb802 100644 --- a/packages/ui/src/theme/themes/oc-1.json +++ b/packages/ui/src/theme/themes/oc-1.json @@ -3,9 +3,11 @@ "name": "OC-1", "id": "oc-1", "light": { - "seeds": { + "palette": { "neutral": "#8e8b8b", + "ink": "#656363", "primary": "#dcde8d", + "accent": "#fb4804", "success": "#12c905", "warning": "#ffdc17", "error": "#fc533a", @@ -13,265 +15,14 @@ "interactive": "#034cff", "diffAdd": "#9ff29a", "diffDelete": "#fc533a" - }, - "overrides": { - "background-base": "#f8f7f7", - "background-weak": "var(--smoke-light-3)", - "background-strong": "var(--smoke-light-1)", - "background-stronger": "#fcfcfc", - "surface-base": "var(--smoke-light-alpha-2)", - "base": "var(--smoke-light-alpha-2)", - "surface-base-hover": "#0500000f", - "surface-base-active": "var(--smoke-light-alpha-3)", - "surface-base-interactive-active": "var(--cobalt-light-alpha-3)", - "base2": "var(--smoke-light-alpha-2)", - "base3": "var(--smoke-light-alpha-2)", - "surface-inset-base": "var(--smoke-light-alpha-2)", - "surface-inset-base-hover": "var(--smoke-light-alpha-3)", - "surface-inset-strong": "#1f000017", - "surface-inset-strong-hover": "#1f000017", - "surface-raised-base": "var(--smoke-light-alpha-2)", - "surface-float-base": "var(--smoke-dark-1)", - "surface-float-base-hover": "var(--smoke-dark-2)", - "surface-raised-base-hover": "var(--smoke-light-alpha-3)", - "surface-raised-base-active": "var(--smoke-light-alpha-4)", - "surface-raised-strong": "var(--smoke-light-1)", - "surface-raised-strong-hover": "var(--white)", - "surface-raised-stronger": "var(--white)", - "surface-raised-stronger-hover": "var(--white)", - "surface-weak": "var(--smoke-light-alpha-3)", - "surface-weaker": "var(--smoke-light-alpha-4)", - "surface-strong": "#ffffff", - "surface-raised-stronger-non-alpha": "var(--white)", - "surface-brand-base": "var(--yuzu-light-9)", - "surface-brand-hover": "var(--yuzu-light-10)", - "surface-interactive-base": "var(--cobalt-light-3)", - "surface-interactive-hover": "#E5F0FF", - "surface-interactive-weak": "var(--cobalt-light-2)", - "surface-interactive-weak-hover": "var(--cobalt-light-3)", - "surface-success-base": "var(--apple-light-3)", - "surface-success-weak": "var(--apple-light-2)", - "surface-success-strong": "var(--apple-light-9)", - "surface-warning-base": "var(--solaris-light-3)", - "surface-warning-weak": "var(--solaris-light-2)", - "surface-warning-strong": "var(--solaris-light-9)", - "surface-critical-base": "var(--ember-light-3)", - "surface-critical-weak": "var(--ember-light-2)", - "surface-critical-strong": "var(--ember-light-9)", - "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": "#ffffff00", - "surface-diff-skip-base": "var(--smoke-light-2)", - "surface-diff-hidden-base": "var(--blue-light-3)", - "surface-diff-hidden-weak": "var(--blue-light-2)", - "surface-diff-hidden-weaker": "var(--blue-light-1)", - "surface-diff-hidden-strong": "var(--blue-light-5)", - "surface-diff-hidden-stronger": "var(--blue-light-9)", - "surface-diff-add-base": "#dafbe0", - "surface-diff-add-weak": "var(--mint-light-2)", - "surface-diff-add-weaker": "var(--mint-light-1)", - "surface-diff-add-strong": "var(--mint-light-5)", - "surface-diff-add-stronger": "var(--mint-light-9)", - "surface-diff-delete-base": "var(--ember-light-3)", - "surface-diff-delete-weak": "var(--ember-light-2)", - "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(--smoke-light-1)", - "input-hover": "var(--smoke-light-2)", - "input-active": "var(--cobalt-light-1)", - "input-selected": "var(--cobalt-light-4)", - "input-focus": "var(--cobalt-light-1)", - "input-disabled": "var(--smoke-light-4)", - "text-base": "var(--smoke-light-11)", - "text-weak": "var(--smoke-light-9)", - "text-weaker": "var(--smoke-light-8)", - "text-strong": "var(--smoke-light-12)", - "text-invert-base": "var(--smoke-dark-alpha-11)", - "text-invert-weak": "var(--smoke-dark-alpha-9)", - "text-invert-weaker": "var(--smoke-dark-alpha-8)", - "text-invert-strong": "var(--smoke-dark-alpha-12)", - "text-interactive-base": "var(--cobalt-light-9)", - "text-on-brand-base": "var(--smoke-light-alpha-11)", - "text-on-interactive-base": "var(--smoke-light-1)", - "text-on-interactive-weak": "var(--smoke-dark-alpha-11)", - "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(--smoke-dark-alpha-11)", - "text-on-info-base": "var(--smoke-dark-alpha-11)", - "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(--smoke-dark-alpha-9)", - "text-on-info-strong": "var(--smoke-dark-alpha-12)", - "text-on-warning-weak": "var(--smoke-dark-alpha-9)", - "text-on-warning-strong": "var(--smoke-dark-alpha-12)", - "text-on-success-weak": "var(--apple-light-6)", - "text-on-success-strong": "var(--apple-light-12)", - "text-on-brand-weak": "var(--smoke-light-alpha-9)", - "text-on-brand-weaker": "var(--smoke-light-alpha-8)", - "text-on-brand-strong": "var(--smoke-light-alpha-12)", - "button-primary-base": "var(--smoke-light-12)", - "button-secondary-base": "#fdfcfc", - "button-secondary-hover": "#faf9f9", - "border-base": "var(--smoke-light-alpha-7)", - "border-hover": "var(--smoke-light-alpha-8)", - "border-active": "var(--smoke-light-alpha-9)", - "border-selected": "var(--cobalt-light-alpha-9)", - "border-disabled": "var(--smoke-light-alpha-8)", - "border-focus": "var(--smoke-light-alpha-9)", - "border-weak-base": "var(--smoke-light-alpha-5)", - "border-strong-base": "var(--smoke-light-alpha-7)", - "border-strong-hover": "var(--smoke-light-alpha-8)", - "border-strong-active": "var(--smoke-light-alpha-7)", - "border-strong-selected": "var(--cobalt-light-alpha-6)", - "border-strong-disabled": "var(--smoke-light-alpha-6)", - "border-strong-focus": "var(--smoke-light-alpha-7)", - "border-weak-hover": "var(--smoke-light-alpha-6)", - "border-weak-active": "var(--smoke-light-alpha-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-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(--smoke-light-8)", - "border-interactive-focus": "var(--cobalt-light-9)", - "border-success-base": "var(--apple-light-6)", - "border-success-hover": "var(--apple-light-7)", - "border-success-selected": "var(--apple-light-9)", - "border-warning-base": "var(--solaris-light-6)", - "border-warning-hover": "var(--solaris-light-7)", - "border-warning-selected": "var(--solaris-light-9)", - "border-critical-base": "var(--ember-light-6)", - "border-critical-hover": "var(--ember-light-7)", - "border-critical-selected": "var(--ember-light-9)", - "border-info-base": "var(--lilac-light-6)", - "border-info-hover": "var(--lilac-light-7)", - "border-info-selected": "var(--lilac-light-9)", - "icon-base": "var(--smoke-light-9)", - "icon-hover": "var(--smoke-light-11)", - "icon-active": "var(--smoke-light-12)", - "icon-selected": "var(--smoke-light-12)", - "icon-disabled": "var(--smoke-light-8)", - "icon-focus": "var(--smoke-light-12)", - "icon-invert-base": "#ffffff", - "icon-weak-base": "var(--smoke-light-7)", - "icon-weak-hover": "var(--smoke-light-8)", - "icon-weak-active": "var(--smoke-light-9)", - "icon-weak-selected": "var(--smoke-light-10)", - "icon-weak-disabled": "var(--smoke-light-6)", - "icon-weak-focus": "var(--smoke-light-9)", - "icon-strong-base": "var(--smoke-light-12)", - "icon-strong-hover": "#151313", - "icon-strong-active": "#020202", - "icon-strong-selected": "#020202", - "icon-strong-disabled": "var(--smoke-light-8)", - "icon-strong-focus": "#020202", - "icon-brand-base": "var(--smoke-light-12)", - "icon-interactive-base": "var(--cobalt-light-9)", - "icon-success-base": "var(--apple-light-7)", - "icon-success-hover": "var(--apple-light-8)", - "icon-success-active": "var(--apple-light-11)", - "icon-warning-base": "var(--amber-light-7)", - "icon-warning-hover": "var(--amber-light-8)", - "icon-warning-active": "var(--amber-light-11)", - "icon-critical-base": "var(--ember-light-10)", - "icon-critical-hover": "var(--ember-light-11)", - "icon-critical-active": "var(--ember-light-12)", - "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(--smoke-light-alpha-11)", - "icon-on-brand-hover": "var(--smoke-light-alpha-12)", - "icon-on-brand-selected": "var(--smoke-light-alpha-12)", - "icon-on-interactive-base": "var(--smoke-light-1)", - "icon-agent-plan-base": "var(--purple-light-9)", - "icon-agent-docs-base": "var(--amber-light-9)", - "icon-agent-ask-base": "var(--cyan-light-9)", - "icon-agent-build-base": "var(--cobalt-light-9)", - "icon-on-success-base": "var(--apple-light-alpha-9)", - "icon-on-success-hover": "var(--apple-light-alpha-10)", - "icon-on-success-selected": "var(--apple-light-alpha-11)", - "icon-on-warning-base": "var(--amber-lightalpha-9)", - "icon-on-warning-hover": "var(--amber-lightalpha-10)", - "icon-on-warning-selected": "var(--amber-lightalpha-11)", - "icon-on-critical-base": "var(--ember-light-alpha-9)", - "icon-on-critical-hover": "var(--ember-light-alpha-10)", - "icon-on-critical-selected": "var(--ember-light-alpha-11)", - "icon-on-info-base": "var(--lilac-light-9)", - "icon-on-info-hover": "var(--lilac-light-alpha-10)", - "icon-on-info-selected": "var(--lilac-light-alpha-11)", - "icon-diff-add-base": "var(--mint-light-11)", - "icon-diff-add-hover": "var(--mint-light-12)", - "icon-diff-add-active": "var(--mint-light-12)", - "icon-diff-delete-base": "var(--ember-light-10)", - "icon-diff-delete-hover": "var(--ember-light-11)", - "syntax-comment": "var(--text-weak)", - "syntax-regexp": "var(--text-base)", - "syntax-string": "#006656", - "syntax-keyword": "var(--text-weak)", - "syntax-primitive": "#fb4804", - "syntax-operator": "var(--text-base)", - "syntax-variable": "var(--text-strong)", - "syntax-property": "#ed6dc8", - "syntax-type": "#596600", - "syntax-constant": "#007b80", - "syntax-punctuation": "var(--text-base)", - "syntax-object": "var(--text-strong)", - "syntax-success": "var(--apple-light-10)", - "syntax-warning": "var(--amber-light-10)", - "syntax-critical": "var(--ember-light-10)", - "syntax-info": "#0092a8", - "syntax-diff-add": "var(--mint-light-11)", - "syntax-diff-delete": "var(--ember-light-11)", - "syntax-diff-unknown": "#ff0000", - "markdown-heading": "#d68c27", - "markdown-text": "#1a1a1a", - "markdown-link": "#3b7dd8", - "markdown-link-text": "#318795", - "markdown-code": "#3d9a57", - "markdown-block-quote": "#b0851f", - "markdown-emph": "#b0851f", - "markdown-strong": "#d68c27", - "markdown-horizontal-rule": "#8a8a8a", - "markdown-list-item": "#3b7dd8", - "markdown-list-enumeration": "#318795", - "markdown-image": "#3b7dd8", - "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", - "avatar-background-mint": "#e1fbf4", - "avatar-background-orange": "#fff1e7", - "avatar-background-purple": "#f9f1fe", - "avatar-background-cyan": "#e7f9fb", - "avatar-background-lime": "#eefadc", - "avatar-text-pink": "#cd1d8d", - "avatar-text-mint": "#147d6f", - "avatar-text-orange": "#ed5f00", - "avatar-text-purple": "#8445bc", - "avatar-text-cyan": "#0894b3", - "avatar-text-lime": "#5d770d" } }, "dark": { - "seeds": { + "palette": { "neutral": "#716c6b", + "ink": "#b7b1b1", "primary": "#fab283", + "accent": "#ffba92", "success": "#12c905", "warning": "#fcd53a", "error": "#fc533a", @@ -279,259 +30,6 @@ "interactive": "#034cff", "diffAdd": "#c8ffc4", "diffDelete": "#fc533a" - }, - "overrides": { - "background-base": "var(--smoke-dark-1)", - "background-weak": "#1c1717", - "background-strong": "#151313", - "background-stronger": "#191515", - "surface-base": "var(--smoke-dark-alpha-2)", - "base": "var(--smoke-dark-alpha-2)", - "surface-base-hover": "#e0b7b716", - "surface-base-active": "var(--smoke-dark-alpha-3)", - "surface-base-interactive-active": "var(--cobalt-dark-alpha-2)", - "base2": "var(--smoke-dark-alpha-2)", - "base3": "var(--smoke-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(--smoke-dark-alpha-3)", - "surface-float-base": "var(--smoke-dark-1)", - "surface-float-base-hover": "var(--smoke-dark-2)", - "surface-raised-base-hover": "var(--smoke-dark-alpha-4)", - "surface-raised-base-active": "var(--smoke-dark-alpha-5)", - "surface-raised-strong": "var(--smoke-dark-alpha-4)", - "surface-raised-strong-hover": "var(--smoke-dark-alpha-6)", - "surface-raised-stronger": "var(--smoke-dark-alpha-6)", - "surface-raised-stronger-hover": "var(--smoke-dark-alpha-7)", - "surface-weak": "var(--smoke-dark-alpha-4)", - "surface-weaker": "var(--smoke-dark-alpha-5)", - "surface-strong": "var(--smoke-dark-alpha-7)", - "surface-raised-stronger-non-alpha": "var(--smoke-dark-3)", - "surface-brand-base": "var(--yuzu-light-9)", - "surface-brand-hover": "var(--yuzu-light-10)", - "surface-interactive-base": "var(--cobalt-dark-3)", - "surface-interactive-hover": "#0A1D4D", - "surface-interactive-weak": "var(--cobalt-dark-2)", - "surface-interactive-weak-hover": "var(--cobalt-light-3)", - "surface-success-base": "var(--apple-dark-3)", - "surface-success-weak": "var(--apple-dark-2)", - "surface-success-strong": "var(--apple-dark-9)", - "surface-warning-base": "var(--solaris-light-3)", - "surface-warning-weak": "var(--solaris-light-2)", - "surface-warning-strong": "var(--solaris-light-9)", - "surface-critical-base": "var(--ember-dark-3)", - "surface-critical-weak": "var(--ember-dark-2)", - "surface-critical-strong": "var(--ember-dark-9)", - "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(--smoke-dark-1)", - "surface-diff-skip-base": "var(--smoke-dark-alpha-1)", - "surface-diff-hidden-base": "var(--blue-dark-2)", - "surface-diff-hidden-weak": "var(--blue-dark-1)", - "surface-diff-hidden-weaker": "var(--blue-dark-3)", - "surface-diff-hidden-strong": "var(--blue-dark-5)", - "surface-diff-hidden-stronger": "var(--blue-dark-11)", - "surface-diff-add-base": "var(--mint-dark-3)", - "surface-diff-add-weak": "var(--mint-dark-4)", - "surface-diff-add-weaker": "var(--mint-dark-3)", - "surface-diff-add-strong": "var(--mint-dark-5)", - "surface-diff-add-stronger": "var(--mint-dark-11)", - "surface-diff-delete-base": "var(--ember-dark-3)", - "surface-diff-delete-weak": "var(--ember-dark-4)", - "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(--smoke-dark-2)", - "input-hover": "var(--smoke-dark-2)", - "input-active": "var(--cobalt-dark-1)", - "input-selected": "var(--cobalt-dark-2)", - "input-focus": "var(--cobalt-dark-1)", - "input-disabled": "var(--smoke-dark-4)", - "text-base": "var(--smoke-dark-alpha-11)", - "text-weak": "var(--smoke-dark-alpha-9)", - "text-weaker": "var(--smoke-dark-alpha-8)", - "text-strong": "var(--smoke-dark-alpha-12)", - "text-invert-base": "var(--smoke-dark-alpha-11)", - "text-invert-weak": "var(--smoke-dark-alpha-9)", - "text-invert-weaker": "var(--smoke-dark-alpha-8)", - "text-invert-strong": "var(--smoke-dark-alpha-12)", - "text-interactive-base": "var(--cobalt-dark-11)", - "text-on-brand-base": "var(--smoke-dark-alpha-11)", - "text-on-interactive-base": "var(--smoke-dark-12)", - "text-on-interactive-weak": "var(--smoke-dark-alpha-11)", - "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(--smoke-dark-alpha-11)", - "text-on-info-base": "var(--smoke-dark-alpha-11)", - "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(--smoke-dark-alpha-9)", - "text-on-info-strong": "var(--smoke-dark-alpha-12)", - "text-on-warning-weak": "var(--smoke-dark-alpha-9)", - "text-on-warning-strong": "var(--smoke-dark-alpha-12)", - "text-on-success-weak": "var(--apple-dark-8)", - "text-on-success-strong": "var(--apple-dark-12)", - "text-on-brand-weak": "var(--smoke-dark-alpha-9)", - "text-on-brand-weaker": "var(--smoke-dark-alpha-8)", - "text-on-brand-strong": "var(--smoke-dark-alpha-12)", - "button-primary-base": "var(--smoke-dark-12)", - "button-secondary-base": "#231f1f", - "button-secondary-hover": "#2a2727", - "border-base": "var(--smoke-dark-alpha-7)", - "border-hover": "var(--smoke-dark-alpha-8)", - "border-active": "var(--smoke-dark-alpha-9)", - "border-selected": "var(--cobalt-dark-alpha-11)", - "border-disabled": "var(--smoke-dark-alpha-8)", - "border-focus": "var(--smoke-dark-alpha-9)", - "border-weak-base": "var(--smoke-dark-alpha-6)", - "border-strong-base": "var(--smoke-dark-alpha-8)", - "border-strong-hover": "var(--smoke-dark-alpha-7)", - "border-strong-active": "var(--smoke-dark-alpha-8)", - "border-strong-selected": "var(--cobalt-dark-alpha-6)", - "border-strong-disabled": "var(--smoke-dark-alpha-6)", - "border-strong-focus": "var(--smoke-dark-alpha-8)", - "border-weak-hover": "var(--smoke-dark-alpha-7)", - "border-weak-active": "var(--smoke-dark-alpha-8)", - "border-weak-selected": "var(--cobalt-dark-alpha-6)", - "border-weak-disabled": "var(--smoke-dark-alpha-6)", - "border-weak-focus": "var(--smoke-dark-alpha-8)", - "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(--smoke-light-8)", - "border-interactive-focus": "var(--cobalt-light-9)", - "border-success-base": "var(--apple-light-6)", - "border-success-hover": "var(--apple-light-7)", - "border-success-selected": "var(--apple-light-9)", - "border-warning-base": "var(--solaris-light-6)", - "border-warning-hover": "var(--solaris-light-7)", - "border-warning-selected": "var(--solaris-light-9)", - "border-critical-base": "var(--ember-dark-5)", - "border-critical-hover": "var(--ember-dark-7)", - "border-critical-selected": "var(--ember-dark-9)", - "border-info-base": "var(--lilac-light-6)", - "border-info-hover": "var(--lilac-light-7)", - "border-info-selected": "var(--lilac-light-9)", - "icon-base": "var(--smoke-dark-9)", - "icon-hover": "var(--smoke-dark-10)", - "icon-active": "var(--smoke-dark-11)", - "icon-selected": "var(--smoke-dark-12)", - "icon-disabled": "var(--smoke-dark-7)", - "icon-focus": "var(--smoke-dark-12)", - "icon-invert-base": "var(--smoke-dark-1)", - "icon-weak-base": "var(--smoke-dark-6)", - "icon-weak-hover": "var(--smoke-light-7)", - "icon-weak-active": "var(--smoke-light-8)", - "icon-weak-selected": "var(--smoke-light-9)", - "icon-weak-disabled": "var(--smoke-light-4)", - "icon-weak-focus": "var(--smoke-light-9)", - "icon-strong-base": "var(--smoke-dark-12)", - "icon-strong-hover": "#f6f3f3", - "icon-strong-active": "#fcfcfc", - "icon-strong-selected": "#fdfcfc", - "icon-strong-disabled": "var(--smoke-dark-8)", - "icon-strong-focus": "#fdfcfc", - "icon-brand-base": "var(--white)", - "icon-interactive-base": "var(--cobalt-dark-11)", - "icon-success-base": "var(--apple-dark-9)", - "icon-success-hover": "var(--apple-dark-10)", - "icon-success-active": "var(--apple-dark-11)", - "icon-warning-base": "var(--amber-dark-9)", - "icon-warning-hover": "var(--amber-dark-8)", - "icon-warning-active": "var(--amber-dark-11)", - "icon-critical-base": "var(--ember-dark-9)", - "icon-critical-hover": "var(--ember-dark-11)", - "icon-critical-active": "var(--ember-dark-12)", - "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(--smoke-light-alpha-11)", - "icon-on-brand-hover": "var(--smoke-light-alpha-12)", - "icon-on-brand-selected": "var(--smoke-light-alpha-12)", - "icon-on-interactive-base": "var(--smoke-dark-12)", - "icon-agent-plan-base": "var(--purple-dark-9)", - "icon-agent-docs-base": "var(--amber-dark-9)", - "icon-agent-ask-base": "var(--cyan-dark-9)", - "icon-agent-build-base": "var(--cobalt-dark-11)", - "icon-on-success-base": "var(--apple-dark-alpha-9)", - "icon-on-success-hover": "var(--apple-dark-alpha-10)", - "icon-on-success-selected": "var(--apple-dark-alpha-11)", - "icon-on-warning-base": "var(--amber-darkalpha-9)", - "icon-on-warning-hover": "var(--amber-darkalpha-10)", - "icon-on-warning-selected": "var(--amber-darkalpha-11)", - "icon-on-critical-base": "var(--ember-dark-alpha-9)", - "icon-on-critical-hover": "var(--ember-dark-alpha-10)", - "icon-on-critical-selected": "var(--ember-dark-alpha-11)", - "icon-on-info-base": "var(--lilac-dark-9)", - "icon-on-info-hover": "var(--lilac-dark-alpha-10)", - "icon-on-info-selected": "var(--lilac-dark-alpha-11)", - "icon-diff-add-base": "var(--mint-dark-11)", - "icon-diff-add-hover": "var(--mint-dark-10)", - "icon-diff-add-active": "var(--mint-dark-11)", - "icon-diff-delete-base": "var(--ember-dark-9)", - "icon-diff-delete-hover": "var(--ember-dark-10)", - "syntax-comment": "var(--text-weak)", - "syntax-regexp": "var(--text-base)", - "syntax-string": "#00ceb9", - "syntax-keyword": "var(--text-weak)", - "syntax-primitive": "#ffba92", - "syntax-operator": "var(--text-weak)", - "syntax-variable": "var(--text-strong)", - "syntax-property": "#ff9ae2", - "syntax-type": "#ecf58c", - "syntax-constant": "#93e9f6", - "syntax-punctuation": "var(--text-weak)", - "syntax-object": "var(--text-strong)", - "syntax-success": "var(--apple-dark-10)", - "syntax-warning": "var(--amber-dark-10)", - "syntax-critical": "var(--ember-dark-10)", - "syntax-info": "#93e9f6", - "syntax-diff-add": "var(--mint-dark-11)", - "syntax-diff-delete": "var(--ember-dark-11)", - "syntax-diff-unknown": "#ff0000", - "markdown-heading": "#9d7cd8", - "markdown-text": "#eeeeee", - "markdown-link": "#fab283", - "markdown-link-text": "#56b6c2", - "markdown-code": "#7fd88f", - "markdown-block-quote": "#e5c07b", - "markdown-emph": "#e5c07b", - "markdown-strong": "#f5a742", - "markdown-horizontal-rule": "#808080", - "markdown-list-item": "#fab283", - "markdown-list-enumeration": "#56b6c2", - "markdown-image": "#fab283", - "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)", - "button-ghost-hover": "var(--smoke-dark-alpha-2)", - "button-ghost-hover2": "var(--smoke-dark-alpha-3)", - "avatar-background-pink": "#501b3f", - "avatar-background-mint": "#033a34", - "avatar-background-orange": "#5f2a06", - "avatar-background-purple": "#432155", - "avatar-background-cyan": "#0f3058", - "avatar-background-lime": "#2b3711", - "avatar-text-pink": "#e34ba9", - "avatar-text-mint": "#95f3d9", - "avatar-text-orange": "#ff802b", - "avatar-text-purple": "#9d5bd2", - "avatar-text-cyan": "#369eff", - "avatar-text-lime": "#c4f042" } } } diff --git a/packages/ui/src/theme/themes/oc-2.json b/packages/ui/src/theme/themes/oc-2.json index 01ec1131a2..fdf0c2caf4 100644 --- a/packages/ui/src/theme/themes/oc-2.json +++ b/packages/ui/src/theme/themes/oc-2.json @@ -3,8 +3,8 @@ "name": "OC-2", "id": "oc-2", "light": { - "seeds": { - "neutral": "#8e8b8b", + "palette": { + "neutral": "#8f8f8f", "primary": "#dcde8d", "success": "#12c905", "warning": "#ffdc17", @@ -13,264 +13,11 @@ "interactive": "#034cff", "diffAdd": "#9ff29a", "diffDelete": "#fc533a" - }, - "overrides": { - "background-base": "#f8f7f7", - "background-weak": "var(--gray-light-3)", - "background-strong": "var(--gray-light-1)", - "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-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)", - "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-strong": "#ffffff", - "surface-raised-stronger-non-alpha": "var(--white)", - "surface-brand-base": "var(--yuzu-light-9)", - "surface-brand-hover": "var(--yuzu-light-10)", - "surface-interactive-base": "var(--cobalt-light-3)", - "surface-interactive-hover": "#E5F0FF", - "surface-interactive-weak": "var(--cobalt-light-2)", - "surface-interactive-weak-hover": "var(--cobalt-light-3)", - "surface-success-base": "var(--apple-light-3)", - "surface-success-weak": "var(--apple-light-2)", - "surface-success-strong": "var(--apple-light-9)", - "surface-warning-base": "var(--solaris-light-3)", - "surface-warning-weak": "var(--solaris-light-2)", - "surface-warning-strong": "var(--solaris-light-9)", - "surface-critical-base": "var(--ember-light-3)", - "surface-critical-weak": "var(--ember-light-2)", - "surface-critical-strong": "var(--ember-light-9)", - "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": "#ffffff00", - "surface-diff-skip-base": "var(--gray-light-2)", - "surface-diff-hidden-base": "var(--blue-light-3)", - "surface-diff-hidden-weak": "var(--blue-light-2)", - "surface-diff-hidden-weaker": "var(--blue-light-1)", - "surface-diff-hidden-strong": "var(--blue-light-5)", - "surface-diff-hidden-stronger": "var(--blue-light-9)", - "surface-diff-add-base": "#dafbe0", - "surface-diff-add-weak": "var(--mint-light-2)", - "surface-diff-add-weaker": "var(--mint-light-1)", - "surface-diff-add-strong": "var(--mint-light-5)", - "surface-diff-add-stronger": "var(--mint-light-9)", - "surface-diff-delete-base": "var(--ember-light-3)", - "surface-diff-delete-weak": "var(--ember-light-2)", - "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-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)", - "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-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-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-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)", - "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-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-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-weak-selected": "var(--cobalt-light-alpha-5)", - "border-weak-disabled": "var(--gray-light-alpha-6)", - "border-weak-focus": "var(--gray-light-alpha-7)", - "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-focus": "var(--cobalt-light-9)", - "border-success-base": "var(--apple-light-6)", - "border-success-hover": "var(--apple-light-7)", - "border-success-selected": "var(--apple-light-9)", - "border-warning-base": "var(--solaris-light-6)", - "border-warning-hover": "var(--solaris-light-7)", - "border-warning-selected": "var(--solaris-light-9)", - "border-critical-base": "var(--ember-light-6)", - "border-critical-hover": "var(--ember-light-7)", - "border-critical-selected": "var(--ember-light-9)", - "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-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-strong-active": "#020202", - "icon-strong-selected": "#020202", - "icon-strong-disabled": "var(--gray-light-6)", - "icon-strong-focus": "#020202", - "icon-brand-base": "var(--gray-light-12)", - "icon-interactive-base": "var(--cobalt-light-9)", - "icon-success-base": "var(--apple-light-7)", - "icon-success-hover": "var(--apple-light-8)", - "icon-success-active": "var(--apple-light-11)", - "icon-warning-base": "var(--amber-light-7)", - "icon-warning-hover": "var(--amber-light-8)", - "icon-warning-active": "var(--amber-light-11)", - "icon-critical-base": "var(--ember-light-10)", - "icon-critical-hover": "var(--ember-light-11)", - "icon-critical-active": "var(--ember-light-12)", - "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-agent-plan-base": "var(--purple-light-9)", - "icon-agent-docs-base": "var(--amber-light-9)", - "icon-agent-ask-base": "var(--cyan-light-9)", - "icon-agent-build-base": "var(--cobalt-light-9)", - "icon-on-success-base": "var(--apple-light-alpha-9)", - "icon-on-success-hover": "var(--apple-light-alpha-10)", - "icon-on-success-selected": "var(--apple-light-alpha-11)", - "icon-on-warning-base": "var(--amber-lightalpha-9)", - "icon-on-warning-hover": "var(--amber-lightalpha-10)", - "icon-on-warning-selected": "var(--amber-lightalpha-11)", - "icon-on-critical-base": "var(--ember-light-alpha-9)", - "icon-on-critical-hover": "var(--ember-light-alpha-10)", - "icon-on-critical-selected": "var(--ember-light-alpha-11)", - "icon-on-info-base": "var(--lilac-light-9)", - "icon-on-info-hover": "var(--lilac-light-alpha-10)", - "icon-on-info-selected": "var(--lilac-light-alpha-11)", - "icon-diff-add-base": "var(--mint-light-11)", - "icon-diff-add-hover": "var(--mint-light-12)", - "icon-diff-add-active": "var(--mint-light-12)", - "icon-diff-delete-base": "var(--ember-light-10)", - "icon-diff-delete-hover": "var(--ember-light-11)", - "syntax-comment": "var(--text-weak)", - "syntax-regexp": "var(--text-base)", - "syntax-string": "#006656", - "syntax-keyword": "var(--text-weak)", - "syntax-primitive": "#fb4804", - "syntax-operator": "var(--text-base)", - "syntax-variable": "var(--text-strong)", - "syntax-property": "#ed6dc8", - "syntax-type": "#596600", - "syntax-constant": "#007b80", - "syntax-punctuation": "var(--text-base)", - "syntax-object": "var(--text-strong)", - "syntax-success": "var(--apple-light-10)", - "syntax-warning": "var(--amber-light-10)", - "syntax-critical": "var(--ember-light-10)", - "syntax-info": "#0092a8", - "syntax-diff-add": "var(--mint-light-11)", - "syntax-diff-delete": "var(--ember-light-11)", - "syntax-diff-unknown": "#ff0000", - "markdown-heading": "#d68c27", - "markdown-text": "#1a1a1a", - "markdown-link": "#3b7dd8", - "markdown-link-text": "#318795", - "markdown-code": "#3d9a57", - "markdown-block-quote": "#b0851f", - "markdown-emph": "#b0851f", - "markdown-strong": "#d68c27", - "markdown-horizontal-rule": "#8a8a8a", - "markdown-list-item": "#3b7dd8", - "markdown-list-enumeration": "#318795", - "markdown-image": "#3b7dd8", - "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)", - "avatar-background-pink": "#feeef8", - "avatar-background-mint": "#e1fbf4", - "avatar-background-orange": "#fff1e7", - "avatar-background-purple": "#f9f1fe", - "avatar-background-cyan": "#e7f9fb", - "avatar-background-lime": "#eefadc", - "avatar-text-pink": "#cd1d8d", - "avatar-text-mint": "#147d6f", - "avatar-text-orange": "#ed5f00", - "avatar-text-purple": "#8445bc", - "avatar-text-cyan": "#0894b3", - "avatar-text-lime": "#5d770d" } }, "dark": { - "seeds": { - "neutral": "#716c6b", + "palette": { + "neutral": "#707070", "primary": "#fab283", "success": "#12c905", "warning": "#fcd53a", @@ -279,254 +26,6 @@ "interactive": "#034cff", "diffAdd": "#c8ffc4", "diffDelete": "#fc533a" - }, - "overrides": { - "base": "var(--gray-dark-alpha-2)", - "base2": "var(--gray-dark-alpha-2)", - "base3": "var(--gray-dark-alpha-2)", - "background-base": "#101010", - "background-weak": "#1E1E1E", - "background-strong": "#121212", - "background-stronger": "#151515", - "surface-base": "var(--gray-dark-alpha-2)", - "surface-base-hover": "#FFFFFF0A", - "surface-base-active": "var(--gray-dark-alpha-3)", - "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-raised-stronger-non-alpha": "#1B1B1B", - "surface-brand-base": "var(--yuzu-light-9)", - "surface-brand-hover": "var(--yuzu-light-10)", - "surface-interactive-base": "var(--cobalt-dark-3)", - "surface-interactive-hover": "#0A1D4D", - "surface-interactive-weak": "var(--cobalt-dark-2)", - "surface-interactive-weak-hover": "var(--cobalt-light-3)", - "surface-success-base": "var(--apple-dark-3)", - "surface-success-weak": "var(--apple-dark-2)", - "surface-success-strong": "var(--apple-dark-9)", - "surface-warning-base": "var(--solaris-light-3)", - "surface-warning-weak": "var(--solaris-light-2)", - "surface-warning-strong": "var(--solaris-light-9)", - "surface-critical-base": "var(--ember-dark-3)", - "surface-critical-weak": "var(--ember-dark-2)", - "surface-critical-strong": "var(--ember-dark-9)", - "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-hidden-base": "var(--blue-dark-2)", - "surface-diff-hidden-weak": "var(--blue-dark-1)", - "surface-diff-hidden-weaker": "var(--blue-dark-3)", - "surface-diff-hidden-strong": "var(--blue-dark-5)", - "surface-diff-hidden-stronger": "var(--blue-dark-11)", - "surface-diff-add-base": "var(--mint-dark-3)", - "surface-diff-add-weak": "var(--mint-dark-4)", - "surface-diff-add-weaker": "var(--mint-dark-3)", - "surface-diff-add-strong": "var(--mint-dark-5)", - "surface-diff-add-stronger": "var(--mint-dark-11)", - "surface-diff-delete-base": "var(--ember-dark-3)", - "surface-diff-delete-weak": "var(--ember-dark-4)", - "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-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)", - "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-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-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-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)", - "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-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-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-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-focus": "var(--cobalt-light-9)", - "border-success-base": "var(--apple-light-6)", - "border-success-hover": "var(--apple-light-7)", - "border-success-selected": "var(--apple-light-9)", - "border-warning-base": "var(--solaris-light-6)", - "border-warning-hover": "var(--solaris-light-7)", - "border-warning-selected": "var(--solaris-light-9)", - "border-critical-base": "var(--ember-dark-5)", - "border-critical-hover": "var(--ember-dark-7)", - "border-critical-selected": "var(--ember-dark-9)", - "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-strong-hover": "#F3F3F3", - "icon-strong-active": "#EBEBEB", - "icon-strong-selected": "#FCFCFC", - "icon-strong-disabled": "var(--gray-dark-7)", - "icon-strong-focus": "#FCFCFC", - "icon-brand-base": "var(--white)", - "icon-interactive-base": "var(--cobalt-dark-11)", - "icon-success-base": "var(--apple-dark-9)", - "icon-success-hover": "var(--apple-dark-10)", - "icon-success-active": "var(--apple-dark-11)", - "icon-warning-base": "var(--amber-dark-9)", - "icon-warning-hover": "var(--amber-dark-8)", - "icon-warning-active": "var(--amber-dark-11)", - "icon-critical-base": "var(--ember-dark-9)", - "icon-critical-hover": "var(--ember-dark-11)", - "icon-critical-active": "var(--ember-dark-12)", - "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-agent-plan-base": "var(--purple-dark-9)", - "icon-agent-docs-base": "var(--amber-dark-9)", - "icon-agent-ask-base": "var(--cyan-dark-9)", - "icon-agent-build-base": "var(--cobalt-dark-11)", - "icon-on-success-base": "var(--apple-dark-alpha-9)", - "icon-on-success-hover": "var(--apple-dark-alpha-10)", - "icon-on-success-selected": "var(--apple-dark-alpha-11)", - "icon-on-warning-base": "var(--amber-darkalpha-9)", - "icon-on-warning-hover": "var(--amber-darkalpha-10)", - "icon-on-warning-selected": "var(--amber-darkalpha-11)", - "icon-on-critical-base": "var(--ember-dark-alpha-9)", - "icon-on-critical-hover": "var(--ember-dark-alpha-10)", - "icon-on-critical-selected": "var(--ember-dark-alpha-11)", - "icon-on-info-base": "var(--lilac-dark-9)", - "icon-on-info-hover": "var(--lilac-dark-alpha-10)", - "icon-on-info-selected": "var(--lilac-dark-alpha-11)", - "icon-diff-add-base": "var(--mint-dark-11)", - "icon-diff-add-hover": "var(--mint-dark-10)", - "icon-diff-add-active": "var(--mint-dark-11)", - "icon-diff-delete-base": "var(--ember-dark-9)", - "icon-diff-delete-hover": "var(--ember-dark-10)", - "syntax-comment": "var(--text-weak)", - "syntax-regexp": "var(--text-base)", - "syntax-string": "#00ceb9", - "syntax-keyword": "var(--text-weak)", - "syntax-primitive": "#ffba92", - "syntax-operator": "var(--text-weak)", - "syntax-variable": "var(--text-strong)", - "syntax-property": "#ff9ae2", - "syntax-type": "#ecf58c", - "syntax-constant": "#93e9f6", - "syntax-punctuation": "var(--text-weak)", - "syntax-object": "var(--text-strong)", - "syntax-success": "var(--apple-dark-10)", - "syntax-warning": "var(--amber-dark-10)", - "syntax-critical": "var(--ember-dark-10)", - "syntax-info": "#93e9f6", - "syntax-diff-add": "var(--mint-dark-11)", - "syntax-diff-delete": "var(--ember-dark-11)", - "syntax-diff-unknown": "#ff0000", - "markdown-heading": "#9d7cd8", - "markdown-text": "#eeeeee", - "markdown-link": "#fab283", - "markdown-link-text": "#56b6c2", - "markdown-code": "#7fd88f", - "markdown-block-quote": "#e5c07b", - "markdown-emph": "#e5c07b", - "markdown-strong": "#f5a742", - "markdown-horizontal-rule": "#808080", - "markdown-list-item": "#fab283", - "markdown-list-enumeration": "#56b6c2", - "markdown-image": "#fab283", - "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)", - "avatar-background-pink": "#501b3f", - "avatar-background-mint": "#033a34", - "avatar-background-orange": "#5f2a06", - "avatar-background-purple": "#432155", - "avatar-background-cyan": "#0f3058", - "avatar-background-lime": "#2b3711", - "avatar-text-pink": "#e34ba9", - "avatar-text-mint": "#95f3d9", - "avatar-text-orange": "#ff802b", - "avatar-text-purple": "#9d5bd2", - "avatar-text-cyan": "#369eff", - "avatar-text-lime": "#c4f042" } } } diff --git a/packages/ui/src/theme/themes/onedarkpro.json b/packages/ui/src/theme/themes/onedarkpro.json index ce01511e85..be17dedff3 100644 --- a/packages/ui/src/theme/themes/onedarkpro.json +++ b/packages/ui/src/theme/themes/onedarkpro.json @@ -3,129 +3,39 @@ "name": "One Dark Pro", "id": "onedarkpro", "light": { - "seeds": { + "palette": { "neutral": "#f5f6f8", + "ink": "#2b303b", "primary": "#528bff", + "accent": "#d85462", "success": "#4fa66d", "warning": "#d19a66", "error": "#e06c75", "info": "#61afef", - "interactive": "#528bff", "diffAdd": "#c2ebcf", "diffDelete": "#f7c1c5" }, "overrides": { - "background-base": "#f5f6f8", - "background-weak": "#eef0f4", - "background-strong": "#fafbfc", - "background-stronger": "#ffffff", - "border-weak-base": "#dee2eb", - "border-weak-hover": "#d4d9e3", - "border-weak-active": "#caced6", - "border-weak-selected": "#bec4d0", - "border-weak-disabled": "#f4f6fb", - "border-weak-focus": "#c4cada", - "border-base": "#b5bccd", - "border-hover": "#aab1c2", - "border-active": "#a0a7b8", - "border-selected": "#959cae", - "border-disabled": "#eceef4", - "border-focus": "#a6adbf", - "border-strong-base": "#747c92", - "border-strong-hover": "#6a7287", - "border-strong-active": "#60687c", - "border-strong-selected": "#565e71", - "border-strong-disabled": "#cbd0dd", - "border-strong-focus": "#666d82", - "surface-diff-add-base": "#e5f4ea", - "surface-diff-delete-base": "#fde7ea", - "surface-diff-hidden-base": "#e4e8f4", - "text-base": "#2b303b", - "text-weak": "#6b717f", - "text-strong": "#0e1118", - "syntax-string": "#4fa66d", - "syntax-primitive": "#d85462", - "syntax-property": "#528bff", - "syntax-type": "#d19a66", - "syntax-constant": "#61afef", - "syntax-info": "#61afef", - "markdown-heading": "#528bff", - "markdown-text": "#2b303b", - "markdown-link": "#528bff", - "markdown-link-text": "#61afef", - "markdown-code": "#4fa66d", - "markdown-block-quote": "#d19a66", - "markdown-emph": "#d19a66", - "markdown-strong": "#d85462", - "markdown-horizontal-rule": "#d3d7e4", - "markdown-list-item": "#528bff", - "markdown-list-enumeration": "#61afef", - "markdown-image": "#528bff", - "markdown-image-text": "#61afef", - "markdown-code-block": "#528bff" + "syntax-keyword": "#a626a4", + "syntax-primitive": "#986801" } }, "dark": { - "seeds": { + "palette": { "neutral": "#1e222a", + "ink": "#abb2bf", "primary": "#61afef", + "accent": "#e06c75", "success": "#98c379", "warning": "#e5c07b", "error": "#e06c75", "info": "#56b6c2", - "interactive": "#61afef", "diffAdd": "#4b815a", "diffDelete": "#b2555f" }, "overrides": { - "background-base": "#1e222a", - "background-weak": "#212631", - "background-strong": "#1b1f27", - "background-stronger": "#171b23", - "border-weak-base": "#323848", - "border-weak-hover": "#363d52", - "border-weak-active": "#3c435c", - "border-weak-selected": "#424967", - "border-weak-disabled": "#141720", - "border-weak-focus": "#3f4560", - "border-base": "#4a5164", - "border-hover": "#515871", - "border-active": "#585f7e", - "border-selected": "#60688a", - "border-disabled": "#1a1e27", - "border-focus": "#555c79", - "border-strong-base": "#6a7390", - "border-strong-hover": "#737c9d", - "border-strong-active": "#7d87ab", - "border-strong-selected": "#8791b8", - "border-strong-disabled": "#212533", - "border-strong-focus": "#7680a2", - "surface-diff-add-base": "#1c2a26", - "surface-diff-delete-base": "#2a1c22", - "surface-diff-hidden-base": "#232836", - "text-base": "#abb2bf", - "text-weak": "#818899", - "text-strong": "#f6f7fb", - "syntax-string": "#98c379", - "syntax-primitive": "#e06c75", - "syntax-property": "#61afef", - "syntax-type": "#e5c07b", - "syntax-constant": "#56b6c2", - "syntax-info": "#56b6c2", - "markdown-heading": "#61afef", - "markdown-text": "#abb2bf", - "markdown-link": "#61afef", - "markdown-link-text": "#56b6c2", - "markdown-code": "#98c379", - "markdown-block-quote": "#e5c07b", - "markdown-emph": "#e5c07b", - "markdown-strong": "#e06c75", - "markdown-horizontal-rule": "#2d3444", - "markdown-list-item": "#61afef", - "markdown-list-enumeration": "#56b6c2", - "markdown-image": "#61afef", - "markdown-image-text": "#56b6c2", - "markdown-code-block": "#abb2bf" + "syntax-keyword": "#c678dd", + "syntax-primitive": "#d19a66" } } } diff --git a/packages/ui/src/theme/themes/shadesofpurple.json b/packages/ui/src/theme/themes/shadesofpurple.json index bc625770f9..03af35c2a3 100644 --- a/packages/ui/src/theme/themes/shadesofpurple.json +++ b/packages/ui/src/theme/themes/shadesofpurple.json @@ -3,129 +3,37 @@ "name": "Shades of Purple", "id": "shadesofpurple", "light": { - "seeds": { + "palette": { "neutral": "#f7ebff", + "ink": "#3b2c59", "primary": "#7a5af8", + "accent": "#ff6bd5", "success": "#3dd598", "warning": "#f7c948", "error": "#ff6bd5", "info": "#62d4ff", - "interactive": "#7a5af8", "diffAdd": "#c8f8da", "diffDelete": "#ffc3ef" }, "overrides": { - "background-base": "#f7ebff", - "background-weak": "#f2e2ff", - "background-strong": "#fbf2ff", - "background-stronger": "#fff7ff", - "border-weak-base": "#e5d3ff", - "border-weak-hover": "#dac8f5", - "border-weak-active": "#d1bdeb", - "border-weak-selected": "#c6b3e1", - "border-weak-disabled": "#fcf6ff", - "border-weak-focus": "#ccb9e7", - "border-base": "#baa4d5", - "border-hover": "#b098cb", - "border-active": "#a68dc2", - "border-selected": "#9b82b8", - "border-disabled": "#f1e7ff", - "border-focus": "#a692c6", - "border-strong-base": "#8769a9", - "border-strong-hover": "#7b5c9d", - "border-strong-active": "#704f91", - "border-strong-selected": "#664587", - "border-strong-disabled": "#d8c4f0", - "border-strong-focus": "#755495", - "surface-diff-add-base": "#edf8f1", - "surface-diff-delete-base": "#ffe4f4", - "surface-diff-hidden-base": "#e9e4ff", - "text-base": "#3b2c59", - "text-weak": "#6c568f", - "text-strong": "#1c1033", - "syntax-string": "#3dd598", - "syntax-primitive": "#ff6bd5", - "syntax-property": "#7a5af8", - "syntax-type": "#f7c948", - "syntax-constant": "#62d4ff", - "syntax-info": "#62d4ff", - "markdown-heading": "#7a5af8", - "markdown-text": "#3b2c59", - "markdown-link": "#7a5af8", - "markdown-link-text": "#62d4ff", - "markdown-code": "#3dd598", - "markdown-block-quote": "#f7c948", - "markdown-emph": "#f7c948", - "markdown-strong": "#ff6bd5", - "markdown-horizontal-rule": "#decbed", - "markdown-list-item": "#7a5af8", - "markdown-list-enumeration": "#62d4ff", - "markdown-image": "#7a5af8", - "markdown-image-text": "#62d4ff", - "markdown-code-block": "#7a5af8" + "syntax-keyword": "#ff6bd5" } }, "dark": { - "seeds": { + "palette": { "neutral": "#1a102b", + "ink": "#f5f0ff", "primary": "#c792ff", + "accent": "#ff7ac6", "success": "#7be0b0", "warning": "#ffd580", "error": "#ff7ac6", "info": "#7dd4ff", - "interactive": "#c792ff", "diffAdd": "#53c39f", "diffDelete": "#d85aa0" }, "overrides": { - "background-base": "#1a102b", - "background-weak": "#1f1434", - "background-strong": "#1c122f", - "background-stronger": "#170e26", - "border-weak-base": "#352552", - "border-weak-hover": "#3a2a5d", - "border-weak-active": "#402f68", - "border-weak-selected": "#463674", - "border-weak-disabled": "#10091b", - "border-weak-focus": "#3d2d65", - "border-base": "#4d3a73", - "border-hover": "#553f7f", - "border-active": "#5d468c", - "border-selected": "#654c99", - "border-disabled": "#150d21", - "border-focus": "#594283", - "border-strong-base": "#7659b0", - "border-strong-hover": "#8262be", - "border-strong-active": "#8e6ccc", - "border-strong-selected": "#9a77da", - "border-strong-disabled": "#1c122c", - "border-strong-focus": "#8666c4", - "surface-diff-add-base": "#142c27", - "surface-diff-delete-base": "#2d1424", - "surface-diff-hidden-base": "#231737", - "text-base": "#f5f0ff", - "text-weak": "#c9b6ff", - "text-strong": "#ffffff", - "syntax-string": "#7be0b0", - "syntax-primitive": "#ff7ac6", - "syntax-property": "#c792ff", - "syntax-type": "#ffd580", - "syntax-constant": "#7dd4ff", - "syntax-info": "#7dd4ff", - "markdown-heading": "#c792ff", - "markdown-text": "#f5f0ff", - "markdown-link": "#c792ff", - "markdown-link-text": "#7dd4ff", - "markdown-code": "#7be0b0", - "markdown-block-quote": "#ffd580", - "markdown-emph": "#ffd580", - "markdown-strong": "#ff7ac6", - "markdown-horizontal-rule": "#2d1d41", - "markdown-list-item": "#c792ff", - "markdown-list-enumeration": "#7dd4ff", - "markdown-image": "#c792ff", - "markdown-image-text": "#7dd4ff", - "markdown-code-block": "#f5f0ff" + "syntax-keyword": "#ff7ac6" } } } diff --git a/packages/ui/src/theme/themes/solarized.json b/packages/ui/src/theme/themes/solarized.json index 7cb44775af..24a4daf458 100644 --- a/packages/ui/src/theme/themes/solarized.json +++ b/packages/ui/src/theme/themes/solarized.json @@ -3,129 +3,39 @@ "name": "Solarized", "id": "solarized", "light": { - "seeds": { + "palette": { "neutral": "#fdf6e3", + "ink": "#586e75", "primary": "#268bd2", + "accent": "#d33682", "success": "#859900", "warning": "#b58900", "error": "#dc322f", "info": "#2aa198", - "interactive": "#268bd2", "diffAdd": "#c6dc7a", "diffDelete": "#f2a1a1" }, "overrides": { - "background-base": "#fdf6e3", - "background-weak": "#f6efda", - "background-strong": "#faf3dc", - "background-stronger": "#f6edd4", - "border-weak-base": "#e3e0cd", - "border-weak-hover": "#d9d4c2", - "border-weak-active": "#cfcab7", - "border-weak-selected": "#c5c0ad", - "border-weak-disabled": "#f2edda", - "border-weak-focus": "#cbc6b2", - "border-base": "#bcb5a0", - "border-hover": "#b1aa96", - "border-active": "#a59f8c", - "border-selected": "#999382", - "border-disabled": "#ede7d4", - "border-focus": "#aca58f", - "border-strong-base": "#8c8572", - "border-strong-hover": "#7f7866", - "border-strong-active": "#716b5b", - "border-strong-selected": "#645f50", - "border-strong-disabled": "#d5cdb8", - "border-strong-focus": "#78715f", - "surface-diff-add-base": "#eef5d6", - "surface-diff-delete-base": "#fde4dd", - "surface-diff-hidden-base": "#e3ecf3", - "text-base": "#586e75", - "text-weak": "#7a8c8e", - "text-strong": "#073642", - "syntax-string": "#859900", - "syntax-primitive": "#d33682", - "syntax-property": "#268bd2", - "syntax-type": "#b58900", - "syntax-constant": "#2aa198", - "syntax-info": "#2aa198", - "markdown-heading": "#268bd2", - "markdown-text": "#586e75", - "markdown-link": "#268bd2", - "markdown-link-text": "#2aa198", - "markdown-code": "#859900", - "markdown-block-quote": "#b58900", - "markdown-emph": "#b58900", - "markdown-strong": "#d33682", - "markdown-horizontal-rule": "#cfd1bf", - "markdown-list-item": "#268bd2", - "markdown-list-enumeration": "#2aa198", - "markdown-image": "#268bd2", - "markdown-image-text": "#2aa198", - "markdown-code-block": "#2aa198" + "syntax-keyword": "#859900", + "syntax-string": "#2aa198" } }, "dark": { - "seeds": { + "palette": { "neutral": "#002b36", + "ink": "#93a1a1", "primary": "#6c71c4", + "accent": "#d33682", "success": "#859900", "warning": "#b58900", "error": "#dc322f", "info": "#2aa198", - "interactive": "#6c71c4", "diffAdd": "#4c7654", "diffDelete": "#c34b4b" }, "overrides": { - "background-base": "#001f27", - "background-weak": "#022733", - "background-strong": "#01222b", - "background-stronger": "#032830", - "border-weak-base": "#20373f", - "border-weak-hover": "#243e47", - "border-weak-active": "#28434f", - "border-weak-selected": "#2d4958", - "border-weak-disabled": "#0f2026", - "border-weak-focus": "#2a4552", - "border-base": "#31505b", - "border-hover": "#365765", - "border-active": "#3c5e70", - "border-selected": "#42657a", - "border-disabled": "#13272e", - "border-focus": "#3a5a6b", - "border-strong-base": "#4a7887", - "border-strong-hover": "#528294", - "border-strong-active": "#5a8ca1", - "border-strong-selected": "#6396ae", - "border-strong-disabled": "#1b323b", - "border-strong-focus": "#56879a", - "surface-diff-add-base": "#0f2f29", - "surface-diff-delete-base": "#321c1c", - "surface-diff-hidden-base": "#0f3844", - "text-base": "#93a1a1", - "text-weak": "#6c7f80", - "text-strong": "#fdf6e3", - "syntax-string": "#859900", - "syntax-primitive": "#d33682", - "syntax-property": "#6c71c4", - "syntax-type": "#b58900", - "syntax-constant": "#2aa198", - "syntax-info": "#2aa198", - "markdown-heading": "#6c71c4", - "markdown-text": "#93a1a1", - "markdown-link": "#6c71c4", - "markdown-link-text": "#2aa198", - "markdown-code": "#859900", - "markdown-block-quote": "#b58900", - "markdown-emph": "#b58900", - "markdown-strong": "#d33682", - "markdown-horizontal-rule": "#0e3b46", - "markdown-list-item": "#6c71c4", - "markdown-list-enumeration": "#2aa198", - "markdown-image": "#6c71c4", - "markdown-image-text": "#2aa198", - "markdown-code-block": "#93a1a1" + "syntax-keyword": "#859900", + "syntax-string": "#2aa198" } } } diff --git a/packages/ui/src/theme/themes/tokyonight.json b/packages/ui/src/theme/themes/tokyonight.json index 31d0e8a474..d29c359942 100644 --- a/packages/ui/src/theme/themes/tokyonight.json +++ b/packages/ui/src/theme/themes/tokyonight.json @@ -3,153 +3,37 @@ "name": "Tokyonight", "id": "tokyonight", "light": { - "seeds": { + "palette": { "neutral": "#e1e2e7", + "ink": "#273153", "primary": "#2e7de9", + "accent": "#b15c00", "success": "#587539", "warning": "#8c6c3e", "error": "#c94060", "info": "#007197", - "interactive": "#2e7de9", "diffAdd": "#4f8f7b", "diffDelete": "#d05f7c" }, "overrides": { - "background-base": "#e1e2e7", - "background-weak": "#dee0ea", - "background-strong": "#e5e6ee", - "background-stronger": "#e9eaf1", - "border-weak-base": "#cdd0dc", - "border-weak-hover": "#c3c6d2", - "border-weak-active": "#b9bcc8", - "border-weak-selected": "#aeb2bf", - "border-weak-disabled": "#e6e7ef", - "border-weak-focus": "#b3b6c3", - "border-base": "#a7abbb", - "border-hover": "#9ba0b1", - "border-active": "#9095a8", - "border-selected": "#83889e", - "border-disabled": "#dedfe6", - "border-focus": "#9599a8", - "border-strong-base": "#757b90", - "border-strong-hover": "#6a7084", - "border-strong-active": "#5f6578", - "border-strong-selected": "#545a6d", - "border-strong-disabled": "#c4c6d0", - "border-strong-focus": "#666b7f", - "surface-diff-add-base": "#dfe7da", - "surface-diff-delete-base": "#f4dadd", - "surface-diff-hidden-base": "#cfd1dd", - "text-base": "#273153", - "text-weak": "#5c6390", - "text-strong": "#1c2544", - "syntax-string": "#587539", - "syntax-primitive": "#b15c00", - "syntax-property": "#9854f1", - "syntax-type": "#3760bf", - "syntax-constant": "#007197", - "syntax-info": "#007197", - "markdown-heading": "#9854f1", - "markdown-text": "#273153", - "markdown-link": "#2e7de9", - "markdown-link-text": "#007197", - "markdown-code": "#587539", - "markdown-block-quote": "#8c6c3e", - "markdown-emph": "#8c6c3e", - "markdown-strong": "#b15c00", - "markdown-horizontal-rule": "#a1a6c5", - "markdown-list-item": "#2e7de9", - "markdown-list-enumeration": "#007197", - "markdown-image": "#2e7de9", - "markdown-image-text": "#007197", - "markdown-code-block": "#3760bf" + "syntax-keyword": "#9854f1" } }, "dark": { - "seeds": { + "palette": { "neutral": "#1a1b26", + "ink": "#c0caf5", "primary": "#7aa2f7", + "accent": "#ff9e64", "success": "#9ece6a", "warning": "#e0af68", "error": "#f7768e", "info": "#7dcfff", - "interactive": "#7aa2f7", "diffAdd": "#41a6b5", "diffDelete": "#c34043" }, "overrides": { - "background-base": "#0f111a", - "background-weak": "#111428", - "background-strong": "#101324", - "background-stronger": "#13172a", - "border-weak-base": "#25283b", - "border-weak-hover": "#292c43", - "border-weak-active": "#2e314b", - "border-weak-selected": "#343755", - "border-weak-disabled": "#151727", - "border-weak-focus": "#30324f", - "border-base": "#3a3e57", - "border-hover": "#414264", - "border-active": "#474972", - "border-selected": "#4f507f", - "border-disabled": "#1c1d2d", - "border-focus": "#45496f", - "border-strong-base": "#5a5f82", - "border-strong-hover": "#646994", - "border-strong-active": "#6f74a6", - "border-strong-selected": "#7a7fb8", - "border-strong-disabled": "#23243a", - "border-strong-focus": "#6a6f9f", - "surface-base": "#1f2335", - "base": "#1f2335", - "surface-base-hover": "#232840", - "surface-base-active": "#262c46", - "surface-base-interactive-active": "#2b3357", - "base2": "#1f2335", - "base3": "#1f2335", - "surface-inset-base": "#161a2ab3", - "surface-inset-base-hover": "#161a2acc", - "surface-inset-strong": "#0d111fcc", - "surface-inset-strong-hover": "#0d111fcc", - "surface-raised-base": "#242a42", - "surface-float-base": "#242b45", - "surface-float-base-hover": "#2a3154", - "surface-raised-base-hover": "#272e49", - "surface-raised-base-active": "#2c3353", - "surface-raised-strong": "#31385a", - "surface-raised-strong-hover": "#373f6b", - "surface-raised-stronger": "#3b4261", - "surface-raised-stronger-hover": "#444c82", - "surface-weak": "#1b2033", - "surface-weaker": "#181d2d", - "surface-strong": "#323858", - "surface-raised-stronger-non-alpha": "#2b3150", - "surface-diff-add-base": "#1c2a38", - "surface-diff-delete-base": "#2a1f32", - "surface-diff-hidden-base": "#24283b", - "text-base": "#c0caf5", - "text-weak": "#7a88cf", - "text-strong": "#eaeaff", - "syntax-string": "#9ece6a", - "syntax-primitive": "#ff9e64", - "syntax-property": "#bb9af7", - "syntax-type": "#e0af68", - "syntax-constant": "#7dcfff", - "syntax-info": "#7dcfff", - "markdown-heading": "#bb9af7", - "markdown-text": "#c0caf5", - "markdown-link": "#7aa2f7", - "markdown-link-text": "#7dcfff", - "markdown-code": "#9ece6a", - "markdown-block-quote": "#e0af68", - "markdown-emph": "#e0af68", - "markdown-strong": "#ff9e64", - "markdown-horizontal-rule": "#3b4261", - "markdown-list-item": "#7aa2f7", - "markdown-list-enumeration": "#7dcfff", - "markdown-image": "#7aa2f7", - "markdown-image-text": "#7dcfff", - "markdown-code-block": "#c0caf5" + "syntax-keyword": "#bb9af7" } } } diff --git a/packages/ui/src/theme/themes/vesper.json b/packages/ui/src/theme/themes/vesper.json index 040bdc049b..8cc658232f 100644 --- a/packages/ui/src/theme/themes/vesper.json +++ b/packages/ui/src/theme/themes/vesper.json @@ -3,129 +3,38 @@ "name": "Vesper", "id": "vesper", "light": { - "seeds": { + "palette": { "neutral": "#F0F0F0", + "ink": "#101010", "primary": "#FFC799", + "accent": "#B30000", "success": "#99FFE4", "warning": "#FFC799", "error": "#FF8080", "info": "#FFC799", - "interactive": "#FFC799", "diffAdd": "#99FFE4", "diffDelete": "#FF8080" }, "overrides": { - "background-base": "#FFF", - "background-weak": "#F8F8F8", - "background-strong": "#F0F0F0", - "background-stronger": "#FBFBFB", - "border-weak-hover": "#E0E0E0", - "border-weak-active": "#D8D8D8", - "border-weak-selected": "#D0D0D0", - "border-weak-disabled": "#F0F0F0", - "border-weak-focus": "#D8D8D8", - "border-base": "#D0D0D0", - "border-hover": "#C8C8C8", - "border-active": "#C0C0C0", - "border-selected": "#B8B8B8", - "border-disabled": "#E8E8E8", - "border-focus": "#C0C0C0", - "border-strong-base": "#A0A0A0", - "border-strong-hover": "#989898", - "border-strong-active": "#909090", - "border-strong-selected": "#888888", - "border-strong-disabled": "#D0D0D0", - "border-strong-focus": "#909090", - "surface-diff-add-base": "#e8f5e8", - "surface-diff-delete-base": "#f5e8e8", - "surface-diff-hidden-base": "#F0F0F0", - "text-base": "#101010", - "text-invert-strong": "var(--smoke-dark-alpha-12)", - "text-weak": "#606060", - "text-strong": "#000000", - "syntax-string": "#0D5C4F", - "syntax-primitive": "#B30000", - "syntax-property": "#C66C00", - "syntax-type": "#9C5C12", - "syntax-constant": "#404040", - "syntax-info": "#606060", - "markdown-heading": "#FFC799", - "markdown-text": "#101010", - "markdown-link": "#FFC799", - "markdown-link-text": "#A0A0A0", - "markdown-code": "#A0A0A0", - "markdown-block-quote": "#101010", - "markdown-emph": "#101010", - "markdown-strong": "#101010", - "markdown-horizontal-rule": "#65737E", - "markdown-list-item": "#101010", - "markdown-list-enumeration": "#101010", - "markdown-image": "#FFC799", - "markdown-image-text": "#A0A0A0", - "markdown-code-block": "#FFC799" + "syntax-keyword": "#b30000" } }, "dark": { - "seeds": { + "palette": { "neutral": "#101010", + "ink": "#FFF", "primary": "#FFC799", + "accent": "#FF8080", "success": "#99FFE4", "warning": "#FFC799", "error": "#FF8080", "info": "#FFC799", - "interactive": "#FFC799", "diffAdd": "#99FFE4", "diffDelete": "#FF8080" }, "overrides": { - "background-base": "#101010", - "background-weak": "#141414", - "background-strong": "#0C0C0C", - "background-stronger": "#080808", - "border-weak-base": "#1C1C1C", - "border-weak-hover": "#202020", - "border-weak-active": "#242424", - "border-weak-selected": "#282828", - "border-weak-disabled": "#141414", - "border-weak-focus": "#242424", - "border-base": "#282828", - "border-hover": "#303030", - "border-active": "#383838", - "border-selected": "#404040", - "border-disabled": "#181818", - "border-focus": "#383838", - "border-strong-base": "#505050", - "border-strong-hover": "#585858", - "border-strong-active": "#606060", - "border-strong-selected": "#686868", - "border-strong-disabled": "#202020", - "border-strong-focus": "#606060", - "surface-diff-add-base": "#0d2818", - "surface-diff-delete-base": "#281a1a", - "surface-diff-hidden-base": "#141414", - "text-base": "#FFF", - "text-weak": "#A0A0A0", - "text-strong": "#FFFFFF", - "syntax-string": "#99FFE4", - "syntax-primitive": "#FF8080", - "syntax-property": "#FFC799", - "syntax-type": "#FFC799", - "syntax-constant": "#A0A0A0", - "syntax-info": "#8b8b8b", - "markdown-heading": "#FFC799", - "markdown-text": "#FFF", - "markdown-link": "#FFC799", - "markdown-link-text": "#A0A0A0", - "markdown-code": "#A0A0A0", - "markdown-block-quote": "#FFF", - "markdown-emph": "#FFF", - "markdown-strong": "#FFF", - "markdown-horizontal-rule": "#65737E", - "markdown-list-item": "#FFF", - "markdown-list-enumeration": "#FFF", - "markdown-image": "#FFC799", - "markdown-image-text": "#A0A0A0", - "markdown-code-block": "#FFF" + "syntax-keyword": "#ff8080", + "syntax-primitive": "#ffc799" } } } diff --git a/packages/ui/src/theme/types.ts b/packages/ui/src/theme/types.ts index 73bd372b45..126e9bb5aa 100644 --- a/packages/ui/src/theme/types.ts +++ b/packages/ui/src/theme/types.ts @@ -18,11 +18,28 @@ export interface ThemeSeedColors { diffDelete: HexColor } -export interface ThemeVariant { - seeds: ThemeSeedColors +export interface ThemePaletteColors { + neutral: HexColor + ink?: HexColor + primary: HexColor + success: HexColor + warning: HexColor + error: HexColor + info: HexColor + accent?: HexColor + interactive?: HexColor + diffAdd?: HexColor + diffDelete?: HexColor +} + +type ThemeVariantBase = { overrides?: Record } +export type ThemeVariant = + | ({ seeds: ThemeSeedColors; palette?: never } & ThemeVariantBase) + | ({ palette: ThemePaletteColors; seeds?: never } & ThemeVariantBase) + export interface DesktopTheme { $schema?: string name: string diff --git a/packages/util/package.json b/packages/util/package.json index bc84be6360..04b0bb93f4 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.22", "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..783b3d1a6f 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.22", "scripts": { "dev": "astro dev", "dev:remote": "VITE_API_URL=https://api.opencode.ai astro dev", diff --git a/packages/web/src/content/docs/ar/keybinds.mdx b/packages/web/src/content/docs/ar/keybinds.mdx index f07eaed37b..986313a5b5 100644 --- a/packages/web/src/content/docs/ar/keybinds.mdx +++ b/packages/web/src/content/docs/ar/keybinds.mdx @@ -28,6 +28,7 @@ description: خصّص اختصارات لوحة المفاتيح. "session_unshare": "none", "session_interrupt": "escape", "session_compact": "c", + "session_child_first": "down", "session_child_cycle": "right", "session_child_cycle_reverse": "left", "session_parent": "up", diff --git a/packages/web/src/content/docs/bs/keybinds.mdx b/packages/web/src/content/docs/bs/keybinds.mdx index a7a6d34a0d..a70fdedaef 100644 --- a/packages/web/src/content/docs/bs/keybinds.mdx +++ b/packages/web/src/content/docs/bs/keybinds.mdx @@ -28,6 +28,7 @@ OpenCode ima listu veza tipki koje možete prilagoditi putem `tui.json`. "session_unshare": "none", "session_interrupt": "escape", "session_compact": "c", + "session_child_first": "down", "session_child_cycle": "right", "session_child_cycle_reverse": "left", "session_parent": "up", diff --git a/packages/web/src/content/docs/da/keybinds.mdx b/packages/web/src/content/docs/da/keybinds.mdx index 9c066fcb49..237c36f775 100644 --- a/packages/web/src/content/docs/da/keybinds.mdx +++ b/packages/web/src/content/docs/da/keybinds.mdx @@ -28,6 +28,7 @@ OpenCode har en liste over nøglebindinger, som du kan tilpasse gennem `tui.json "session_unshare": "none", "session_interrupt": "escape", "session_compact": "c", + "session_child_first": "down", "session_child_cycle": "right", "session_child_cycle_reverse": "left", "session_parent": "up", diff --git a/packages/web/src/content/docs/de/keybinds.mdx b/packages/web/src/content/docs/de/keybinds.mdx index d575800805..628c65006d 100644 --- a/packages/web/src/content/docs/de/keybinds.mdx +++ b/packages/web/src/content/docs/de/keybinds.mdx @@ -3,11 +3,11 @@ title: Tastenkombinationen description: Passen Sie Ihre Tastenkombinationen an. --- -OpenCode verfügt über eine Liste von Tastenkombinationen, die Sie über die OpenCode-Konfiguration anpassen können. +OpenCode verfügt über eine Liste von Tastenkombinationen, die Sie über `tui.json` anpassen können. -```json title="opencode.json" +```json title="tui.json" { - "$schema": "https://opencode.ai/config.json", + "$schema": "https://opencode.ai/tui.json", "keybinds": { "leader": "ctrl+x", "app_exit": "ctrl+c,ctrl+d,q", @@ -28,6 +28,7 @@ OpenCode verfügt über eine Liste von Tastenkombinationen, die Sie über die Op "session_unshare": "none", "session_interrupt": "escape", "session_compact": "c", + "session_child_first": "down", "session_child_cycle": "right", "session_child_cycle_reverse": "left", "session_parent": "up", @@ -117,11 +118,11 @@ Sie müssen für Ihre Keybinds keinen Leader Key verwenden, wir empfehlen jedoch ## Keybind deaktivieren -Sie können eine Keybind deaktivieren, indem Sie den Schlüssel mit dem Wert „none“ zu Ihrer Konfiguration hinzufügen. +Sie können eine Keybind deaktivieren, indem Sie den Schlüssel mit dem Wert „none“ zu `tui.json` hinzufügen. -```json title="opencode.json" +```json title="tui.json" { - "$schema": "https://opencode.ai/config.json", + "$schema": "https://opencode.ai/tui.json", "keybinds": { "session_compact": "none" } diff --git a/packages/web/src/content/docs/es/keybinds.mdx b/packages/web/src/content/docs/es/keybinds.mdx index d4880db2fa..4c1f7e1a80 100644 --- a/packages/web/src/content/docs/es/keybinds.mdx +++ b/packages/web/src/content/docs/es/keybinds.mdx @@ -28,6 +28,7 @@ OpenCode tiene una lista de combinaciones de teclas que puede personalizar a tra "session_unshare": "none", "session_interrupt": "escape", "session_compact": "c", + "session_child_first": "down", "session_child_cycle": "right", "session_child_cycle_reverse": "left", "session_parent": "up", diff --git a/packages/web/src/content/docs/fr/keybinds.mdx b/packages/web/src/content/docs/fr/keybinds.mdx index 4ec98adfa2..281e5df743 100644 --- a/packages/web/src/content/docs/fr/keybinds.mdx +++ b/packages/web/src/content/docs/fr/keybinds.mdx @@ -28,6 +28,7 @@ OpenCode a une liste de raccourcis clavier que vous pouvez personnaliser via la "session_unshare": "none", "session_interrupt": "escape", "session_compact": "c", + "session_child_first": "down", "session_child_cycle": "right", "session_child_cycle_reverse": "left", "session_parent": "up", diff --git a/packages/web/src/content/docs/it/keybinds.mdx b/packages/web/src/content/docs/it/keybinds.mdx index 548805e6ba..e599f4e417 100644 --- a/packages/web/src/content/docs/it/keybinds.mdx +++ b/packages/web/src/content/docs/it/keybinds.mdx @@ -28,6 +28,7 @@ OpenCode ha una lista di scorciatoie che puoi personalizzare tramite `tui.json`. "session_unshare": "none", "session_interrupt": "escape", "session_compact": "c", + "session_child_first": "down", "session_child_cycle": "right", "session_child_cycle_reverse": "left", "session_parent": "up", diff --git a/packages/web/src/content/docs/it/zen.mdx b/packages/web/src/content/docs/it/zen.mdx index 3c892f0d48..0f21684448 100644 --- a/packages/web/src/content/docs/it/zen.mdx +++ b/packages/web/src/content/docs/it/zen.mdx @@ -7,19 +7,19 @@ import config from "../../../../config.mjs" export const console = config.console export const email = `mailto:${config.email}` -OpenCode Zen e una lista di modelli testati e verificati dal team di OpenCode. +OpenCode Zen è una lista di modelli testati e verificati dal team di OpenCode. :::note -OpenCode Zen e attualmente in beta. +OpenCode Zen è attualmente in beta. ::: -Zen funziona come qualunque altro provider in OpenCode. Accedi a OpenCode Zen e ottieni la tua chiave API. E **completamente opzionale**: non devi usarlo per usare OpenCode. +Zen funziona come qualunque altro provider in OpenCode. Accedi a OpenCode Zen e ottieni la tua chiave API. È **completamente opzionale**: non devi usarlo per usare OpenCode. --- ## Contesto -Ci sono moltissimi modelli, ma solo pochi funzionano bene come agenti di coding. Inoltre, la maggior parte dei provider e configurata in modo molto diverso, quindi prestazioni e qualita possono variare parecchio. +Ci sono moltissimi modelli, ma solo pochi funzionano bene come agenti di coding. Inoltre, la maggior parte dei provider è configurata in modo molto diverso, quindi prestazioni e qualità possono variare parecchio. :::tip Abbiamo testato un gruppo selezionato di modelli e provider che funzionano bene con OpenCode. @@ -33,7 +33,7 @@ Per risolvere, abbiamo fatto alcune cose: 2. Poi abbiamo lavorato con alcuni provider per assicurarci che venissero serviti correttamente. 3. Infine, abbiamo fatto benchmark delle combinazioni modello/provider e creato una lista che ci sentiamo di raccomandare. -OpenCode Zen e un gateway AI che ti da accesso a questi modelli. +OpenCode Zen e un gateway AI che ti dà accesso a questi modelli. --- @@ -158,7 +158,7 @@ Le commissioni della carta di credito vengono ribaltate al costo (4.4% + $0.30 p I modelli gratuiti: - MiniMax M2.5 Free e disponibile su OpenCode per un periodo limitato. Il team usa questo tempo per raccogliere feedback e migliorare il modello. -- Big Pickle e un modello stealth gratuito su OpenCode per un periodo limitato. Il team usa questo tempo per raccogliere feedback e migliorare il modello. +- Big Pickle è un modello stealth gratuito su OpenCode per un periodo limitato. Il team usa questo tempo per raccogliere feedback e migliorare il modello. Contattaci se hai domande. @@ -212,7 +212,7 @@ Zen funziona benissimo anche per i team. Puoi invitare colleghi, assegnare ruoli I workspace sono attualmente gratuiti per i team come parte della beta. ::: -Gestire il workspace e attualmente gratuito per i team come parte della beta. Condivideremo presto piu dettagli sul pricing. +Gestire il workspace è attualmente gratuito per i team come parte della beta. Condivideremo presto piu dettagli sul pricing. --- @@ -231,7 +231,7 @@ Gli admin possono anche impostare limiti mensili di spesa per ogni membro per te Gli admin possono abilitare o disabilitare modelli specifici per il workspace. Le richieste verso un modello disabilitato restituiscono un errore. -Questo e utile quando vuoi disabilitare l'uso di un modello che raccoglie dati. +Questo è utile quando vuoi disabilitare l'uso di un modello che raccoglie dati. --- diff --git a/packages/web/src/content/docs/ja/keybinds.mdx b/packages/web/src/content/docs/ja/keybinds.mdx index 09a3b0d7cb..3ec9ca94d5 100644 --- a/packages/web/src/content/docs/ja/keybinds.mdx +++ b/packages/web/src/content/docs/ja/keybinds.mdx @@ -28,6 +28,7 @@ OpenCode には、`tui.json` を通じてカスタマイズできるキーバイ "session_unshare": "none", "session_interrupt": "escape", "session_compact": "c", + "session_child_first": "down", "session_child_cycle": "right", "session_child_cycle_reverse": "left", "session_parent": "up", diff --git a/packages/web/src/content/docs/keybinds.mdx b/packages/web/src/content/docs/keybinds.mdx index 95b3d49639..54c15e8621 100644 --- a/packages/web/src/content/docs/keybinds.mdx +++ b/packages/web/src/content/docs/keybinds.mdx @@ -28,6 +28,7 @@ OpenCode has a list of keybinds that you can customize through `tui.json`. "session_unshare": "none", "session_interrupt": "escape", "session_compact": "c", + "session_child_first": "down", "session_child_cycle": "right", "session_child_cycle_reverse": "left", "session_parent": "up", diff --git a/packages/web/src/content/docs/ko/keybinds.mdx b/packages/web/src/content/docs/ko/keybinds.mdx index 2920a22357..fa8b4e9bdd 100644 --- a/packages/web/src/content/docs/ko/keybinds.mdx +++ b/packages/web/src/content/docs/ko/keybinds.mdx @@ -28,6 +28,7 @@ OpenCode에는 `tui.json`을 통해 커스터마이즈할 수 있는 키바인 "session_unshare": "none", "session_interrupt": "escape", "session_compact": "c", + "session_child_first": "down", "session_child_cycle": "right", "session_child_cycle_reverse": "left", "session_parent": "up", diff --git a/packages/web/src/content/docs/nb/keybinds.mdx b/packages/web/src/content/docs/nb/keybinds.mdx index 8314b4dd82..f9837480dc 100644 --- a/packages/web/src/content/docs/nb/keybinds.mdx +++ b/packages/web/src/content/docs/nb/keybinds.mdx @@ -28,6 +28,7 @@ OpenCode har en liste over tastebindinger som du kan tilpasse gjennom `tui.json` "session_unshare": "none", "session_interrupt": "escape", "session_compact": "c", + "session_child_first": "down", "session_child_cycle": "right", "session_child_cycle_reverse": "left", "session_parent": "up", diff --git a/packages/web/src/content/docs/pl/keybinds.mdx b/packages/web/src/content/docs/pl/keybinds.mdx index 03b9ec9c2a..4744ffc783 100644 --- a/packages/web/src/content/docs/pl/keybinds.mdx +++ b/packages/web/src/content/docs/pl/keybinds.mdx @@ -28,6 +28,7 @@ OpenCode zawiera listę skrótów klawiszowych, które można dostosować za pom "session_unshare": "none", "session_interrupt": "escape", "session_compact": "c", + "session_child_first": "down", "session_child_cycle": "right", "session_child_cycle_reverse": "left", "session_parent": "up", diff --git a/packages/web/src/content/docs/pt-br/keybinds.mdx b/packages/web/src/content/docs/pt-br/keybinds.mdx index 1829763ade..6c7fcd208e 100644 --- a/packages/web/src/content/docs/pt-br/keybinds.mdx +++ b/packages/web/src/content/docs/pt-br/keybinds.mdx @@ -28,6 +28,7 @@ O opencode tem uma lista de atalhos de teclado que você pode personalizar atrav "session_unshare": "none", "session_interrupt": "escape", "session_compact": "c", + "session_child_first": "down", "session_child_cycle": "right", "session_child_cycle_reverse": "left", "session_parent": "up", diff --git a/packages/web/src/content/docs/ru/keybinds.mdx b/packages/web/src/content/docs/ru/keybinds.mdx index 8a9a14ca1a..bfd4bf0c24 100644 --- a/packages/web/src/content/docs/ru/keybinds.mdx +++ b/packages/web/src/content/docs/ru/keybinds.mdx @@ -28,6 +28,7 @@ opencode имеет список сочетаний клавиш, которые "session_unshare": "none", "session_interrupt": "escape", "session_compact": "c", + "session_child_first": "down", "session_child_cycle": "right", "session_child_cycle_reverse": "left", "session_parent": "up", diff --git a/packages/web/src/content/docs/th/keybinds.mdx b/packages/web/src/content/docs/th/keybinds.mdx index 8cc7586e5f..ce3234a04a 100644 --- a/packages/web/src/content/docs/th/keybinds.mdx +++ b/packages/web/src/content/docs/th/keybinds.mdx @@ -28,6 +28,7 @@ OpenCode มีรายการปุ่มลัดที่คุณปร "session_unshare": "none", "session_interrupt": "escape", "session_compact": "c", + "session_child_first": "down", "session_child_cycle": "right", "session_child_cycle_reverse": "left", "session_parent": "up", diff --git a/packages/web/src/content/docs/tr/keybinds.mdx b/packages/web/src/content/docs/tr/keybinds.mdx index bea63a3550..7d3142bf38 100644 --- a/packages/web/src/content/docs/tr/keybinds.mdx +++ b/packages/web/src/content/docs/tr/keybinds.mdx @@ -28,6 +28,7 @@ opencode, `tui.json` aracılığıyla özelleştirebileceğiniz bir tuş bağlan "session_unshare": "none", "session_interrupt": "escape", "session_compact": "c", + "session_child_first": "down", "session_child_cycle": "right", "session_child_cycle_reverse": "left", "session_parent": "up", @@ -134,21 +135,21 @@ Anahtarı yapılandırmanıza "none" değeriyle ekleyerek bir tuş atamasını d opencode masaüstü uygulaması bilgi istemi girişi, metni düzenlemek için yaygın Readline/Emacs tarzı kısayolları destekler. Bunlar yerleşiktir ve şu anda `opencode.json` aracılığıyla yapılandırılamaz. -| Shortcut | Action | -| -------- | ---------------------------------------- | -| `ctrl+a` | Geçerli satırın başına git | -| `ctrl+e` | Move to end of current line | -| `ctrl+b` | Move cursor back one character | -| `ctrl+f` | Move cursor forward one character | -| `alt+b` | Move cursor back one word | -| `alt+f` | Move cursor forward one word | -| `ctrl+d` | Delete character under cursor | -| `ctrl+k` | Kill to end of line | -| `ctrl+u` | Satırın başına kadar öldür | -| `ctrl+w` | Kill previous word | -| `alt+d` | Kill next word | -| `ctrl+t` | Transpose characters | -| `ctrl+g` | Cancel popovers / abort running response | +| Shortcut | Action | +| -------- | --------------------------------------------------- | +| `ctrl+a` | Geçerli satırın başına git | +| `ctrl+e` | Geçerli satırın sonuna git | +| `ctrl+b` | İmleci bir karakter geri taşı | +| `ctrl+f` | İmleci bir karakter ileri taşı | +| `alt+b` | İmleci bir kelime geri taşı | +| `alt+f` | İmleci bir kelime ileri taşı | +| `ctrl+d` | İmleç altındaki karakteri sil | +| `ctrl+k` | Satırın sonuna kadar sil | +| `ctrl+u` | Satırın başına kadar sil | +| `ctrl+w` | Önceki kelimeyi sil | +| `alt+d` | Sonraki kelimeyi sil | +| `ctrl+t` | Karakterlerin yerini değiştir | +| `ctrl+g` | Açılır pencereleri iptal et / çalışan yanıtı durdur | --- @@ -158,7 +159,7 @@ Bazı terminaller varsayılan olarak Enter ile değiştirici tuşlar göndermez. ### Windows Terminali -`settings.json` cihazınızı şu adreste açın: +`settings.json` dosyasını şurada açın: ``` %LOCALAPPDATA%\Packages\Microsoft.WindowsTerminal_8wekyb3d8bbwe\LocalState\settings.json diff --git a/packages/web/src/content/docs/zh-cn/keybinds.mdx b/packages/web/src/content/docs/zh-cn/keybinds.mdx index 5108fdbb51..33f75c6dc8 100644 --- a/packages/web/src/content/docs/zh-cn/keybinds.mdx +++ b/packages/web/src/content/docs/zh-cn/keybinds.mdx @@ -28,6 +28,7 @@ OpenCode 提供了一系列快捷键,您可以通过 `tui.json` 进行自定 "session_unshare": "none", "session_interrupt": "escape", "session_compact": "c", + "session_child_first": "down", "session_child_cycle": "right", "session_child_cycle_reverse": "left", "session_parent": "up", diff --git a/packages/web/src/content/docs/zh-tw/keybinds.mdx b/packages/web/src/content/docs/zh-tw/keybinds.mdx index ca085db01d..574404b2fd 100644 --- a/packages/web/src/content/docs/zh-tw/keybinds.mdx +++ b/packages/web/src/content/docs/zh-tw/keybinds.mdx @@ -28,6 +28,7 @@ OpenCode 提供了一系列快捷鍵,您可以透過 `tui.json` 進行自訂 "session_unshare": "none", "session_interrupt": "escape", "session_compact": "c", + "session_child_first": "down", "session_child_cycle": "right", "session_child_cycle_reverse": "left", "session_parent": "up", diff --git a/sdks/vscode/package.json b/sdks/vscode/package.json index 215143ac99..dcbbbc3d07 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.22", "publisher": "sst-dev", "repository": { "type": "git",