mirror of
https://github.com/anomalyco/opencode.git
synced 2026-03-06 22:54:04 +00:00
Compare commits
26 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3704dbcee1 | ||
|
|
b47ab35ddf | ||
|
|
0ac8f06521 | ||
|
|
4e46d98156 | ||
|
|
4795806b13 | ||
|
|
7a071eff5c | ||
|
|
7da24ebf5d | ||
|
|
d6e0f47361 | ||
|
|
95385eb652 | ||
|
|
a71b11caca | ||
|
|
e9568999c3 | ||
|
|
5e699c9426 | ||
|
|
e0ca52ed1f | ||
|
|
1d9dcd2a27 | ||
|
|
eeeb21ff86 | ||
|
|
2094e8b255 | ||
|
|
e1cf761d29 | ||
|
|
f64bb91257 | ||
|
|
eb9eb5e334 | ||
|
|
d4d1292a0e | ||
|
|
b7605add58 | ||
|
|
6c7d968c44 | ||
|
|
326c70184d | ||
|
|
aec6ca71fa | ||
|
|
c04da45be5 | ||
|
|
74effa8eec |
@@ -35,7 +35,8 @@
|
||||
<a href="README.tr.md">Türkçe</a> |
|
||||
<a href="README.uk.md">Українська</a> |
|
||||
<a href="README.bn.md">বাংলা</a> |
|
||||
<a href="README.gr.md">Ελληνικά</a>
|
||||
<a href="README.gr.md">Ελληνικά</a> |
|
||||
<a href="README.vi.md">Tiếng Việt</a>
|
||||
</p>
|
||||
|
||||
[](https://opencode.ai)
|
||||
|
||||
@@ -35,7 +35,8 @@
|
||||
<a href="README.tr.md">Türkçe</a> |
|
||||
<a href="README.uk.md">Українська</a> |
|
||||
<a href="README.bn.md">বাংলা</a> |
|
||||
<a href="README.gr.md">Ελληνικά</a>
|
||||
<a href="README.gr.md">Ελληνικά</a> |
|
||||
<a href="README.vi.md">Tiếng Việt</a>
|
||||
</p>
|
||||
|
||||
[](https://opencode.ai)
|
||||
|
||||
@@ -27,6 +27,7 @@
|
||||
<a href="README.ja.md">日本語</a> |
|
||||
<a href="README.pl.md">Polski</a> |
|
||||
<a href="README.ru.md">Русский</a> |
|
||||
<a href="README.bs.md">Bosanski</a> |
|
||||
<a href="README.ar.md">العربية</a> |
|
||||
<a href="README.no.md">Norsk</a> |
|
||||
<a href="README.br.md">Português (Brasil)</a> |
|
||||
@@ -34,7 +35,8 @@
|
||||
<a href="README.tr.md">Türkçe</a> |
|
||||
<a href="README.uk.md">Українська</a> |
|
||||
<a href="README.bn.md">বাংলা</a> |
|
||||
<a href="README.gr.md">Ελληνικά</a>
|
||||
<a href="README.gr.md">Ελληνικά</a> |
|
||||
<a href="README.vi.md">Tiếng Việt</a>
|
||||
</p>
|
||||
|
||||
[](https://opencode.ai)
|
||||
|
||||
@@ -35,7 +35,8 @@
|
||||
<a href="README.tr.md">Türkçe</a> |
|
||||
<a href="README.uk.md">Українська</a> |
|
||||
<a href="README.bn.md">বাংলা</a> |
|
||||
<a href="README.gr.md">Ελληνικά</a>
|
||||
<a href="README.gr.md">Ελληνικά</a> |
|
||||
<a href="README.vi.md">Tiếng Việt</a>
|
||||
</p>
|
||||
|
||||
[](https://opencode.ai)
|
||||
|
||||
@@ -27,6 +27,7 @@
|
||||
<a href="README.ja.md">日本語</a> |
|
||||
<a href="README.pl.md">Polski</a> |
|
||||
<a href="README.ru.md">Русский</a> |
|
||||
<a href="README.bs.md">Bosanski</a> |
|
||||
<a href="README.ar.md">العربية</a> |
|
||||
<a href="README.no.md">Norsk</a> |
|
||||
<a href="README.br.md">Português (Brasil)</a> |
|
||||
@@ -34,7 +35,8 @@
|
||||
<a href="README.tr.md">Türkçe</a> |
|
||||
<a href="README.uk.md">Українська</a> |
|
||||
<a href="README.bn.md">বাংলা</a> |
|
||||
<a href="README.gr.md">Ελληνικά</a>
|
||||
<a href="README.gr.md">Ελληνικά</a> |
|
||||
<a href="README.vi.md">Tiếng Việt</a>
|
||||
</p>
|
||||
|
||||
[](https://opencode.ai)
|
||||
|
||||
@@ -27,6 +27,7 @@
|
||||
<a href="README.ja.md">日本語</a> |
|
||||
<a href="README.pl.md">Polski</a> |
|
||||
<a href="README.ru.md">Русский</a> |
|
||||
<a href="README.bs.md">Bosanski</a> |
|
||||
<a href="README.ar.md">العربية</a> |
|
||||
<a href="README.no.md">Norsk</a> |
|
||||
<a href="README.br.md">Português (Brasil)</a> |
|
||||
@@ -34,7 +35,8 @@
|
||||
<a href="README.tr.md">Türkçe</a> |
|
||||
<a href="README.uk.md">Українська</a> |
|
||||
<a href="README.bn.md">বাংলা</a> |
|
||||
<a href="README.gr.md">Ελληνικά</a>
|
||||
<a href="README.gr.md">Ελληνικά</a> |
|
||||
<a href="README.vi.md">Tiếng Việt</a>
|
||||
</p>
|
||||
|
||||
[](https://opencode.ai)
|
||||
|
||||
@@ -27,6 +27,7 @@
|
||||
<a href="README.ja.md">日本語</a> |
|
||||
<a href="README.pl.md">Polski</a> |
|
||||
<a href="README.ru.md">Русский</a> |
|
||||
<a href="README.bs.md">Bosanski</a> |
|
||||
<a href="README.ar.md">العربية</a> |
|
||||
<a href="README.no.md">Norsk</a> |
|
||||
<a href="README.br.md">Português (Brasil)</a> |
|
||||
@@ -34,7 +35,8 @@
|
||||
<a href="README.tr.md">Türkçe</a> |
|
||||
<a href="README.uk.md">Українська</a> |
|
||||
<a href="README.bn.md">বাংলা</a> |
|
||||
<a href="README.gr.md">Ελληνικά</a>
|
||||
<a href="README.gr.md">Ελληνικά</a> |
|
||||
<a href="README.vi.md">Tiếng Việt</a>
|
||||
</p>
|
||||
|
||||
[](https://opencode.ai)
|
||||
|
||||
@@ -27,6 +27,7 @@
|
||||
<a href="README.ja.md">日本語</a> |
|
||||
<a href="README.pl.md">Polski</a> |
|
||||
<a href="README.ru.md">Русский</a> |
|
||||
<a href="README.bs.md">Bosanski</a> |
|
||||
<a href="README.ar.md">العربية</a> |
|
||||
<a href="README.no.md">Norsk</a> |
|
||||
<a href="README.br.md">Português (Brasil)</a> |
|
||||
@@ -34,7 +35,8 @@
|
||||
<a href="README.tr.md">Türkçe</a> |
|
||||
<a href="README.uk.md">Українська</a> |
|
||||
<a href="README.bn.md">বাংলা</a> |
|
||||
<a href="README.gr.md">Ελληνικά</a>
|
||||
<a href="README.gr.md">Ελληνικά</a> |
|
||||
<a href="README.vi.md">Tiếng Việt</a>
|
||||
</p>
|
||||
|
||||
[](https://opencode.ai)
|
||||
|
||||
@@ -35,7 +35,8 @@
|
||||
<a href="README.tr.md">Türkçe</a> |
|
||||
<a href="README.uk.md">Українська</a> |
|
||||
<a href="README.bn.md">বাংলা</a> |
|
||||
<a href="README.gr.md">Ελληνικά</a>
|
||||
<a href="README.gr.md">Ελληνικά</a> |
|
||||
<a href="README.vi.md">Tiếng Việt</a>
|
||||
</p>
|
||||
|
||||
[](https://opencode.ai)
|
||||
|
||||
@@ -27,6 +27,7 @@
|
||||
<a href="README.ja.md">日本語</a> |
|
||||
<a href="README.pl.md">Polski</a> |
|
||||
<a href="README.ru.md">Русский</a> |
|
||||
<a href="README.bs.md">Bosanski</a> |
|
||||
<a href="README.ar.md">العربية</a> |
|
||||
<a href="README.no.md">Norsk</a> |
|
||||
<a href="README.br.md">Português (Brasil)</a> |
|
||||
@@ -34,7 +35,8 @@
|
||||
<a href="README.tr.md">Türkçe</a> |
|
||||
<a href="README.uk.md">Українська</a> |
|
||||
<a href="README.bn.md">বাংলা</a> |
|
||||
<a href="README.gr.md">Ελληνικά</a>
|
||||
<a href="README.gr.md">Ελληνικά</a> |
|
||||
<a href="README.vi.md">Tiếng Việt</a>
|
||||
</p>
|
||||
|
||||
[](https://opencode.ai)
|
||||
|
||||
@@ -27,6 +27,7 @@
|
||||
<a href="README.ja.md">日本語</a> |
|
||||
<a href="README.pl.md">Polski</a> |
|
||||
<a href="README.ru.md">Русский</a> |
|
||||
<a href="README.bs.md">Bosanski</a> |
|
||||
<a href="README.ar.md">العربية</a> |
|
||||
<a href="README.no.md">Norsk</a> |
|
||||
<a href="README.br.md">Português (Brasil)</a> |
|
||||
@@ -34,7 +35,8 @@
|
||||
<a href="README.tr.md">Türkçe</a> |
|
||||
<a href="README.uk.md">Українська</a> |
|
||||
<a href="README.bn.md">বাংলা</a> |
|
||||
<a href="README.gr.md">Ελληνικά</a>
|
||||
<a href="README.gr.md">Ελληνικά</a> |
|
||||
<a href="README.vi.md">Tiếng Việt</a>
|
||||
</p>
|
||||
|
||||
[](https://opencode.ai)
|
||||
|
||||
@@ -27,6 +27,7 @@
|
||||
<a href="README.ja.md">日本語</a> |
|
||||
<a href="README.pl.md">Polski</a> |
|
||||
<a href="README.ru.md">Русский</a> |
|
||||
<a href="README.bs.md">Bosanski</a> |
|
||||
<a href="README.ar.md">العربية</a> |
|
||||
<a href="README.no.md">Norsk</a> |
|
||||
<a href="README.br.md">Português (Brasil)</a> |
|
||||
@@ -34,7 +35,8 @@
|
||||
<a href="README.tr.md">Türkçe</a> |
|
||||
<a href="README.uk.md">Українська</a> |
|
||||
<a href="README.bn.md">বাংলা</a> |
|
||||
<a href="README.gr.md">Ελληνικά</a>
|
||||
<a href="README.gr.md">Ελληνικά</a> |
|
||||
<a href="README.vi.md">Tiếng Việt</a>
|
||||
</p>
|
||||
|
||||
[](https://opencode.ai)
|
||||
|
||||
@@ -35,7 +35,8 @@
|
||||
<a href="README.tr.md">Türkçe</a> |
|
||||
<a href="README.uk.md">Українська</a> |
|
||||
<a href="README.bn.md">বাংলা</a> |
|
||||
<a href="README.gr.md">Ελληνικά</a>
|
||||
<a href="README.gr.md">Ελληνικά</a> |
|
||||
<a href="README.vi.md">Tiếng Việt</a>
|
||||
</p>
|
||||
|
||||
[](https://opencode.ai)
|
||||
|
||||
@@ -27,6 +27,7 @@
|
||||
<a href="README.ja.md">日本語</a> |
|
||||
<a href="README.pl.md">Polski</a> |
|
||||
<a href="README.ru.md">Русский</a> |
|
||||
<a href="README.bs.md">Bosanski</a> |
|
||||
<a href="README.ar.md">العربية</a> |
|
||||
<a href="README.no.md">Norsk</a> |
|
||||
<a href="README.br.md">Português (Brasil)</a> |
|
||||
@@ -34,7 +35,8 @@
|
||||
<a href="README.tr.md">Türkçe</a> |
|
||||
<a href="README.uk.md">Українська</a> |
|
||||
<a href="README.bn.md">বাংলা</a> |
|
||||
<a href="README.gr.md">Ελληνικά</a>
|
||||
<a href="README.gr.md">Ελληνικά</a> |
|
||||
<a href="README.vi.md">Tiếng Việt</a>
|
||||
</p>
|
||||
|
||||
[](https://opencode.ai)
|
||||
|
||||
@@ -27,6 +27,7 @@
|
||||
<a href="README.ja.md">日本語</a> |
|
||||
<a href="README.pl.md">Polski</a> |
|
||||
<a href="README.ru.md">Русский</a> |
|
||||
<a href="README.bs.md">Bosanski</a> |
|
||||
<a href="README.ar.md">العربية</a> |
|
||||
<a href="README.no.md">Norsk</a> |
|
||||
<a href="README.br.md">Português (Brasil)</a> |
|
||||
@@ -34,7 +35,8 @@
|
||||
<a href="README.tr.md">Türkçe</a> |
|
||||
<a href="README.uk.md">Українська</a> |
|
||||
<a href="README.bn.md">বাংলা</a> |
|
||||
<a href="README.gr.md">Ελληνικά</a>
|
||||
<a href="README.gr.md">Ελληνικά</a> |
|
||||
<a href="README.vi.md">Tiếng Việt</a>
|
||||
</p>
|
||||
|
||||
[](https://opencode.ai)
|
||||
|
||||
@@ -27,6 +27,7 @@
|
||||
<a href="README.ja.md">日本語</a> |
|
||||
<a href="README.pl.md">Polski</a> |
|
||||
<a href="README.ru.md">Русский</a> |
|
||||
<a href="README.bs.md">Bosanski</a> |
|
||||
<a href="README.ar.md">العربية</a> |
|
||||
<a href="README.no.md">Norsk</a> |
|
||||
<a href="README.br.md">Português (Brasil)</a> |
|
||||
@@ -34,7 +35,8 @@
|
||||
<a href="README.tr.md">Türkçe</a> |
|
||||
<a href="README.uk.md">Українська</a> |
|
||||
<a href="README.bn.md">বাংলা</a> |
|
||||
<a href="README.gr.md">Ελληνικά</a>
|
||||
<a href="README.gr.md">Ελληνικά</a> |
|
||||
<a href="README.vi.md">Tiếng Việt</a>
|
||||
</p>
|
||||
|
||||
[](https://opencode.ai)
|
||||
|
||||
@@ -27,6 +27,7 @@
|
||||
<a href="README.ja.md">日本語</a> |
|
||||
<a href="README.pl.md">Polski</a> |
|
||||
<a href="README.ru.md">Русский</a> |
|
||||
<a href="README.bs.md">Bosanski</a> |
|
||||
<a href="README.ar.md">العربية</a> |
|
||||
<a href="README.no.md">Norsk</a> |
|
||||
<a href="README.br.md">Português (Brasil)</a> |
|
||||
@@ -34,7 +35,8 @@
|
||||
<a href="README.tr.md">Türkçe</a> |
|
||||
<a href="README.uk.md">Українська</a> |
|
||||
<a href="README.bn.md">বাংলা</a> |
|
||||
<a href="README.gr.md">Ελληνικά</a>
|
||||
<a href="README.gr.md">Ελληνικά</a> |
|
||||
<a href="README.vi.md">Tiếng Việt</a>
|
||||
</p>
|
||||
|
||||
[](https://opencode.ai)
|
||||
|
||||
@@ -27,6 +27,7 @@
|
||||
<a href="README.ja.md">日本語</a> |
|
||||
<a href="README.pl.md">Polski</a> |
|
||||
<a href="README.ru.md">Русский</a> |
|
||||
<a href="README.bs.md">Bosanski</a> |
|
||||
<a href="README.ar.md">العربية</a> |
|
||||
<a href="README.no.md">Norsk</a> |
|
||||
<a href="README.br.md">Português (Brasil)</a> |
|
||||
@@ -34,7 +35,8 @@
|
||||
<a href="README.tr.md">Türkçe</a> |
|
||||
<a href="README.uk.md">Українська</a> |
|
||||
<a href="README.bn.md">বাংলা</a> |
|
||||
<a href="README.gr.md">Ελληνικά</a>
|
||||
<a href="README.gr.md">Ελληνικά</a> |
|
||||
<a href="README.vi.md">Tiếng Việt</a>
|
||||
</p>
|
||||
|
||||
[](https://opencode.ai)
|
||||
|
||||
@@ -35,7 +35,8 @@
|
||||
<a href="README.tr.md">Türkçe</a> |
|
||||
<a href="README.uk.md">Українська</a> |
|
||||
<a href="README.bn.md">বাংলা</a> |
|
||||
<a href="README.gr.md">Ελληνικά</a>
|
||||
<a href="README.gr.md">Ελληνικά</a> |
|
||||
<a href="README.vi.md">Tiếng Việt</a>
|
||||
</p>
|
||||
|
||||
[](https://opencode.ai)
|
||||
|
||||
141
README.vi.md
Normal file
141
README.vi.md
Normal file
@@ -0,0 +1,141 @@
|
||||
<p align="center">
|
||||
<a href="https://opencode.ai">
|
||||
<picture>
|
||||
<source srcset="packages/console/app/src/asset/logo-ornate-dark.svg" media="(prefers-color-scheme: dark)">
|
||||
<source srcset="packages/console/app/src/asset/logo-ornate-light.svg" media="(prefers-color-scheme: light)">
|
||||
<img src="packages/console/app/src/asset/logo-ornate-light.svg" alt="OpenCode logo">
|
||||
</picture>
|
||||
</a>
|
||||
</p>
|
||||
<p align="center">Trợ lý lập trình AI mã nguồn mở.</p>
|
||||
<p align="center">
|
||||
<a href="https://opencode.ai/discord"><img alt="Discord" src="https://img.shields.io/discord/1391832426048651334?style=flat-square&label=discord" /></a>
|
||||
<a href="https://www.npmjs.com/package/opencode-ai"><img alt="npm" src="https://img.shields.io/npm/v/opencode-ai?style=flat-square" /></a>
|
||||
<a href="https://github.com/anomalyco/opencode/actions/workflows/publish.yml"><img alt="Build status" src="https://img.shields.io/github/actions/workflow/status/anomalyco/opencode/publish.yml?style=flat-square&branch=dev" /></a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="README.md">English</a> |
|
||||
<a href="README.zh.md">简体中文</a> |
|
||||
<a href="README.zht.md">繁體中文</a> |
|
||||
<a href="README.ko.md">한국어</a> |
|
||||
<a href="README.de.md">Deutsch</a> |
|
||||
<a href="README.es.md">Español</a> |
|
||||
<a href="README.fr.md">Français</a> |
|
||||
<a href="README.it.md">Italiano</a> |
|
||||
<a href="README.da.md">Dansk</a> |
|
||||
<a href="README.ja.md">日本語</a> |
|
||||
<a href="README.pl.md">Polski</a> |
|
||||
<a href="README.ru.md">Русский</a> |
|
||||
<a href="README.bs.md">Bosanski</a> |
|
||||
<a href="README.ar.md">العربية</a> |
|
||||
<a href="README.no.md">Norsk</a> |
|
||||
<a href="README.br.md">Português (Brasil)</a> |
|
||||
<a href="README.th.md">ไทย</a> |
|
||||
<a href="README.tr.md">Türkçe</a> |
|
||||
<a href="README.uk.md">Українська</a> |
|
||||
<a href="README.bn.md">বাংলা</a> |
|
||||
<a href="README.gr.md">Ελληνικά</a> |
|
||||
<a href="README.vi.md">Tiếng Việt</a>
|
||||
</p>
|
||||
|
||||
[](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)
|
||||
@@ -27,6 +27,7 @@
|
||||
<a href="README.ja.md">日本語</a> |
|
||||
<a href="README.pl.md">Polski</a> |
|
||||
<a href="README.ru.md">Русский</a> |
|
||||
<a href="README.bs.md">Bosanski</a> |
|
||||
<a href="README.ar.md">العربية</a> |
|
||||
<a href="README.no.md">Norsk</a> |
|
||||
<a href="README.br.md">Português (Brasil)</a> |
|
||||
@@ -34,7 +35,8 @@
|
||||
<a href="README.tr.md">Türkçe</a> |
|
||||
<a href="README.uk.md">Українська</a> |
|
||||
<a href="README.bn.md">বাংলা</a> |
|
||||
<a href="README.gr.md">Ελληνικά</a>
|
||||
<a href="README.gr.md">Ελληνικά</a> |
|
||||
<a href="README.vi.md">Tiếng Việt</a>
|
||||
</p>
|
||||
|
||||
[](https://opencode.ai)
|
||||
|
||||
@@ -27,6 +27,7 @@
|
||||
<a href="README.ja.md">日本語</a> |
|
||||
<a href="README.pl.md">Polski</a> |
|
||||
<a href="README.ru.md">Русский</a> |
|
||||
<a href="README.bs.md">Bosanski</a> |
|
||||
<a href="README.ar.md">العربية</a> |
|
||||
<a href="README.no.md">Norsk</a> |
|
||||
<a href="README.br.md">Português (Brasil)</a> |
|
||||
@@ -34,7 +35,8 @@
|
||||
<a href="README.tr.md">Türkçe</a> |
|
||||
<a href="README.uk.md">Українська</a> |
|
||||
<a href="README.bn.md">বাংলা</a> |
|
||||
<a href="README.gr.md">Ελληνικά</a>
|
||||
<a href="README.gr.md">Ελληνικά</a> |
|
||||
<a href="README.vi.md">Tiếng Việt</a>
|
||||
</p>
|
||||
|
||||
[](https://opencode.ai)
|
||||
|
||||
67
bun.lock
67
bun.lock
@@ -26,7 +26,7 @@
|
||||
},
|
||||
"packages/app": {
|
||||
"name": "@opencode-ai/app",
|
||||
"version": "1.2.19",
|
||||
"version": "1.2.20",
|
||||
"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.20",
|
||||
"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.20",
|
||||
"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.20",
|
||||
"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.20",
|
||||
"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.20",
|
||||
"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.20",
|
||||
"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.20",
|
||||
"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.20",
|
||||
"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.20",
|
||||
"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",
|
||||
@@ -373,6 +373,7 @@
|
||||
"ulid": "catalog:",
|
||||
"vscode-jsonrpc": "8.2.1",
|
||||
"web-tree-sitter": "0.25.10",
|
||||
"which": "6.0.1",
|
||||
"xdg-basedir": "5.1.0",
|
||||
"yargs": "18.0.0",
|
||||
"zod": "catalog:",
|
||||
@@ -395,10 +396,11 @@
|
||||
"@types/bun": "catalog:",
|
||||
"@types/mime-types": "3.0.1",
|
||||
"@types/turndown": "5.0.5",
|
||||
"@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",
|
||||
@@ -407,7 +409,7 @@
|
||||
},
|
||||
"packages/plugin": {
|
||||
"name": "@opencode-ai/plugin",
|
||||
"version": "1.2.19",
|
||||
"version": "1.2.20",
|
||||
"dependencies": {
|
||||
"@opencode-ai/sdk": "workspace:*",
|
||||
"zod": "catalog:",
|
||||
@@ -427,7 +429,7 @@
|
||||
},
|
||||
"packages/sdk/js": {
|
||||
"name": "@opencode-ai/sdk",
|
||||
"version": "1.2.19",
|
||||
"version": "1.2.20",
|
||||
"devDependencies": {
|
||||
"@hey-api/openapi-ts": "0.90.10",
|
||||
"@tsconfig/node22": "catalog:",
|
||||
@@ -438,7 +440,7 @@
|
||||
},
|
||||
"packages/slack": {
|
||||
"name": "@opencode-ai/slack",
|
||||
"version": "1.2.19",
|
||||
"version": "1.2.20",
|
||||
"dependencies": {
|
||||
"@opencode-ai/sdk": "workspace:*",
|
||||
"@slack/bolt": "^3.17.1",
|
||||
@@ -473,7 +475,7 @@
|
||||
},
|
||||
"packages/ui": {
|
||||
"name": "@opencode-ai/ui",
|
||||
"version": "1.2.19",
|
||||
"version": "1.2.20",
|
||||
"dependencies": {
|
||||
"@kobalte/core": "catalog:",
|
||||
"@opencode-ai/sdk": "workspace:*",
|
||||
@@ -504,6 +506,7 @@
|
||||
"virtua": "catalog:",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@happy-dom/global-registrator": "20.0.11",
|
||||
"@tailwindcss/vite": "catalog:",
|
||||
"@tsconfig/node22": "catalog:",
|
||||
"@types/bun": "catalog:",
|
||||
@@ -519,7 +522,7 @@
|
||||
},
|
||||
"packages/util": {
|
||||
"name": "@opencode-ai/util",
|
||||
"version": "1.2.19",
|
||||
"version": "1.2.20",
|
||||
"dependencies": {
|
||||
"zod": "catalog:",
|
||||
},
|
||||
@@ -530,7 +533,7 @@
|
||||
},
|
||||
"packages/web": {
|
||||
"name": "@opencode-ai/web",
|
||||
"version": "1.2.19",
|
||||
"version": "1.2.20",
|
||||
"dependencies": {
|
||||
"@astrojs/cloudflare": "12.6.3",
|
||||
"@astrojs/markdown-remark": "6.3.1",
|
||||
@@ -599,8 +602,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",
|
||||
@@ -2120,6 +2123,8 @@
|
||||
|
||||
"@types/whatwg-mimetype": ["@types/whatwg-mimetype@3.0.2", "", {}, "sha512-c2AKvDT8ToxLIOUlN51gTiHXflsfIFisS4pO7pDPoKouJCESkhZnEy623gwP9laCy5lnLDAw1vAzu2vM2YLOrA=="],
|
||||
|
||||
"@types/which": ["@types/which@3.0.4", "", {}, "sha512-liyfuo/106JdlgSchJzXEQCVArk0CvevqPote8F8HgWgJ3dRCcTHgJIsLDuee0kxk/mhbInzIZk3QWSZJ8R+2w=="],
|
||||
|
||||
"@types/ws": ["@types/ws@8.18.1", "", { "dependencies": { "@types/node": "*" } }, "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg=="],
|
||||
|
||||
"@types/yargs": ["@types/yargs@17.0.33", "", { "dependencies": { "@types/yargs-parser": "*" } }, "sha512-WpxBCKWPLr4xSsHgz511rFJAM+wS28w2zEO1QDNY5zM/S8ok70NNfztH0xwhqKyaK0OHCbN98LDAZuy1ctxDkA=="],
|
||||
@@ -2680,9 +2685,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=="],
|
||||
|
||||
@@ -2958,7 +2963,7 @@
|
||||
|
||||
"get-tsconfig": ["get-tsconfig@4.13.6", "", { "dependencies": { "resolve-pkg-maps": "^1.0.0" } }, "sha512-shZT/QMiSHc/YBLxxOkMtgSid5HFoauqCE3/exfsEcwg1WkeqjG+V40yBbBrsD+jW2HDXcs28xOfcbm2jI8Ddw=="],
|
||||
|
||||
"ghostty-web": ["ghostty-web@github:anomalyco/ghostty-web#4af877d", {}, "anomalyco-ghostty-web-4af877d", "sha512-fbEK8mtr7ar4ySsF+JUGjhaZrane7dKphanN+SxHt5XXI6yLMAh/Hpf6sNCOyyVa2UlGCd7YpXG/T2v2RUAX+A=="],
|
||||
"ghostty-web": ["ghostty-web@github:anomalyco/ghostty-web#4af877d", {}, "anomalyco-ghostty-web-4af877d"],
|
||||
|
||||
"gifwrap": ["gifwrap@0.10.1", "", { "dependencies": { "image-q": "^4.0.0", "omggif": "^1.0.10" } }, "sha512-2760b1vpJHNmLzZ/ubTtNnEx5WApN/PYWJvXvgS+tL1egTTthayFYIQQNi136FLEDcN/IyEY2EcGpIITD6eYUw=="],
|
||||
|
||||
@@ -3236,7 +3241,7 @@
|
||||
|
||||
"isbinaryfile": ["isbinaryfile@5.0.7", "", {}, "sha512-gnWD14Jh3FzS3CPhF0AxNOJ8CxqeblPTADzI38r0wt8ZyQl5edpy75myt08EG2oKvpyiqSqsx+Wkz9vtkbTqYQ=="],
|
||||
|
||||
"isexe": ["isexe@3.1.5", "", {}, "sha512-6B3tLtFqtQS4ekarvLVMZ+X+VlvQekbe4taUkf/rhVO3d/h0M2rfARm/pXLcPEsjjMsFgrFgSrhQIxcSVrBz8w=="],
|
||||
"isexe": ["isexe@4.0.0", "", {}, "sha512-FFUtZMpoZ8RqHS3XeXEmHWLA4thH+ZxCv2lOiPIn1Xc7CxrqhWzNSDzD+/chS/zbYezmiwWLdQC09JdQKmthOw=="],
|
||||
|
||||
"isomorphic-ws": ["isomorphic-ws@5.0.0", "", { "peerDependencies": { "ws": "*" } }, "sha512-muId7Zzn9ywDsyXgTIafTry2sV3nySZeUDe6YedVd1Hvuuep5AsIlqK+XefWpYTyJG5e503F2xIuT2lcU6rCSw=="],
|
||||
|
||||
@@ -4586,7 +4591,7 @@
|
||||
|
||||
"when-exit": ["when-exit@2.1.5", "", {}, "sha512-VGkKJ564kzt6Ms1dbgPP/yuIoQCrsFAnRbptpC5wOEsDaNsbCB2bnfnaA8i/vRs5tjUSEOtIuvl9/MyVsvQZCg=="],
|
||||
|
||||
"which": ["which@5.0.0", "", { "dependencies": { "isexe": "^3.1.1" }, "bin": { "node-which": "bin/which.js" } }, "sha512-JEdGzHwwkrbWoGOlIHqQ5gtprKGOenpDHpxE9zVR1bWbOtYRyPPHMe9FaP6x61CmNaTThSkb0DAJte5jD+DmzQ=="],
|
||||
"which": ["which@6.0.1", "", { "dependencies": { "isexe": "^4.0.0" }, "bin": { "node-which": "bin/which.js" } }, "sha512-oGLe46MIrCRqX7ytPUf66EAYvdeMIZYn3WaocqqKZAxrBpkqHfL/qvTyJ/bTk5+AqHCjXmrv3CEWgy368zhRUg=="],
|
||||
|
||||
"which-boxed-primitive": ["which-boxed-primitive@1.1.1", "", { "dependencies": { "is-bigint": "^1.1.0", "is-boolean-object": "^1.2.1", "is-number-object": "^1.1.1", "is-string": "^1.1.1", "is-symbol": "^1.1.1" } }, "sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA=="],
|
||||
|
||||
@@ -5202,6 +5207,8 @@
|
||||
|
||||
"app-builder-lib/minimatch": ["minimatch@10.2.1", "", { "dependencies": { "brace-expansion": "^5.0.2" } }, "sha512-MClCe8IL5nRRmawL6ib/eT4oLyeKMGCghibcDWK+J0hh0Q8kqSdia6BvbRMVk6mPa6WqUa5uR2oxt6C5jd533A=="],
|
||||
|
||||
"app-builder-lib/which": ["which@5.0.0", "", { "dependencies": { "isexe": "^3.1.1" }, "bin": { "node-which": "bin/which.js" } }, "sha512-JEdGzHwwkrbWoGOlIHqQ5gtprKGOenpDHpxE9zVR1bWbOtYRyPPHMe9FaP6x61CmNaTThSkb0DAJte5jD+DmzQ=="],
|
||||
|
||||
"archiver-utils/glob": ["glob@10.5.0", "", { "dependencies": { "foreground-child": "^3.1.0", "jackspeak": "^3.1.2", "minimatch": "^9.0.4", "minipass": "^7.1.2", "package-json-from-dist": "^1.0.0", "path-scurry": "^1.11.1" }, "bin": { "glob": "dist/esm/bin.mjs" } }, "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg=="],
|
||||
|
||||
"archiver-utils/is-stream": ["is-stream@2.0.1", "", {}, "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg=="],
|
||||
@@ -5264,6 +5271,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=="],
|
||||
@@ -5388,6 +5397,8 @@
|
||||
|
||||
"node-gyp/nopt": ["nopt@8.1.0", "", { "dependencies": { "abbrev": "^3.0.0" }, "bin": { "nopt": "bin/nopt.js" } }, "sha512-ieGu42u/Qsa4TFktmaKEwM6MQH0pOWnaB3htzh0JRtx84+Mebc0cbZYN5bC+6WTZ4+77xrL9Pn5m7CV6VIkV7A=="],
|
||||
|
||||
"node-gyp/which": ["which@5.0.0", "", { "dependencies": { "isexe": "^3.1.1" }, "bin": { "node-which": "bin/which.js" } }, "sha512-JEdGzHwwkrbWoGOlIHqQ5gtprKGOenpDHpxE9zVR1bWbOtYRyPPHMe9FaP6x61CmNaTThSkb0DAJte5jD+DmzQ=="],
|
||||
|
||||
"npm-run-path/path-key": ["path-key@4.0.0", "", {}, "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ=="],
|
||||
|
||||
"nypm/citty": ["citty@0.2.1", "", {}, "sha512-kEV95lFBhQgtogAPlQfJJ0WGVSokvLr/UEoFPiKKOXF7pl98HfUVUD0ejsuTCld/9xH9vogSywZ5KqHzXrZpqg=="],
|
||||
@@ -5918,6 +5929,8 @@
|
||||
|
||||
"app-builder-lib/@electron/get/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
|
||||
|
||||
"app-builder-lib/which/isexe": ["isexe@3.1.5", "", {}, "sha512-6B3tLtFqtQS4ekarvLVMZ+X+VlvQekbe4taUkf/rhVO3d/h0M2rfARm/pXLcPEsjjMsFgrFgSrhQIxcSVrBz8w=="],
|
||||
|
||||
"archiver-utils/glob/jackspeak": ["jackspeak@3.4.3", "", { "dependencies": { "@isaacs/cliui": "^8.0.2" }, "optionalDependencies": { "@pkgjs/parseargs": "^0.11.0" } }, "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw=="],
|
||||
|
||||
"archiver-utils/glob/minimatch": ["minimatch@9.0.5", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow=="],
|
||||
@@ -6000,6 +6013,8 @@
|
||||
|
||||
"node-gyp/nopt/abbrev": ["abbrev@3.0.1", "", {}, "sha512-AO2ac6pjRB3SJmGJo+v5/aK6Omggp6fsLrs6wN9bd35ulu4cCwaAU9+7ZhXjeqHVkaHThLuzH0nZr0YpCDhygg=="],
|
||||
|
||||
"node-gyp/which/isexe": ["isexe@3.1.5", "", {}, "sha512-6B3tLtFqtQS4ekarvLVMZ+X+VlvQekbe4taUkf/rhVO3d/h0M2rfARm/pXLcPEsjjMsFgrFgSrhQIxcSVrBz8w=="],
|
||||
|
||||
"opencode/@ai-sdk/openai/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@3.0.20", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@standard-schema/spec": "^1.0.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-iXHVe0apM2zUEzauqJwqmpC37A5rihrStAih5Ks+JE32iTe4LZ58y17UGBjpQQTCRw9YxMeo2UFLxLpBluyvLQ=="],
|
||||
|
||||
"opencode/@ai-sdk/openai-compatible/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@3.0.20", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@standard-schema/spec": "^1.0.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-iXHVe0apM2zUEzauqJwqmpC37A5rihrStAih5Ks+JE32iTe4LZ58y17UGBjpQQTCRw9YxMeo2UFLxLpBluyvLQ=="],
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"nodeModules": {
|
||||
"x86_64-linux": "sha256-v83hWzYVg/g4zJiBpGsQ71wTdndPk3BQVZ2mjMApUIQ=",
|
||||
"aarch64-linux": "sha256-inpMwkQqwBFP2wL8w/pTOP7q3fg1aOqvE0wgzVd3/B8=",
|
||||
"aarch64-darwin": "sha256-r42LGrQWqDyIy62mBSU5Nf3M22dJ3NNo7mjN/1h8d8Y=",
|
||||
"x86_64-darwin": "sha256-J6XrrdK5qBK3sQBQOO/B3ZluOnsAf5f65l4q/K1nDTI="
|
||||
"x86_64-linux": "sha256-pBTIT8Pgdm3272YhBjiAZsmj0SSpHTklh6lGc8YcMoE=",
|
||||
"aarch64-linux": "sha256-prt039++d5UZgtldAN6+RVOR557ifIeusiy5XpzN8QU=",
|
||||
"aarch64-darwin": "sha256-Y3f+cXcIGLqz6oyc5fG22t6CLD4wGkvwqO6RNXjFriQ=",
|
||||
"x86_64-darwin": "sha256-BjbBBhQUgGhrlP56skABcrObvutNUZSWnrnPCg1OTKE="
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
sessionItemSelector,
|
||||
dropdownMenuTriggerSelector,
|
||||
dropdownMenuContentSelector,
|
||||
sessionHeaderSelector,
|
||||
projectMenuTriggerSelector,
|
||||
projectWorkspacesToggleSelector,
|
||||
titlebarRightSelector,
|
||||
@@ -225,9 +226,9 @@ export async function hoverSessionItem(page: Page, sessionID: string) {
|
||||
export async function openSessionMoreMenu(page: Page, sessionID: string) {
|
||||
await expect(page).toHaveURL(new RegExp(`/session/${sessionID}(?:[/?#]|$)`))
|
||||
|
||||
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(sessionHeaderSelector).first()
|
||||
await expect(header).toBeVisible()
|
||||
await expect(header.getByRole("heading", { level: 1 }).first()).toBeVisible({ timeout: 30_000 })
|
||||
|
||||
const menu = page
|
||||
.locator(dropdownMenuContentSelector)
|
||||
@@ -243,7 +244,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()
|
||||
|
||||
@@ -441,6 +442,57 @@ export async function seedSessionPermission(
|
||||
return { id: result.id }
|
||||
}
|
||||
|
||||
export async function seedSessionTask(
|
||||
sdk: ReturnType<typeof createSdk>,
|
||||
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<typeof createSdk>,
|
||||
input: {
|
||||
|
||||
@@ -53,6 +53,8 @@ export const dropdownMenuContentSelector = '[data-component="dropdown-menu-conte
|
||||
|
||||
export const inlineInputSelector = '[data-component="inline-input"]'
|
||||
|
||||
export const sessionHeaderSelector = "[data-session-title]"
|
||||
|
||||
export const sessionItemSelector = (sessionID: string) => `${sidebarNavSelector} [data-session-id="${sessionID}"]`
|
||||
|
||||
export const workspaceItemSelector = (slug: string) =>
|
||||
|
||||
37
packages/app/e2e/session/session-child-navigation.spec.ts
Normal file
37
packages/app/e2e/session/session-child-navigation.spec.ts
Normal file
@@ -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)
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
openSharePopover,
|
||||
withSession,
|
||||
} from "../actions"
|
||||
import { sessionItemSelector, inlineInputSelector } from "../selectors"
|
||||
import { sessionHeaderSelector, sessionItemSelector, inlineInputSelector } from "../selectors"
|
||||
|
||||
const shareDisabled = process.env.OPENCODE_DISABLE_SHARE === "true" || process.env.OPENCODE_DISABLE_SHARE === "1"
|
||||
|
||||
@@ -44,7 +44,7 @@ test("session can be renamed via header menu", async ({ page, sdk, gotoSession }
|
||||
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(sessionHeaderSelector).locator(inlineInputSelector).first()
|
||||
await expect(input).toBeVisible()
|
||||
await expect(input).toBeFocused()
|
||||
await input.fill(renamedTitle)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/app",
|
||||
"version": "1.2.19",
|
||||
"version": "1.2.20",
|
||||
"description": "",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
|
||||
@@ -256,7 +256,17 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
pendingAutoAccept: false,
|
||||
})
|
||||
|
||||
const buttonsSpring = useSpring(() => (store.mode === "normal" ? 1 : 0), { visualDuration: 0.2, bounce: 0 })
|
||||
const buttonsSpring = useSpring(
|
||||
() => (store.mode === "normal" ? 1 : 0),
|
||||
{ visualDuration: 0.2, bounce: 0 },
|
||||
)
|
||||
|
||||
const springFade = (t: number): Record<string, string> => ({
|
||||
opacity: `${t}`,
|
||||
transform: `scale(${0.95 + t * 0.05})`,
|
||||
filter: `blur(${(1 - t) * 2}px)`,
|
||||
"pointer-events": t > 0.5 ? "auto" : "none",
|
||||
})
|
||||
|
||||
const commentCount = createMemo(() => {
|
||||
if (store.mode === "shell") return 0
|
||||
@@ -1211,9 +1221,9 @@ export const PromptInput: Component<PromptInputProps> = (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)}
|
||||
@@ -1254,9 +1264,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
<div
|
||||
aria-hidden={store.mode !== "normal"}
|
||||
class="flex items-center gap-1"
|
||||
style={{
|
||||
"pointer-events": buttonsSpring() > 0.5 ? "auto" : "none",
|
||||
}}
|
||||
style={{ "pointer-events": buttonsSpring() > 0.5 ? "auto" : "none" }}
|
||||
>
|
||||
<TooltipKeybind
|
||||
placement="top"
|
||||
@@ -1268,11 +1276,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
type="button"
|
||||
variant="ghost"
|
||||
class="size-8 p-0"
|
||||
style={{
|
||||
opacity: buttonsSpring(),
|
||||
transform: `scale(${0.95 + buttonsSpring() * 0.05})`,
|
||||
filter: `blur(${(1 - buttonsSpring()) * 2}px)`,
|
||||
}}
|
||||
style={springFade(buttonsSpring())}
|
||||
onClick={pick}
|
||||
disabled={store.mode !== "normal"}
|
||||
tabIndex={store.mode === "normal" ? undefined : -1}
|
||||
@@ -1310,11 +1314,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
icon={working() ? "stop" : "arrow-up"}
|
||||
variant="primary"
|
||||
class="size-8"
|
||||
style={{
|
||||
opacity: buttonsSpring(),
|
||||
transform: `scale(${0.95 + buttonsSpring() * 0.05})`,
|
||||
filter: `blur(${(1 - buttonsSpring()) * 2}px)`,
|
||||
}}
|
||||
style={springFade(buttonsSpring())}
|
||||
aria-label={working() ? language.t("prompt.action.stop") : language.t("prompt.action.send")}
|
||||
/>
|
||||
</Tooltip>
|
||||
@@ -1370,13 +1370,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
<div class="flex items-center gap-1.5 min-w-0 flex-1 relative">
|
||||
<div
|
||||
class="h-7 flex items-center gap-1.5 max-w-[160px] min-w-0 absolute inset-y-0 left-0"
|
||||
style={{
|
||||
padding: "0 4px 0 8px",
|
||||
opacity: 1 - buttonsSpring(),
|
||||
transform: `scale(${0.95 + (1 - buttonsSpring()) * 0.05})`,
|
||||
filter: `blur(${buttonsSpring() * 2}px)`,
|
||||
"pointer-events": buttonsSpring() < 0.5 ? "auto" : "none",
|
||||
}}
|
||||
style={{ padding: "0 4px 0 8px", ...springFade(1 - buttonsSpring()) }}
|
||||
>
|
||||
<span class="truncate text-13-medium text-text-strong">{language.t("prompt.mode.shell")}</span>
|
||||
<div class="size-4 shrink-0" />
|
||||
@@ -1395,13 +1389,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
onSelect={local.agent.set}
|
||||
class="capitalize max-w-[160px]"
|
||||
valueClass="truncate text-13-regular"
|
||||
triggerStyle={{
|
||||
height: "28px",
|
||||
opacity: buttonsSpring(),
|
||||
transform: `scale(${0.95 + buttonsSpring() * 0.05})`,
|
||||
filter: `blur(${(1 - buttonsSpring()) * 2}px)`,
|
||||
"pointer-events": buttonsSpring() > 0.5 ? "auto" : "none",
|
||||
}}
|
||||
triggerStyle={{ height: "28px", ...springFade(buttonsSpring()) }}
|
||||
variant="ghost"
|
||||
/>
|
||||
</TooltipKeybind>
|
||||
@@ -1419,13 +1407,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
variant="ghost"
|
||||
size="normal"
|
||||
class="min-w-0 max-w-[320px] text-13-regular group"
|
||||
style={{
|
||||
height: "28px",
|
||||
opacity: buttonsSpring(),
|
||||
transform: `scale(${0.95 + buttonsSpring() * 0.05})`,
|
||||
filter: `blur(${(1 - buttonsSpring()) * 2}px)`,
|
||||
"pointer-events": buttonsSpring() > 0.5 ? "auto" : "none",
|
||||
}}
|
||||
style={{ height: "28px", ...springFade(buttonsSpring()) }}
|
||||
onClick={() => dialog.show(() => <DialogSelectModelUnpaid />)}
|
||||
>
|
||||
<Show when={local.model.current()?.provider?.id}>
|
||||
@@ -1454,13 +1436,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
triggerProps={{
|
||||
variant: "ghost",
|
||||
size: "normal",
|
||||
style: {
|
||||
height: "28px",
|
||||
opacity: buttonsSpring(),
|
||||
transform: `scale(${0.95 + buttonsSpring() * 0.05})`,
|
||||
filter: `blur(${(1 - buttonsSpring()) * 2}px)`,
|
||||
"pointer-events": buttonsSpring() > 0.5 ? "auto" : "none",
|
||||
},
|
||||
style: { height: "28px", ...springFade(buttonsSpring()) },
|
||||
class: "min-w-0 max-w-[320px] text-13-regular group",
|
||||
}}
|
||||
>
|
||||
@@ -1492,13 +1468,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
onSelect={(x) => local.model.variant.set(x === "default" ? undefined : x)}
|
||||
class="capitalize max-w-[160px]"
|
||||
valueClass="truncate text-13-regular"
|
||||
triggerStyle={{
|
||||
height: "28px",
|
||||
opacity: buttonsSpring(),
|
||||
transform: `scale(${0.95 + buttonsSpring() * 0.05})`,
|
||||
filter: `blur(${(1 - buttonsSpring()) * 2}px)`,
|
||||
"pointer-events": buttonsSpring() > 0.5 ? "auto" : "none",
|
||||
}}
|
||||
triggerStyle={{ height: "28px", ...springFade(buttonsSpring()) }}
|
||||
variant="ghost"
|
||||
/>
|
||||
</TooltipKeybind>
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import type { Message } from "@opencode-ai/sdk/v2/client"
|
||||
import { showToast } from "@opencode-ai/ui/toast"
|
||||
import { base64Encode } from "@opencode-ai/util/encode"
|
||||
import { errorMessage } from "@/pages/layout/helpers"
|
||||
import { useNavigate, useParams } from "@solidjs/router"
|
||||
import type { Accessor } from "solid-js"
|
||||
import { batch, type Accessor } from "solid-js"
|
||||
import type { FileSelection } from "@/context/file"
|
||||
import { useGlobalSync } from "@/context/global-sync"
|
||||
import { useLanguage } from "@/context/language"
|
||||
@@ -65,14 +66,7 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
||||
const language = useLanguage()
|
||||
const params = useParams()
|
||||
|
||||
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")
|
||||
}
|
||||
const toastError = (err: unknown) => errorMessage(err, language.t("common.requestFailed"))
|
||||
|
||||
const abort = async () => {
|
||||
const sessionID = params.id
|
||||
@@ -158,7 +152,7 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
||||
.catch((err) => {
|
||||
showToast({
|
||||
title: language.t("prompt.toast.worktreeCreateFailed.title"),
|
||||
description: errorMessage(err),
|
||||
description: toastError(err),
|
||||
})
|
||||
return undefined
|
||||
})
|
||||
@@ -197,7 +191,7 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
||||
.catch((err) => {
|
||||
showToast({
|
||||
title: language.t("prompt.toast.sessionCreateFailed.title"),
|
||||
description: errorMessage(err),
|
||||
description: toastError(err),
|
||||
})
|
||||
return undefined
|
||||
})
|
||||
@@ -255,7 +249,7 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
||||
.catch((err) => {
|
||||
showToast({
|
||||
title: language.t("prompt.toast.shellSendFailed.title"),
|
||||
description: errorMessage(err),
|
||||
description: toastError(err),
|
||||
})
|
||||
restoreInput()
|
||||
})
|
||||
@@ -333,9 +327,14 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
||||
messageID,
|
||||
})
|
||||
|
||||
removeCommentItems(commentItems)
|
||||
clearInput()
|
||||
addOptimisticMessage()
|
||||
batch(() => {
|
||||
removeCommentItems(commentItems)
|
||||
clearInput()
|
||||
if (sessionDirectory === projectDirectory) {
|
||||
sync.set("session_status", session.id, { type: "busy" })
|
||||
}
|
||||
addOptimisticMessage()
|
||||
})
|
||||
|
||||
const waitForWorktree = async () => {
|
||||
const worktree = WorktreeState.get(sessionDirectory)
|
||||
@@ -412,7 +411,7 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
||||
}
|
||||
showToast({
|
||||
title: language.t("prompt.toast.promptSendFailed.title"),
|
||||
description: errorMessage(err),
|
||||
description: toastError(err),
|
||||
})
|
||||
removeOptimisticMessage()
|
||||
restoreCommentItems(commentItems)
|
||||
|
||||
@@ -4,8 +4,7 @@ import { useParams } from "@solidjs/router"
|
||||
import { useSync } from "@/context/sync"
|
||||
import { useLayout } from "@/context/layout"
|
||||
import { checksum } from "@opencode-ai/util/encode"
|
||||
import { findLast } from "@opencode-ai/util/array"
|
||||
import { same } from "@/utils/same"
|
||||
import { findLast, same } from "@opencode-ai/util/array"
|
||||
import { Icon } from "@opencode-ai/ui/icon"
|
||||
import { Accordion } from "@opencode-ai/ui/accordion"
|
||||
import { StickyAccordionHeader } from "@opencode-ai/ui/sticky-accordion-header"
|
||||
|
||||
@@ -288,29 +288,6 @@ export const SettingsGeneral: Component = () => {
|
||||
</div>
|
||||
</SettingsRow>
|
||||
|
||||
<SettingsRow
|
||||
title={language.t("settings.general.row.shellToolPartsExpanded.title")}
|
||||
description={language.t("settings.general.row.shellToolPartsExpanded.description")}
|
||||
>
|
||||
<div data-action="settings-feed-shell-tool-parts-expanded">
|
||||
<Switch
|
||||
checked={settings.general.shellToolPartsExpanded()}
|
||||
onChange={(checked) => settings.general.setShellToolPartsExpanded(checked)}
|
||||
/>
|
||||
</div>
|
||||
</SettingsRow>
|
||||
|
||||
<SettingsRow
|
||||
title={language.t("settings.general.row.editToolPartsExpanded.title")}
|
||||
description={language.t("settings.general.row.editToolPartsExpanded.description")}
|
||||
>
|
||||
<div data-action="settings-feed-edit-tool-parts-expanded">
|
||||
<Switch
|
||||
checked={settings.general.editToolPartsExpanded()}
|
||||
onChange={(checked) => settings.general.setEditToolPartsExpanded(checked)}
|
||||
/>
|
||||
</div>
|
||||
</SettingsRow>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -4,6 +4,7 @@ import { createSimpleContext } from "@opencode-ai/ui/context"
|
||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { useSettings } from "@/context/settings"
|
||||
import { isEditableTarget } from "@/utils/dom"
|
||||
import { Persist, persisted } from "@/utils/persist"
|
||||
|
||||
const IS_MAC = typeof navigator === "object" && /(Mac|iPod|iPhone|iPad)/.test(navigator.platform)
|
||||
@@ -177,14 +178,6 @@ export function formatKeybind(config: string): string {
|
||||
return IS_MAC ? parts.join("") : parts.join("+")
|
||||
}
|
||||
|
||||
function isEditableTarget(target: EventTarget | null) {
|
||||
if (!(target instanceof HTMLElement)) return false
|
||||
if (target.isContentEditable) return true
|
||||
if (target.closest("[contenteditable='true']")) return true
|
||||
if (target.closest("input, textarea, select")) return true
|
||||
return false
|
||||
}
|
||||
|
||||
export const { use: useCommand, provider: CommandProvider } = createSimpleContext({
|
||||
name: "Command",
|
||||
init: () => {
|
||||
|
||||
@@ -353,7 +353,6 @@ function createGlobalSync() {
|
||||
.update({ config })
|
||||
.then(bootstrap)
|
||||
.then(() => {
|
||||
queue.refresh()
|
||||
setGlobalStore("reload", undefined)
|
||||
queue.refresh()
|
||||
})
|
||||
|
||||
@@ -8,7 +8,7 @@ import { usePlatform } from "./platform"
|
||||
import { Project } from "@opencode-ai/sdk/v2"
|
||||
import { Persist, persisted, removePersisted } from "@/utils/persist"
|
||||
import { decode64 } from "@/utils/base64"
|
||||
import { same } from "@/utils/same"
|
||||
import { same } from "@opencode-ai/util/array"
|
||||
import { createScrollPersistence, type SessionScroll } from "./layout-scroll"
|
||||
import { createPathHelpers } from "./file/path"
|
||||
|
||||
|
||||
@@ -23,8 +23,6 @@ export interface Settings {
|
||||
autoSave: boolean
|
||||
releaseNotes: boolean
|
||||
showReasoningSummaries: boolean
|
||||
shellToolPartsExpanded: boolean
|
||||
editToolPartsExpanded: boolean
|
||||
}
|
||||
updates: {
|
||||
startup: boolean
|
||||
@@ -46,8 +44,6 @@ const defaultSettings: Settings = {
|
||||
autoSave: true,
|
||||
releaseNotes: true,
|
||||
showReasoningSummaries: false,
|
||||
shellToolPartsExpanded: true,
|
||||
editToolPartsExpanded: false,
|
||||
},
|
||||
updates: {
|
||||
startup: true,
|
||||
@@ -133,20 +129,6 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont
|
||||
setShowReasoningSummaries(value: boolean) {
|
||||
setStore("general", "showReasoningSummaries", value)
|
||||
},
|
||||
shellToolPartsExpanded: withFallback(
|
||||
() => store.general?.shellToolPartsExpanded,
|
||||
defaultSettings.general.shellToolPartsExpanded,
|
||||
),
|
||||
setShellToolPartsExpanded(value: boolean) {
|
||||
setStore("general", "shellToolPartsExpanded", value)
|
||||
},
|
||||
editToolPartsExpanded: withFallback(
|
||||
() => store.general?.editToolPartsExpanded,
|
||||
defaultSettings.general.editToolPartsExpanded,
|
||||
),
|
||||
setEditToolPartsExpanded(value: boolean) {
|
||||
setStore("general", "editToolPartsExpanded", value)
|
||||
},
|
||||
},
|
||||
updates: {
|
||||
startup: withFallback(() => store.updates?.startup, defaultSettings.updates.startup),
|
||||
|
||||
@@ -541,12 +541,6 @@ export const dict = {
|
||||
"settings.general.row.theme.description": "تخصيص سمة OpenCode.",
|
||||
"settings.general.row.font.title": "الخط",
|
||||
"settings.general.row.font.description": "تخصيص الخط الأحادي المستخدم في كتل التعليمات البرمجية",
|
||||
"settings.general.row.shellToolPartsExpanded.title": "توسيع أجزاء أداة shell",
|
||||
"settings.general.row.shellToolPartsExpanded.description":
|
||||
"إظهار أجزاء أداة shell موسعة بشكل افتراضي في الشريط الزمني",
|
||||
"settings.general.row.editToolPartsExpanded.title": "توسيع أجزاء أداة edit",
|
||||
"settings.general.row.editToolPartsExpanded.description":
|
||||
"إظهار أجزاء أدوات edit و write و patch موسعة بشكل افتراضي في الشريط الزمني",
|
||||
"settings.general.row.wayland.title": "استخدام Wayland الأصلي",
|
||||
"settings.general.row.wayland.description": "تعطيل التراجع إلى X11 على Wayland. يتطلب إعادة التشغيل.",
|
||||
"settings.general.row.wayland.tooltip":
|
||||
|
||||
@@ -547,12 +547,6 @@ export const dict = {
|
||||
"settings.general.row.theme.description": "Personalize como o OpenCode é tematizado.",
|
||||
"settings.general.row.font.title": "Fonte",
|
||||
"settings.general.row.font.description": "Personalize a fonte monoespaçada usada em blocos de código",
|
||||
"settings.general.row.shellToolPartsExpanded.title": "Expandir partes da ferramenta shell",
|
||||
"settings.general.row.shellToolPartsExpanded.description":
|
||||
"Mostrar partes da ferramenta shell expandidas por padrão na linha do tempo",
|
||||
"settings.general.row.editToolPartsExpanded.title": "Expandir partes da ferramenta de edição",
|
||||
"settings.general.row.editToolPartsExpanded.description":
|
||||
"Mostrar partes das ferramentas de edição, escrita e patch expandidas por padrão na linha do tempo",
|
||||
"settings.general.row.wayland.title": "Usar Wayland nativo",
|
||||
"settings.general.row.wayland.description": "Desabilitar fallback X11 no Wayland. Requer reinicialização.",
|
||||
"settings.general.row.wayland.tooltip":
|
||||
|
||||
@@ -613,12 +613,6 @@ export const dict = {
|
||||
"settings.general.row.font.title": "Font",
|
||||
"settings.general.row.font.description": "Prilagodi monospace font koji se koristi u blokovima koda",
|
||||
|
||||
"settings.general.row.shellToolPartsExpanded.title": "Proširi dijelove shell alata",
|
||||
"settings.general.row.shellToolPartsExpanded.description":
|
||||
"Prikaži dijelove shell alata podrazumijevano proširene na vremenskoj traci",
|
||||
"settings.general.row.editToolPartsExpanded.title": "Proširi dijelove alata za uređivanje",
|
||||
"settings.general.row.editToolPartsExpanded.description":
|
||||
"Prikaži dijelove alata za uređivanje, pisanje i patch podrazumijevano proširene na vremenskoj traci",
|
||||
"settings.general.row.wayland.title": "Koristi nativni Wayland",
|
||||
"settings.general.row.wayland.description": "Onemogući X11 fallback na Waylandu. Zahtijeva restart.",
|
||||
"settings.general.row.wayland.tooltip":
|
||||
|
||||
@@ -608,11 +608,6 @@ export const dict = {
|
||||
"settings.general.row.font.title": "Skrifttype",
|
||||
"settings.general.row.font.description": "Tilpas mono-skrifttypen brugt i kodeblokke",
|
||||
|
||||
"settings.general.row.shellToolPartsExpanded.title": "Udvid shell-værktøjsdele",
|
||||
"settings.general.row.shellToolPartsExpanded.description": "Vis shell-værktøjsdele udvidet som standard i tidslinjen",
|
||||
"settings.general.row.editToolPartsExpanded.title": "Udvid edit-værktøjsdele",
|
||||
"settings.general.row.editToolPartsExpanded.description":
|
||||
"Vis edit-, write- og patch-værktøjsdele udvidet som standard i tidslinjen",
|
||||
"settings.general.row.wayland.title": "Brug native Wayland",
|
||||
"settings.general.row.wayland.description": "Deaktiver X11-fallback på Wayland. Kræver genstart.",
|
||||
"settings.general.row.wayland.tooltip":
|
||||
|
||||
@@ -556,12 +556,6 @@ export const dict = {
|
||||
"settings.general.row.theme.description": "Das Thema von OpenCode anpassen.",
|
||||
"settings.general.row.font.title": "Schriftart",
|
||||
"settings.general.row.font.description": "Die in Codeblöcken verwendete Monospace-Schriftart anpassen",
|
||||
"settings.general.row.shellToolPartsExpanded.title": "Shell-Tool-Abschnitte ausklappen",
|
||||
"settings.general.row.shellToolPartsExpanded.description":
|
||||
"Shell-Tool-Abschnitte standardmäßig in der Timeline ausgeklappt anzeigen",
|
||||
"settings.general.row.editToolPartsExpanded.title": "Edit-Tool-Abschnitte ausklappen",
|
||||
"settings.general.row.editToolPartsExpanded.description":
|
||||
"Edit-, Write- und Patch-Tool-Abschnitte standardmäßig in der Timeline ausgeklappt anzeigen",
|
||||
"settings.general.row.wayland.title": "Natives Wayland verwenden",
|
||||
"settings.general.row.wayland.description": "X11-Fallback unter Wayland deaktivieren. Erfordert Neustart.",
|
||||
"settings.general.row.wayland.tooltip":
|
||||
|
||||
@@ -636,13 +636,6 @@ export const dict = {
|
||||
"settings.general.row.font.description": "Customise the mono font used in code blocks",
|
||||
"settings.general.row.reasoningSummaries.title": "Show reasoning summaries",
|
||||
"settings.general.row.reasoningSummaries.description": "Display model reasoning summaries in the timeline",
|
||||
"settings.general.row.shellToolPartsExpanded.title": "Expand shell tool parts",
|
||||
"settings.general.row.shellToolPartsExpanded.description":
|
||||
"Show shell tool parts expanded by default in the timeline",
|
||||
"settings.general.row.editToolPartsExpanded.title": "Expand edit tool parts",
|
||||
"settings.general.row.editToolPartsExpanded.description":
|
||||
"Show edit, write, and patch tool parts expanded by default in the timeline",
|
||||
|
||||
"settings.general.row.wayland.title": "Use native Wayland",
|
||||
"settings.general.row.wayland.description": "Disable X11 fallback on Wayland. Requires restart.",
|
||||
"settings.general.row.wayland.tooltip":
|
||||
|
||||
@@ -616,12 +616,6 @@ export const dict = {
|
||||
"settings.general.row.font.title": "Fuente",
|
||||
"settings.general.row.font.description": "Personaliza la fuente monoespaciada usada en bloques de código",
|
||||
|
||||
"settings.general.row.shellToolPartsExpanded.title": "Expandir partes de la herramienta shell",
|
||||
"settings.general.row.shellToolPartsExpanded.description":
|
||||
"Mostrar las partes de la herramienta shell expandidas por defecto en la línea de tiempo",
|
||||
"settings.general.row.editToolPartsExpanded.title": "Expandir partes de la herramienta de edición",
|
||||
"settings.general.row.editToolPartsExpanded.description":
|
||||
"Mostrar las partes de las herramientas de edición, escritura y parcheado expandidas por defecto en la línea de tiempo",
|
||||
"settings.general.row.wayland.title": "Usar Wayland nativo",
|
||||
"settings.general.row.wayland.description": "Deshabilitar fallback a X11 en Wayland. Requiere reinicio.",
|
||||
"settings.general.row.wayland.tooltip":
|
||||
|
||||
@@ -553,12 +553,6 @@ export const dict = {
|
||||
"settings.general.row.theme.description": "Personnaliser le thème d'OpenCode.",
|
||||
"settings.general.row.font.title": "Police",
|
||||
"settings.general.row.font.description": "Personnaliser la police mono utilisée dans les blocs de code",
|
||||
"settings.general.row.shellToolPartsExpanded.title": "Développer les parties de l'outil shell",
|
||||
"settings.general.row.shellToolPartsExpanded.description":
|
||||
"Afficher les parties de l'outil shell développées par défaut dans la chronologie",
|
||||
"settings.general.row.editToolPartsExpanded.title": "Développer les parties de l'outil edit",
|
||||
"settings.general.row.editToolPartsExpanded.description":
|
||||
"Afficher les parties des outils edit, write et patch développées par défaut dans la chronologie",
|
||||
"settings.general.row.wayland.title": "Utiliser Wayland natif",
|
||||
"settings.general.row.wayland.description": "Désactiver le repli X11 sur Wayland. Nécessite un redémarrage.",
|
||||
"settings.general.row.wayland.tooltip":
|
||||
|
||||
@@ -545,12 +545,6 @@ export const dict = {
|
||||
"settings.general.row.theme.description": "OpenCodeのテーマをカスタマイズします。",
|
||||
"settings.general.row.font.title": "フォント",
|
||||
"settings.general.row.font.description": "コードブロックで使用する等幅フォントをカスタマイズします",
|
||||
"settings.general.row.shellToolPartsExpanded.title": "shell ツールパーツを展開",
|
||||
"settings.general.row.shellToolPartsExpanded.description":
|
||||
"タイムラインで shell ツールパーツをデフォルトで展開して表示します",
|
||||
"settings.general.row.editToolPartsExpanded.title": "edit ツールパーツを展開",
|
||||
"settings.general.row.editToolPartsExpanded.description":
|
||||
"タイムラインで edit、write、patch ツールパーツをデフォルトで展開して表示します",
|
||||
"settings.general.row.wayland.title": "ネイティブWaylandを使用",
|
||||
"settings.general.row.wayland.description": "WaylandでのX11フォールバックを無効にします。再起動が必要です。",
|
||||
"settings.general.row.wayland.tooltip":
|
||||
|
||||
@@ -546,12 +546,6 @@ export const dict = {
|
||||
"settings.general.row.theme.description": "OpenCode 테마 사용자 지정",
|
||||
"settings.general.row.font.title": "글꼴",
|
||||
"settings.general.row.font.description": "코드 블록에 사용되는 고정폭 글꼴 사용자 지정",
|
||||
"settings.general.row.shellToolPartsExpanded.title": "shell 도구 파트 펼치기",
|
||||
"settings.general.row.shellToolPartsExpanded.description":
|
||||
"타임라인에서 기본적으로 shell 도구 파트를 펼친 상태로 표시합니다",
|
||||
"settings.general.row.editToolPartsExpanded.title": "edit 도구 파트 펼치기",
|
||||
"settings.general.row.editToolPartsExpanded.description":
|
||||
"타임라인에서 기본적으로 edit, write, patch 도구 파트를 펼친 상태로 표시합니다",
|
||||
"settings.general.row.wayland.title": "네이티브 Wayland 사용",
|
||||
"settings.general.row.wayland.description": "Wayland에서 X11 폴백을 비활성화합니다. 다시 시작해야 합니다.",
|
||||
"settings.general.row.wayland.tooltip":
|
||||
|
||||
@@ -616,11 +616,6 @@ export const dict = {
|
||||
"settings.general.row.font.title": "Skrift",
|
||||
"settings.general.row.font.description": "Tilpass mono-skriften som brukes i kodeblokker",
|
||||
|
||||
"settings.general.row.shellToolPartsExpanded.title": "Utvid shell-verktøydeler",
|
||||
"settings.general.row.shellToolPartsExpanded.description": "Vis shell-verktøydeler utvidet som standard i tidslinjen",
|
||||
"settings.general.row.editToolPartsExpanded.title": "Utvid edit-verktøydeler",
|
||||
"settings.general.row.editToolPartsExpanded.description":
|
||||
"Vis edit-, write- og patch-verktøydeler utvidet som standard i tidslinjen",
|
||||
"settings.general.row.wayland.title": "Bruk innebygd Wayland",
|
||||
"settings.general.row.wayland.description": "Deaktiver X11-fallback på Wayland. Krever omstart.",
|
||||
"settings.general.row.wayland.tooltip":
|
||||
|
||||
@@ -546,12 +546,6 @@ export const dict = {
|
||||
"settings.general.row.theme.description": "Dostosuj motyw OpenCode.",
|
||||
"settings.general.row.font.title": "Czcionka",
|
||||
"settings.general.row.font.description": "Dostosuj czcionkę mono używaną w blokach kodu",
|
||||
"settings.general.row.shellToolPartsExpanded.title": "Rozwijaj elementy narzędzia shell",
|
||||
"settings.general.row.shellToolPartsExpanded.description":
|
||||
"Domyślnie pokazuj rozwinięte elementy narzędzia shell na osi czasu",
|
||||
"settings.general.row.editToolPartsExpanded.title": "Rozwijaj elementy narzędzia edit",
|
||||
"settings.general.row.editToolPartsExpanded.description":
|
||||
"Domyślnie pokazuj rozwinięte elementy narzędzi edit, write i patch na osi czasu",
|
||||
"settings.general.row.wayland.title": "Użyj natywnego Wayland",
|
||||
"settings.general.row.wayland.description": "Wyłącz fallback X11 na Wayland. Wymaga restartu.",
|
||||
"settings.general.row.wayland.tooltip":
|
||||
|
||||
@@ -614,12 +614,6 @@ export const dict = {
|
||||
"settings.general.row.font.title": "Шрифт",
|
||||
"settings.general.row.font.description": "Настройте моноширинный шрифт для блоков кода",
|
||||
|
||||
"settings.general.row.shellToolPartsExpanded.title": "Разворачивать элементы инструмента shell",
|
||||
"settings.general.row.shellToolPartsExpanded.description":
|
||||
"Показывать элементы инструмента shell в ленте развернутыми по умолчанию",
|
||||
"settings.general.row.editToolPartsExpanded.title": "Разворачивать элементы инструмента edit",
|
||||
"settings.general.row.editToolPartsExpanded.description":
|
||||
"Показывать элементы инструментов edit, write и patch в ленте развернутыми по умолчанию",
|
||||
"settings.general.row.wayland.title": "Использовать нативный Wayland",
|
||||
"settings.general.row.wayland.description": "Отключить X11 fallback на Wayland. Требуется перезапуск.",
|
||||
"settings.general.row.wayland.tooltip":
|
||||
|
||||
@@ -608,11 +608,6 @@ export const dict = {
|
||||
"settings.general.row.font.title": "ฟอนต์",
|
||||
"settings.general.row.font.description": "ปรับแต่งฟอนต์โมโนที่ใช้ในบล็อกโค้ด",
|
||||
|
||||
"settings.general.row.shellToolPartsExpanded.title": "ขยายส่วนเครื่องมือ shell",
|
||||
"settings.general.row.shellToolPartsExpanded.description": "แสดงส่วนเครื่องมือ shell แบบขยายตามค่าเริ่มต้นในไทม์ไลน์",
|
||||
"settings.general.row.editToolPartsExpanded.title": "ขยายส่วนเครื่องมือ edit",
|
||||
"settings.general.row.editToolPartsExpanded.description":
|
||||
"แสดงส่วนเครื่องมือ edit, write และ patch แบบขยายตามค่าเริ่มต้นในไทม์ไลน์",
|
||||
"settings.general.row.wayland.title": "ใช้ Wayland แบบเนทีฟ",
|
||||
"settings.general.row.wayland.description": "ปิดใช้งาน X11 fallback บน Wayland ต้องรีสตาร์ท",
|
||||
"settings.general.row.wayland.tooltip": "บน Linux ที่มีจอภาพรีเฟรชเรตแบบผสม Wayland แบบเนทีฟอาจเสถียรกว่า",
|
||||
|
||||
@@ -623,13 +623,6 @@ export const dict = {
|
||||
"settings.general.row.font.description": "Kod bloklarında kullanılan monospace yazı tipini özelleştirin",
|
||||
"settings.general.row.reasoningSummaries.title": "Akıl yürütme özetlerini göster",
|
||||
"settings.general.row.reasoningSummaries.description": "Zaman çizelgesinde model akıl yürütme özetlerini görüntüle",
|
||||
"settings.general.row.shellToolPartsExpanded.title": "Kabuk araç bileşenlerini genişlet",
|
||||
"settings.general.row.shellToolPartsExpanded.description":
|
||||
"Zaman çizelgesinde kabuk araç bileşenlerini varsayılan olarak genişletilmiş göster",
|
||||
"settings.general.row.editToolPartsExpanded.title": "Düzenleme araç bileşenlerini genişlet",
|
||||
"settings.general.row.editToolPartsExpanded.description":
|
||||
"Zaman çizelgesinde düzenleme, yazma ve yama araç bileşenlerini varsayılan olarak genişletilmiş göster",
|
||||
|
||||
"settings.general.row.wayland.title": "Yerel Wayland kullan",
|
||||
"settings.general.row.wayland.description":
|
||||
"Wayland'da X11 geri dönüşünü devre dışı bırak. Yeniden başlatma gerektirir.",
|
||||
|
||||
@@ -607,10 +607,6 @@ export const dict = {
|
||||
"settings.general.row.theme.description": "自定义 OpenCode 的主题。",
|
||||
"settings.general.row.font.title": "字体",
|
||||
"settings.general.row.font.description": "自定义代码块使用的等宽字体",
|
||||
"settings.general.row.shellToolPartsExpanded.title": "展开 shell 工具部分",
|
||||
"settings.general.row.shellToolPartsExpanded.description": "默认在时间线中展开 shell 工具部分",
|
||||
"settings.general.row.editToolPartsExpanded.title": "展开编辑工具部分",
|
||||
"settings.general.row.editToolPartsExpanded.description": "默认在时间线中展开 edit、write 和 patch 工具部分",
|
||||
"settings.general.row.wayland.title": "使用原生 Wayland",
|
||||
"settings.general.row.wayland.description": "在 Wayland 上禁用 X11 回退。需要重启。",
|
||||
"settings.general.row.wayland.tooltip": "在混合刷新率显示器的 Linux 系统上,原生 Wayland 可能更稳定。",
|
||||
|
||||
@@ -603,10 +603,6 @@ export const dict = {
|
||||
"settings.general.row.font.title": "字型",
|
||||
"settings.general.row.font.description": "自訂程式碼區塊使用的等寬字型",
|
||||
|
||||
"settings.general.row.shellToolPartsExpanded.title": "展開 shell 工具區塊",
|
||||
"settings.general.row.shellToolPartsExpanded.description": "在時間軸中預設展開 shell 工具區塊",
|
||||
"settings.general.row.editToolPartsExpanded.title": "展開 edit 工具區塊",
|
||||
"settings.general.row.editToolPartsExpanded.description": "在時間軸中預設展開 edit、write 和 patch 工具區塊",
|
||||
"settings.general.row.wayland.title": "使用原生 Wayland",
|
||||
"settings.general.row.wayland.description": "在 Wayland 上停用 X11 後備模式。需要重新啟動。",
|
||||
"settings.general.row.wayland.tooltip": "在混合更新率螢幕的 Linux 系統上,原生 Wayland 可能更穩定。",
|
||||
|
||||
@@ -22,7 +22,7 @@ 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 { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip"
|
||||
import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu"
|
||||
import { Dialog } from "@opencode-ai/ui/dialog"
|
||||
import { getFilename } from "@opencode-ai/util/path"
|
||||
@@ -1937,14 +1937,20 @@ export default function Layout(props: ParentProps) {
|
||||
fallback={
|
||||
<>
|
||||
<div class="shrink-0 py-4 px-3">
|
||||
<Button
|
||||
size="large"
|
||||
icon="plus-small"
|
||||
class="w-full"
|
||||
onClick={() => navigateWithSidebarReset(`/${base64Encode(p.worktree)}/session`)}
|
||||
<TooltipKeybind
|
||||
title={language.t("command.session.new")}
|
||||
keybind={command.keybind("session.new")}
|
||||
placement="top"
|
||||
>
|
||||
{language.t("command.session.new")}
|
||||
</Button>
|
||||
<Button
|
||||
size="large"
|
||||
icon="plus-small"
|
||||
class="w-full"
|
||||
onClick={() => navigateWithSidebarReset(`/${base64Encode(p.worktree)}/session`)}
|
||||
>
|
||||
{language.t("command.session.new")}
|
||||
</Button>
|
||||
</TooltipKeybind>
|
||||
</div>
|
||||
<div class="flex-1 min-h-0">
|
||||
<LocalWorkspace
|
||||
@@ -1959,9 +1965,15 @@ export default function Layout(props: ParentProps) {
|
||||
>
|
||||
<>
|
||||
<div class="shrink-0 py-4 px-3">
|
||||
<Button size="large" icon="plus-small" class="w-full" onClick={() => createWorkspace(p)}>
|
||||
{language.t("workspace.new")}
|
||||
</Button>
|
||||
<TooltipKeybind
|
||||
title={language.t("workspace.new")}
|
||||
keybind={command.keybind("workspace.new")}
|
||||
placement="top"
|
||||
>
|
||||
<Button size="large" icon="plus-small" class="w-full" onClick={() => createWorkspace(p)}>
|
||||
{language.t("workspace.new")}
|
||||
</Button>
|
||||
</TooltipKeybind>
|
||||
</div>
|
||||
<div class="relative flex-1 min-h-0">
|
||||
<DragDropProvider
|
||||
|
||||
@@ -31,16 +31,17 @@ import { usePrompt } from "@/context/prompt"
|
||||
import { useSDK } from "@/context/sdk"
|
||||
import { useSync } from "@/context/sync"
|
||||
import { createSessionComposerState, SessionComposerRegion } from "@/pages/session/composer"
|
||||
import { same } from "@opencode-ai/util/array"
|
||||
import { isEditableTarget } from "@/utils/dom"
|
||||
import { createOpenReviewFile } from "@/pages/session/helpers"
|
||||
import { MessageTimeline } from "@/pages/session/message-timeline"
|
||||
import { type DiffStyle, SessionReviewTab, type SessionReviewTabProps } from "@/pages/session/review-tab"
|
||||
import { createScrollSpy } from "@/pages/session/scroll-spy"
|
||||
import { useSessionCommands } from "@/pages/session/use-session-commands"
|
||||
import { SessionMobileTabs } from "@/pages/session/session-mobile-tabs"
|
||||
import { SessionSidePanel } from "@/pages/session/session-side-panel"
|
||||
import { TerminalPanel } from "@/pages/session/terminal-panel"
|
||||
import { useSessionCommands } from "@/pages/session/use-session-commands"
|
||||
import { useSessionHashScroll } from "@/pages/session/use-session-hash-scroll"
|
||||
import { same } from "@/utils/same"
|
||||
|
||||
const emptyUserMessages: UserMessage[] = []
|
||||
|
||||
@@ -118,13 +119,13 @@ function createSessionHistoryWindow(input: SessionHistoryWindowInput) {
|
||||
return
|
||||
}
|
||||
const beforeTop = el.scrollTop
|
||||
const beforeHeight = el.scrollHeight
|
||||
fn()
|
||||
requestAnimationFrame(() => {
|
||||
const delta = el.scrollHeight - beforeHeight
|
||||
if (!delta) return
|
||||
el.scrollTop = beforeTop + delta
|
||||
})
|
||||
// SolidJS updates the DOM synchronously. Force reflow so the browser
|
||||
// processes the new layout, then restore scrollTop before paint.
|
||||
// With column-reverse + overflow-anchor:none the same scrollTop value
|
||||
// keeps the same distance from the bottom — no delta math needed.
|
||||
void el.scrollHeight
|
||||
el.scrollTop = beforeTop
|
||||
}
|
||||
|
||||
const backfillTurns = () => {
|
||||
@@ -207,7 +208,8 @@ function createSessionHistoryWindow(input: SessionHistoryWindowInput) {
|
||||
if (!input.userScrolled()) return
|
||||
const el = input.scroller()
|
||||
if (!el) return
|
||||
if (el.scrollTop >= turnScrollThreshold) return
|
||||
// With column-reverse, distance from top = scrollHeight - clientHeight + scrollTop
|
||||
if (el.scrollHeight - el.clientHeight + el.scrollTop >= turnScrollThreshold) return
|
||||
|
||||
const start = turnStart()
|
||||
if (start > 0) {
|
||||
@@ -285,7 +287,6 @@ export default function Page() {
|
||||
bottom: true,
|
||||
},
|
||||
})
|
||||
|
||||
const composer = createSessionComposerState()
|
||||
|
||||
const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`)
|
||||
@@ -430,20 +431,8 @@ export default function Page() {
|
||||
mobileTab: "session" as "session" | "changes",
|
||||
changes: "session" as "session" | "turn",
|
||||
newSessionWorktree: "main",
|
||||
deferRender: false,
|
||||
})
|
||||
|
||||
createComputed((prev) => {
|
||||
const key = sessionKey()
|
||||
if (key !== prev) {
|
||||
setStore("deferRender", true)
|
||||
requestAnimationFrame(() => {
|
||||
setTimeout(() => setStore("deferRender", false), 0)
|
||||
})
|
||||
}
|
||||
return key
|
||||
}, sessionKey())
|
||||
|
||||
const turnDiffs = createMemo(() => lastUserMessage()?.summary?.diffs ?? [])
|
||||
const reviewDiffs = createMemo(() => (store.changes === "session" ? diffs() : turnDiffs()))
|
||||
|
||||
@@ -454,11 +443,6 @@ export default function Page() {
|
||||
return "main"
|
||||
})
|
||||
|
||||
const activeMessage = createMemo(() => {
|
||||
if (!store.messageId) return lastUserMessage()
|
||||
const found = visibleUserMessages()?.find((m) => m.id === store.messageId)
|
||||
return found ?? lastUserMessage()
|
||||
})
|
||||
const setActiveMessage = (message: UserMessage | undefined) => {
|
||||
setStore("messageId", message?.id)
|
||||
}
|
||||
@@ -620,11 +604,6 @@ export default function Page() {
|
||||
saveLabel: language.t("common.save"),
|
||||
}))
|
||||
|
||||
const isEditableTarget = (target: EventTarget | null | undefined) => {
|
||||
if (!(target instanceof HTMLElement)) return false
|
||||
return /^(INPUT|TEXTAREA|SELECT|BUTTON)$/.test(target.tagName) || target.isContentEditable
|
||||
}
|
||||
|
||||
const deepActiveElement = () => {
|
||||
let current: Element | null = document.activeElement
|
||||
while (current instanceof HTMLElement && current.shadowRoot?.activeElement) {
|
||||
@@ -755,12 +734,35 @@ export default function Page() {
|
||||
loadingClass: string
|
||||
emptyClass: string
|
||||
}) => (
|
||||
<Show when={!store.deferRender}>
|
||||
<Switch>
|
||||
<Match when={store.changes === "turn" && !!params.id}>
|
||||
<Switch>
|
||||
<Match when={store.changes === "turn" && !!params.id}>
|
||||
<SessionReviewTab
|
||||
title={changesTitle()}
|
||||
empty={emptyTurn()}
|
||||
diffs={reviewDiffs}
|
||||
view={view}
|
||||
diffStyle={input.diffStyle}
|
||||
onDiffStyleChange={input.onDiffStyleChange}
|
||||
onScrollRef={(el) => setTree("reviewScroll", el)}
|
||||
focusedFile={tree.activeDiff}
|
||||
onLineComment={(comment) => addCommentToContext({ ...comment, origin: "review" })}
|
||||
onLineCommentUpdate={updateCommentInContext}
|
||||
onLineCommentDelete={removeCommentFromContext}
|
||||
lineCommentActions={reviewCommentActions()}
|
||||
comments={comments.all()}
|
||||
focusedComment={comments.focus()}
|
||||
onFocusedCommentChange={comments.setFocus}
|
||||
onViewFile={openReviewFile}
|
||||
classes={input.classes}
|
||||
/>
|
||||
</Match>
|
||||
<Match when={hasReview()}>
|
||||
<Show
|
||||
when={diffsReady()}
|
||||
fallback={<div class={input.loadingClass}>{language.t("session.review.loadingChanges")}</div>}
|
||||
>
|
||||
<SessionReviewTab
|
||||
title={changesTitle()}
|
||||
empty={emptyTurn()}
|
||||
diffs={reviewDiffs}
|
||||
view={view}
|
||||
diffStyle={input.diffStyle}
|
||||
@@ -777,64 +779,39 @@ export default function Page() {
|
||||
onViewFile={openReviewFile}
|
||||
classes={input.classes}
|
||||
/>
|
||||
</Match>
|
||||
<Match when={hasReview()}>
|
||||
<Show
|
||||
when={diffsReady()}
|
||||
fallback={<div class={input.loadingClass}>{language.t("session.review.loadingChanges")}</div>}
|
||||
>
|
||||
<SessionReviewTab
|
||||
title={changesTitle()}
|
||||
diffs={reviewDiffs}
|
||||
view={view}
|
||||
diffStyle={input.diffStyle}
|
||||
onDiffStyleChange={input.onDiffStyleChange}
|
||||
onScrollRef={(el) => setTree("reviewScroll", el)}
|
||||
focusedFile={tree.activeDiff}
|
||||
onLineComment={(comment) => addCommentToContext({ ...comment, origin: "review" })}
|
||||
onLineCommentUpdate={updateCommentInContext}
|
||||
onLineCommentDelete={removeCommentFromContext}
|
||||
lineCommentActions={reviewCommentActions()}
|
||||
comments={comments.all()}
|
||||
focusedComment={comments.focus()}
|
||||
onFocusedCommentChange={comments.setFocus}
|
||||
onViewFile={openReviewFile}
|
||||
classes={input.classes}
|
||||
/>
|
||||
</Show>
|
||||
</Match>
|
||||
<Match when={true}>
|
||||
<SessionReviewTab
|
||||
title={changesTitle()}
|
||||
empty={
|
||||
store.changes === "turn" ? (
|
||||
emptyTurn()
|
||||
) : (
|
||||
<div class={input.emptyClass}>
|
||||
<Mark class="w-14 opacity-10" />
|
||||
<div class="text-14-regular text-text-weak max-w-56">{language.t(reviewEmptyKey())}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
diffs={reviewDiffs}
|
||||
view={view}
|
||||
diffStyle={input.diffStyle}
|
||||
onDiffStyleChange={input.onDiffStyleChange}
|
||||
onScrollRef={(el) => setTree("reviewScroll", el)}
|
||||
focusedFile={tree.activeDiff}
|
||||
onLineComment={(comment) => addCommentToContext({ ...comment, origin: "review" })}
|
||||
onLineCommentUpdate={updateCommentInContext}
|
||||
onLineCommentDelete={removeCommentFromContext}
|
||||
lineCommentActions={reviewCommentActions()}
|
||||
comments={comments.all()}
|
||||
focusedComment={comments.focus()}
|
||||
onFocusedCommentChange={comments.setFocus}
|
||||
onViewFile={openReviewFile}
|
||||
classes={input.classes}
|
||||
/>
|
||||
</Match>
|
||||
</Switch>
|
||||
</Show>
|
||||
</Show>
|
||||
</Match>
|
||||
<Match when={true}>
|
||||
<SessionReviewTab
|
||||
title={changesTitle()}
|
||||
empty={
|
||||
store.changes === "turn" ? (
|
||||
emptyTurn()
|
||||
) : (
|
||||
<div class={input.emptyClass}>
|
||||
<Mark class="w-14 opacity-10" />
|
||||
<div class="text-14-regular text-text-weak max-w-56">{language.t(reviewEmptyKey())}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
diffs={reviewDiffs}
|
||||
view={view}
|
||||
diffStyle={input.diffStyle}
|
||||
onDiffStyleChange={input.onDiffStyleChange}
|
||||
onScrollRef={(el) => setTree("reviewScroll", el)}
|
||||
focusedFile={tree.activeDiff}
|
||||
onLineComment={(comment) => addCommentToContext({ ...comment, origin: "review" })}
|
||||
onLineCommentUpdate={updateCommentInContext}
|
||||
onLineCommentDelete={removeCommentFromContext}
|
||||
lineCommentActions={reviewCommentActions()}
|
||||
comments={comments.all()}
|
||||
focusedComment={comments.focus()}
|
||||
onFocusedCommentChange={comments.setFocus}
|
||||
onViewFile={openReviewFile}
|
||||
classes={input.classes}
|
||||
/>
|
||||
</Match>
|
||||
</Switch>
|
||||
)
|
||||
|
||||
const reviewPanel = () => (
|
||||
@@ -1045,7 +1022,10 @@ 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
|
||||
// If auto-scroll is tracking the bottom, always report bottom: true
|
||||
// to prevent the scroll-down arrow from flashing during height animations
|
||||
// With column-reverse, scrollTop=0 is at the bottom
|
||||
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 +1048,7 @@ export default function Page() {
|
||||
|
||||
const resumeScroll = () => {
|
||||
setStore("messageId", undefined)
|
||||
autoScroll.forceScrollToBottom()
|
||||
autoScroll.smoothScrollToBottom()
|
||||
clearMessageHash()
|
||||
|
||||
const el = scroller
|
||||
@@ -1136,9 +1116,8 @@ 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
|
||||
// With column-reverse, near bottom = scrollTop near 0
|
||||
const stick = el ? Math.abs(el.scrollTop) < 10 + Math.max(0, delta) : false
|
||||
|
||||
dockHeight = next
|
||||
|
||||
@@ -1204,50 +1183,49 @@ export default function Page() {
|
||||
<div class="flex-1 min-h-0 overflow-hidden">
|
||||
<Switch>
|
||||
<Match when={params.id}>
|
||||
<Show when={activeMessage()}>
|
||||
<MessageTimeline
|
||||
mobileChanges={mobileChanges()}
|
||||
mobileFallback={reviewContent({
|
||||
diffStyle: "unified",
|
||||
classes: {
|
||||
root: "pb-8",
|
||||
header: "px-4",
|
||||
container: "px-4",
|
||||
},
|
||||
loadingClass: "px-4 py-4 text-text-weak",
|
||||
emptyClass: "h-full pb-30 flex flex-col items-center justify-center text-center gap-6",
|
||||
})}
|
||||
scroll={ui.scroll}
|
||||
onResumeScroll={resumeScroll}
|
||||
setScrollRef={setScrollRef}
|
||||
onScheduleScrollState={scheduleScrollState}
|
||||
onAutoScrollHandleScroll={autoScroll.handleScroll}
|
||||
onMarkScrollGesture={markScrollGesture}
|
||||
hasScrollGesture={hasScrollGesture}
|
||||
isDesktop={isDesktop()}
|
||||
onScrollSpyScroll={scrollSpy.onScroll}
|
||||
onTurnBackfillScroll={historyWindow.onScrollerScroll}
|
||||
onAutoScrollInteraction={autoScroll.handleInteraction}
|
||||
centered={centered()}
|
||||
setContentRef={(el) => {
|
||||
content = el
|
||||
autoScroll.contentRef(el)
|
||||
<MessageTimeline
|
||||
mobileChanges={mobileChanges()}
|
||||
mobileFallback={reviewContent({
|
||||
diffStyle: "unified",
|
||||
classes: {
|
||||
root: "pb-8",
|
||||
header: "px-4",
|
||||
container: "px-4",
|
||||
},
|
||||
loadingClass: "px-4 py-4 text-text-weak",
|
||||
emptyClass: "h-full pb-30 flex flex-col items-center justify-center text-center gap-6",
|
||||
})}
|
||||
scroll={ui.scroll}
|
||||
onResumeScroll={resumeScroll}
|
||||
setScrollRef={setScrollRef}
|
||||
onScheduleScrollState={scheduleScrollState}
|
||||
onAutoScrollHandleScroll={autoScroll.handleScroll}
|
||||
onMarkScrollGesture={markScrollGesture}
|
||||
hasScrollGesture={hasScrollGesture}
|
||||
isDesktop={isDesktop()}
|
||||
onScrollSpyScroll={scrollSpy.onScroll}
|
||||
onTurnBackfillScroll={historyWindow.onScrollerScroll}
|
||||
onAutoScrollInteraction={autoScroll.handleInteraction}
|
||||
onPreserveScrollAnchor={autoScroll.preserve}
|
||||
centered={centered()}
|
||||
setContentRef={(el) => {
|
||||
content = el
|
||||
autoScroll.contentRef(el)
|
||||
|
||||
const root = scroller
|
||||
if (root) scheduleScrollState(root)
|
||||
}}
|
||||
turnStart={historyWindow.turnStart()}
|
||||
historyMore={historyMore()}
|
||||
historyLoading={historyLoading()}
|
||||
onLoadEarlier={() => {
|
||||
void historyWindow.loadAndReveal()
|
||||
}}
|
||||
renderedUserMessages={historyWindow.renderedUserMessages()}
|
||||
anchor={anchor}
|
||||
onRegisterMessage={scrollSpy.register}
|
||||
onUnregisterMessage={scrollSpy.unregister}
|
||||
/>
|
||||
</Show>
|
||||
const root = scroller
|
||||
if (root) scheduleScrollState(root)
|
||||
}}
|
||||
turnStart={historyWindow.turnStart()}
|
||||
historyMore={historyMore()}
|
||||
historyLoading={historyLoading()}
|
||||
onLoadEarlier={() => {
|
||||
void historyWindow.loadAndReveal()
|
||||
}}
|
||||
renderedUserMessages={historyWindow.renderedUserMessages()}
|
||||
anchor={anchor}
|
||||
onRegisterMessage={scrollSpy.register}
|
||||
onUnregisterMessage={scrollSpy.unregister}
|
||||
/>
|
||||
</Match>
|
||||
<Match when={true}>
|
||||
<NewSessionView
|
||||
@@ -1273,7 +1251,6 @@ export default function Page() {
|
||||
|
||||
<SessionComposerRegion
|
||||
state={composer}
|
||||
ready={!store.deferRender && messagesReady()}
|
||||
centered={centered()}
|
||||
inputRef={(el) => {
|
||||
inputRef = el
|
||||
|
||||
@@ -1,3 +1,29 @@
|
||||
/**
|
||||
* Composer Island migration tracker
|
||||
*
|
||||
* Goal
|
||||
* - Replace the split composer stack (PromptInput + question/permission/todo docks)
|
||||
* with a single morphing ComposerIsland + app runtime adapter.
|
||||
*
|
||||
* Current status
|
||||
* - [x] Storybook prototype with morphing surfaces exists (`packages/ui/src/components/composer-island.tsx`).
|
||||
* - [ ] App still renders the existing production stack (`session-composer-region` + `prompt-input`).
|
||||
*
|
||||
* Feature parity checklist
|
||||
* - [ ] Submit pipeline parity (session/worktree/create, optimistic user message, abort, restore on error).
|
||||
* - [x] Runtime adapter API boundary in island (`runtime.submit`, `runtime.abort`, lookup, permission/question handlers).
|
||||
* - [x] Shell mode wiring parity (single mode source for tray + editor).
|
||||
* - [x] Cursor scroll-into-view behavior in island editor.
|
||||
* - [x] Attachment parity in island UI: image + PDF support and file picker wiring.
|
||||
* - [x] Drag/drop parity in island UI: attachment drop + `file:` text drop into @mention.
|
||||
* - [x] Keyboard parity in island UI: mod+u, ctrl+g, ctrl+n/ctrl+p, popover navigation.
|
||||
* - [x] Auto-accept + stop/send state parity in island UI.
|
||||
* - [x] Async @mention/slash sourcing parity in island via runtime search hooks.
|
||||
* - [ ] Context item parity (comment chips/open behavior is in progress; app comment wiring still pending).
|
||||
* - [ ] Question flow parity (island submit/reject hooks + sending locks done; app SDK/cache wiring pending).
|
||||
* - [ ] Permission flow parity (island responding lock done; app tool-description wiring pending).
|
||||
* - [x] Todo parity in island UI (in-progress pulse + auto-scroll active item).
|
||||
*/
|
||||
export { SessionComposerRegion } from "./session-composer-region"
|
||||
export { createSessionComposerBlocked, createSessionComposerState } from "./session-composer-state"
|
||||
export type { SessionComposerState } from "./session-composer-state"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Show, createEffect, createMemo, createSignal, onCleanup } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { Show, createMemo, createSignal, createEffect } from "solid-js"
|
||||
import { useParams } from "@solidjs/router"
|
||||
import { useSpring } from "@opencode-ai/ui/motion-spring"
|
||||
import { useElementHeight } from "@opencode-ai/ui/hooks"
|
||||
import { PromptInput } from "@/components/prompt-input"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { usePrompt } from "@/context/prompt"
|
||||
@@ -9,11 +9,12 @@ import { getSessionHandoff, setSessionHandoff } from "@/pages/session/handoff"
|
||||
import { SessionPermissionDock } from "@/pages/session/composer/session-permission-dock"
|
||||
import { SessionQuestionDock } from "@/pages/session/composer/session-question-dock"
|
||||
import type { SessionComposerState } from "@/pages/session/composer/session-composer-state"
|
||||
import { SessionTodoDock } from "@/pages/session/composer/session-todo-dock"
|
||||
import { SessionTodoDock, COLLAPSED_HEIGHT } from "@/pages/session/composer/session-todo-dock"
|
||||
|
||||
const DOCK_SPRING = { visualDuration: 0.3, bounce: 0 }
|
||||
|
||||
export function SessionComposerRegion(props: {
|
||||
state: SessionComposerState
|
||||
ready: boolean
|
||||
centered: boolean
|
||||
inputRef: (el: HTMLDivElement) => void
|
||||
newSessionWorktree: string
|
||||
@@ -21,23 +22,6 @@ export function SessionComposerRegion(props: {
|
||||
onSubmit: () => void
|
||||
onResponseSubmit: () => void
|
||||
setPromptDockRef: (el: HTMLDivElement) => void
|
||||
visualDuration?: number
|
||||
bounce?: number
|
||||
dockOpenVisualDuration?: number
|
||||
dockOpenBounce?: number
|
||||
dockCloseVisualDuration?: number
|
||||
dockCloseBounce?: number
|
||||
drawerExpandVisualDuration?: number
|
||||
drawerExpandBounce?: number
|
||||
drawerCollapseVisualDuration?: number
|
||||
drawerCollapseBounce?: number
|
||||
subtitleDuration?: number
|
||||
subtitleTravel?: number
|
||||
subtitleEdge?: number
|
||||
countDuration?: number
|
||||
countMask?: number
|
||||
countMaskHeight?: number
|
||||
countWidthDuration?: number
|
||||
}) {
|
||||
const params = useParams()
|
||||
const prompt = usePrompt()
|
||||
@@ -63,73 +47,15 @@ export function SessionComposerRegion(props: {
|
||||
setSessionHandoff(sessionKey(), { prompt: previewPrompt() })
|
||||
})
|
||||
|
||||
const [gate, setGate] = createStore({
|
||||
ready: false,
|
||||
})
|
||||
let timer: number | undefined
|
||||
let frame: number | undefined
|
||||
|
||||
const clear = () => {
|
||||
if (timer !== undefined) {
|
||||
window.clearTimeout(timer)
|
||||
timer = undefined
|
||||
}
|
||||
if (frame !== undefined) {
|
||||
cancelAnimationFrame(frame)
|
||||
frame = undefined
|
||||
}
|
||||
}
|
||||
|
||||
createEffect(() => {
|
||||
sessionKey()
|
||||
const ready = props.ready
|
||||
const delay = 140
|
||||
|
||||
clear()
|
||||
setGate("ready", false)
|
||||
if (!ready) return
|
||||
|
||||
frame = requestAnimationFrame(() => {
|
||||
frame = undefined
|
||||
timer = window.setTimeout(() => {
|
||||
setGate("ready", true)
|
||||
timer = undefined
|
||||
}, delay)
|
||||
})
|
||||
})
|
||||
|
||||
onCleanup(clear)
|
||||
|
||||
const open = createMemo(() => gate.ready && props.state.dock() && !props.state.closing())
|
||||
const config = createMemo(() =>
|
||||
open()
|
||||
? {
|
||||
visualDuration: props.dockOpenVisualDuration ?? props.visualDuration ?? 0.3,
|
||||
bounce: props.dockOpenBounce ?? props.bounce ?? 0,
|
||||
}
|
||||
: {
|
||||
visualDuration: props.dockCloseVisualDuration ?? props.visualDuration ?? 0.3,
|
||||
bounce: props.dockCloseBounce ?? props.bounce ?? 0,
|
||||
},
|
||||
const open = createMemo(() => props.state.dock() && !props.state.closing())
|
||||
const progress = useSpring(
|
||||
() => (open() ? 1 : 0),
|
||||
DOCK_SPRING,
|
||||
)
|
||||
const progress = useSpring(() => (open() ? 1 : 0), config)
|
||||
const value = createMemo(() => Math.max(0, Math.min(1, progress())))
|
||||
const [height, setHeight] = createSignal(320)
|
||||
const dock = createMemo(() => (gate.ready && props.state.dock()) || value() > 0.001)
|
||||
const full = createMemo(() => Math.max(78, height()))
|
||||
const dock = createMemo(() => props.state.dock() || progress() > 0.001)
|
||||
const [contentRef, setContentRef] = createSignal<HTMLDivElement>()
|
||||
|
||||
createEffect(() => {
|
||||
const el = contentRef()
|
||||
if (!el) return
|
||||
const update = () => {
|
||||
setHeight(el.getBoundingClientRect().height)
|
||||
}
|
||||
update()
|
||||
const observer = new ResizeObserver(update)
|
||||
observer.observe(el)
|
||||
onCleanup(() => observer.disconnect())
|
||||
})
|
||||
const height = useElementHeight(contentRef, 320)
|
||||
const full = createMemo(() => Math.max(COLLAPSED_HEIGHT, height()))
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -179,10 +105,10 @@ export function SessionComposerRegion(props: {
|
||||
<div
|
||||
classList={{
|
||||
"overflow-hidden": true,
|
||||
"pointer-events-none": value() < 0.98,
|
||||
"pointer-events-none": progress() < 0.98,
|
||||
}}
|
||||
style={{
|
||||
"max-height": `${full() * value()}px`,
|
||||
"max-height": `${full() * progress()}px`,
|
||||
}}
|
||||
>
|
||||
<div ref={setContentRef}>
|
||||
@@ -191,20 +117,7 @@ export function SessionComposerRegion(props: {
|
||||
title={language.t("session.todo.title")}
|
||||
collapseLabel={language.t("session.todo.collapse")}
|
||||
expandLabel={language.t("session.todo.expand")}
|
||||
dockProgress={value()}
|
||||
visualDuration={props.visualDuration}
|
||||
bounce={props.bounce}
|
||||
expandVisualDuration={props.drawerExpandVisualDuration}
|
||||
expandBounce={props.drawerExpandBounce}
|
||||
collapseVisualDuration={props.drawerCollapseVisualDuration}
|
||||
collapseBounce={props.drawerCollapseBounce}
|
||||
subtitleDuration={props.subtitleDuration}
|
||||
subtitleTravel={props.subtitleTravel}
|
||||
subtitleEdge={props.subtitleEdge}
|
||||
countDuration={props.countDuration}
|
||||
countMask={props.countMask}
|
||||
countMaskHeight={props.countMaskHeight}
|
||||
countWidthDuration={props.countWidthDuration}
|
||||
dockProgress={progress()}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -214,7 +127,7 @@ export function SessionComposerRegion(props: {
|
||||
"relative z-10": true,
|
||||
}}
|
||||
style={{
|
||||
"margin-top": `${-36 * value()}px`,
|
||||
"margin-top": `${-36 * progress()}px`,
|
||||
}}
|
||||
>
|
||||
<PromptInput
|
||||
|
||||
@@ -29,7 +29,11 @@ export function createSessionComposerBlocked() {
|
||||
})
|
||||
}
|
||||
|
||||
export function createSessionComposerState(options?: { closeMs?: number | (() => number) }) {
|
||||
export function createSessionComposerState(
|
||||
options?: {
|
||||
closeMs?: number | (() => number)
|
||||
},
|
||||
) {
|
||||
const params = useParams()
|
||||
const sdk = useSDK()
|
||||
const sync = useSync()
|
||||
|
||||
@@ -6,9 +6,15 @@ import { IconButton } from "@opencode-ai/ui/icon-button"
|
||||
import { useSpring } from "@opencode-ai/ui/motion-spring"
|
||||
import { TextReveal } from "@opencode-ai/ui/text-reveal"
|
||||
import { TextStrikethrough } from "@opencode-ai/ui/text-strikethrough"
|
||||
import { useElementHeight } from "@opencode-ai/ui/hooks"
|
||||
import { Index, createEffect, createMemo, createSignal, on, onCleanup } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
|
||||
const COLLAPSE_SPRING = { visualDuration: 0.3, bounce: 0 }
|
||||
export const COLLAPSED_HEIGHT = 78
|
||||
const SUBTITLE = { duration: 600, travel: 25, edge: 17 }
|
||||
const COUNT = { duration: 600, mask: 18, maskHeight: 0, widthDuration: 560 }
|
||||
|
||||
function dot(status: Todo["status"]) {
|
||||
if (status !== "in_progress") return undefined
|
||||
return (
|
||||
@@ -40,19 +46,6 @@ export function SessionTodoDock(props: {
|
||||
collapseLabel: string
|
||||
expandLabel: string
|
||||
dockProgress?: number
|
||||
visualDuration?: number
|
||||
bounce?: number
|
||||
expandVisualDuration?: number
|
||||
expandBounce?: number
|
||||
collapseVisualDuration?: number
|
||||
collapseBounce?: number
|
||||
subtitleDuration?: number
|
||||
subtitleTravel?: number
|
||||
subtitleEdge?: number
|
||||
countDuration?: number
|
||||
countMask?: number
|
||||
countMaskHeight?: number
|
||||
countWidthDuration?: number
|
||||
}) {
|
||||
const [store, setStore] = createStore({
|
||||
collapsed: false,
|
||||
@@ -73,39 +66,12 @@ export function SessionTodoDock(props: {
|
||||
)
|
||||
|
||||
const preview = createMemo(() => active()?.content ?? "")
|
||||
const config = createMemo(() =>
|
||||
store.collapsed
|
||||
? {
|
||||
visualDuration: props.collapseVisualDuration ?? props.visualDuration ?? 0.3,
|
||||
bounce: props.collapseBounce ?? props.bounce ?? 0,
|
||||
}
|
||||
: {
|
||||
visualDuration: props.expandVisualDuration ?? props.visualDuration ?? 0.3,
|
||||
bounce: props.expandBounce ?? props.bounce ?? 0,
|
||||
},
|
||||
)
|
||||
const collapse = useSpring(() => (store.collapsed ? 1 : 0), config)
|
||||
const dock = createMemo(() => Math.max(0, Math.min(1, props.dockProgress ?? 1)))
|
||||
const shut = createMemo(() => 1 - dock())
|
||||
const value = createMemo(() => Math.max(0, Math.min(1, collapse())))
|
||||
const hide = createMemo(() => Math.max(value(), shut()))
|
||||
const off = createMemo(() => hide() > 0.98)
|
||||
const turn = createMemo(() => Math.max(0, Math.min(1, value())))
|
||||
const [height, setHeight] = createSignal(320)
|
||||
const full = createMemo(() => Math.max(78, height()))
|
||||
const collapse = useSpring(() => (store.collapsed ? 1 : 0), COLLAPSE_SPRING)
|
||||
const shut = createMemo(() => 1 - (props.dockProgress ?? 1))
|
||||
const hide = createMemo(() => Math.max(collapse(), shut()))
|
||||
let contentRef: HTMLDivElement | undefined
|
||||
|
||||
createEffect(() => {
|
||||
const el = contentRef
|
||||
if (!el) return
|
||||
const update = () => {
|
||||
setHeight(el.getBoundingClientRect().height)
|
||||
}
|
||||
update()
|
||||
const observer = new ResizeObserver(update)
|
||||
observer.observe(el)
|
||||
onCleanup(() => observer.disconnect())
|
||||
})
|
||||
const height = useElementHeight(() => contentRef, 320)
|
||||
const full = createMemo(() => Math.max(COLLAPSED_HEIGHT, height()))
|
||||
|
||||
return (
|
||||
<DockTray
|
||||
@@ -113,7 +79,7 @@ export function SessionTodoDock(props: {
|
||||
style={{
|
||||
"overflow-x": "visible",
|
||||
"overflow-y": "hidden",
|
||||
"max-height": `${Math.max(78, full() - value() * (full() - 78))}px`,
|
||||
"max-height": `${Math.max(COLLAPSED_HEIGHT, full() - collapse() * (full() - COLLAPSED_HEIGHT))}px`,
|
||||
}}
|
||||
>
|
||||
<div ref={contentRef}>
|
||||
@@ -133,12 +99,12 @@ export function SessionTodoDock(props: {
|
||||
class="text-14-regular text-text-strong cursor-default inline-flex items-baseline shrink-0 whitespace-nowrap overflow-visible"
|
||||
aria-label={label()}
|
||||
style={{
|
||||
"--tool-motion-odometer-ms": `${props.countDuration ?? 600}ms`,
|
||||
"--tool-motion-mask": `${props.countMask ?? 18}%`,
|
||||
"--tool-motion-mask-height": `${props.countMaskHeight ?? 0}px`,
|
||||
"--tool-motion-spring-ms": `${props.countWidthDuration ?? 560}ms`,
|
||||
opacity: `${Math.max(0, Math.min(1, 1 - shut()))}`,
|
||||
filter: `blur(${Math.max(0, Math.min(1, shut())) * 2}px)`,
|
||||
"--tool-motion-odometer-ms": `${COUNT.duration}ms`,
|
||||
"--tool-motion-mask": `${COUNT.mask}%`,
|
||||
"--tool-motion-mask-height": `${COUNT.maskHeight}px`,
|
||||
"--tool-motion-spring-ms": `${COUNT.widthDuration}ms`,
|
||||
opacity: `${1 - shut()}`,
|
||||
filter: shut() > 0.01 ? `blur(${shut() * 2}px)` : "none",
|
||||
}}
|
||||
>
|
||||
<AnimatedNumber value={done()} />
|
||||
@@ -157,9 +123,9 @@ export function SessionTodoDock(props: {
|
||||
<TextReveal
|
||||
class="text-14-regular text-text-base cursor-default"
|
||||
text={store.collapsed ? preview() : undefined}
|
||||
duration={props.subtitleDuration ?? 600}
|
||||
travel={props.subtitleTravel ?? 25}
|
||||
edge={props.subtitleEdge ?? 17}
|
||||
duration={SUBTITLE.duration}
|
||||
travel={SUBTITLE.travel}
|
||||
edge={SUBTITLE.edge}
|
||||
spring="cubic-bezier(0.34, 1, 0.64, 1)"
|
||||
springSoft="cubic-bezier(0.34, 1, 0.64, 1)"
|
||||
growOnly
|
||||
@@ -173,7 +139,7 @@ export function SessionTodoDock(props: {
|
||||
icon="chevron-down"
|
||||
size="normal"
|
||||
variant="ghost"
|
||||
style={{ transform: `rotate(${turn() * 180}deg)` }}
|
||||
style={{ transform: `rotate(${collapse() * 180}deg)` }}
|
||||
onMouseDown={(event) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
@@ -189,14 +155,15 @@ export function SessionTodoDock(props: {
|
||||
|
||||
<div
|
||||
data-slot="session-todo-list"
|
||||
aria-hidden={store.collapsed || off()}
|
||||
class="pb-2"
|
||||
aria-hidden={store.collapsed}
|
||||
classList={{
|
||||
"pointer-events-none": hide() > 0.1,
|
||||
}}
|
||||
style={{
|
||||
visibility: off() ? "hidden" : "visible",
|
||||
opacity: `${Math.max(0, Math.min(1, 1 - hide()))}`,
|
||||
filter: `blur(${Math.max(0, Math.min(1, hide())) * 2}px)`,
|
||||
opacity: `${1 - hide()}`,
|
||||
filter: hide() > 0.01 ? `blur(${hide() * 2}px)` : "none",
|
||||
visibility: hide() > 0.98 ? "hidden" : "visible",
|
||||
}}
|
||||
>
|
||||
<TodoList todos={props.todos} open={!store.collapsed} />
|
||||
@@ -282,7 +249,7 @@ function TodoList(props: { todos: Todo[]; open: boolean }) {
|
||||
"--checkbox-align": "flex-start",
|
||||
"--checkbox-offset": "1px",
|
||||
transition: "opacity 220ms var(--tool-motion-ease, cubic-bezier(0.22, 1, 0.36, 1))",
|
||||
opacity: todo().status === "pending" ? "0.94" : "1",
|
||||
opacity: todo().status === "pending" ? "0.5" : "1",
|
||||
}}
|
||||
>
|
||||
<TextStrikethrough
|
||||
@@ -292,12 +259,11 @@ function TodoList(props: { todos: Todo[]; open: boolean }) {
|
||||
style={{
|
||||
"line-height": "var(--line-height-normal)",
|
||||
transition:
|
||||
"color 220ms var(--tool-motion-ease, cubic-bezier(0.22, 1, 0.36, 1)), opacity 220ms var(--tool-motion-ease, cubic-bezier(0.22, 1, 0.36, 1))",
|
||||
"color 220ms var(--tool-motion-ease, cubic-bezier(0.22, 1, 0.36, 1))",
|
||||
color:
|
||||
todo().status === "completed" || todo().status === "cancelled"
|
||||
? "var(--text-weak)"
|
||||
: "var(--text-strong)",
|
||||
opacity: todo().status === "pending" ? "0.92" : "1",
|
||||
}}
|
||||
/>
|
||||
</Checkbox>
|
||||
|
||||
@@ -49,11 +49,11 @@ describe("shouldMarkBoundaryGesture", () => {
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
test("does not mark when nested scroller can consume movement", () => {
|
||||
test("does not mark when scroller can consume movement", () => {
|
||||
expect(
|
||||
shouldMarkBoundaryGesture({
|
||||
delta: 20,
|
||||
scrollTop: 200,
|
||||
scrollTop: 300,
|
||||
scrollHeight: 1000,
|
||||
clientHeight: 400,
|
||||
}),
|
||||
|
||||
@@ -14,8 +14,8 @@ export const shouldMarkBoundaryGesture = (input: {
|
||||
if (max <= 1) return true
|
||||
if (!input.delta) return false
|
||||
|
||||
if (input.delta < 0) return input.scrollTop + input.delta <= 0
|
||||
|
||||
const remaining = max - input.scrollTop
|
||||
return input.delta > remaining
|
||||
const top = Math.max(0, Math.min(max, input.scrollTop))
|
||||
if (input.delta < 0) return -input.delta > top
|
||||
const bottom = max - top
|
||||
return input.delta > bottom
|
||||
}
|
||||
|
||||
@@ -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<string>([sessionID])
|
||||
|
||||
const byParent = new Map<string, string[]>()
|
||||
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 (
|
||||
<Dialog title={language.t("session.delete.title")} fit>
|
||||
<div class="flex flex-col gap-4 pl-6 pr-2.5 pb-3">
|
||||
<div class="flex flex-col gap-1">
|
||||
<span class="text-14-regular text-text-strong">
|
||||
{language.t("session.delete.confirm", { name: name() })}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex justify-end gap-2">
|
||||
<Button variant="ghost" size="large" onClick={() => dialog.close()}>
|
||||
{language.t("common.cancel")}
|
||||
</Button>
|
||||
<Button variant="primary" size="large" onClick={handleDelete}>
|
||||
{language.t("session.delete.button")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
const rendered = createMemo(() => staging.messages().map((message) => message.id))
|
||||
|
||||
return (
|
||||
<Show
|
||||
@@ -498,6 +336,16 @@ export function MessageTimeline(props: {
|
||||
<Icon name="arrow-down-to-line" />
|
||||
</button>
|
||||
</div>
|
||||
<SessionTimelineHeader
|
||||
centered={props.centered}
|
||||
showHeader={showHeader}
|
||||
sessionKey={sessionKey}
|
||||
sessionID={sessionID}
|
||||
parentID={parentID}
|
||||
titleValue={titleValue}
|
||||
headerTitle={headerTitle}
|
||||
placeholderTitle={placeholderTitle}
|
||||
/>
|
||||
<ScrollView
|
||||
viewportRef={props.setScrollRef}
|
||||
onWheel={(e) => {
|
||||
@@ -532,9 +380,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,131 +400,21 @@ 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",
|
||||
}}
|
||||
>
|
||||
<div ref={props.setContentRef} class="min-w-0 w-full">
|
||||
<Show when={showHeader()}>
|
||||
<div
|
||||
data-session-title
|
||||
classList={{
|
||||
"sticky top-0 z-30 bg-[linear-gradient(to_bottom,var(--background-stronger)_48px,transparent)]": true,
|
||||
"w-full": true,
|
||||
"pb-4": true,
|
||||
"pl-2 pr-3 md:pl-4 md:pr-3": true,
|
||||
"md:max-w-200 md:mx-auto 2xl:max-w-[1000px]": props.centered,
|
||||
}}
|
||||
>
|
||||
<div class="h-12 w-full flex items-center justify-between gap-2">
|
||||
<div class="flex items-center gap-1 min-w-0 flex-1 pr-3">
|
||||
<Show when={parentID()}>
|
||||
<IconButton
|
||||
tabIndex={-1}
|
||||
icon="arrow-left"
|
||||
variant="ghost"
|
||||
onClick={navigateParent}
|
||||
aria-label={language.t("common.goBack")}
|
||||
/>
|
||||
</Show>
|
||||
<Show when={titleValue() || title.editing}>
|
||||
<Show
|
||||
when={title.editing}
|
||||
fallback={
|
||||
<h1
|
||||
class="text-14-medium text-text-strong truncate grow-1 min-w-0 pl-2"
|
||||
onDblClick={openTitleEditor}
|
||||
>
|
||||
{titleValue()}
|
||||
</h1>
|
||||
}
|
||||
>
|
||||
<InlineInput
|
||||
ref={(el) => {
|
||||
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}
|
||||
/>
|
||||
</Show>
|
||||
</Show>
|
||||
</div>
|
||||
<Show when={sessionID()} keyed>
|
||||
{(id) => (
|
||||
<div class="shrink-0 flex items-center gap-3">
|
||||
<SessionContextUsage placement="bottom" />
|
||||
<DropdownMenu
|
||||
gutter={4}
|
||||
placement="bottom-end"
|
||||
open={title.menuOpen}
|
||||
onOpenChange={(open) => setTitle("menuOpen", open)}
|
||||
>
|
||||
<DropdownMenu.Trigger
|
||||
as={IconButton}
|
||||
icon="dot-grid"
|
||||
variant="ghost"
|
||||
class="size-6 rounded-md data-[expanded]:bg-surface-base-active"
|
||||
aria-label={language.t("common.moreOptions")}
|
||||
/>
|
||||
<DropdownMenu.Portal>
|
||||
<DropdownMenu.Content
|
||||
style={{ "min-width": "104px" }}
|
||||
onCloseAutoFocus={(event) => {
|
||||
if (!title.pendingRename) return
|
||||
event.preventDefault()
|
||||
setTitle("pendingRename", false)
|
||||
openTitleEditor()
|
||||
}}
|
||||
>
|
||||
<DropdownMenu.Item
|
||||
onSelect={() => {
|
||||
setTitle("pendingRename", true)
|
||||
setTitle("menuOpen", false)
|
||||
}}
|
||||
>
|
||||
<DropdownMenu.ItemLabel>{language.t("common.rename")}</DropdownMenu.ItemLabel>
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Item onSelect={() => void archiveSession(id)}>
|
||||
<DropdownMenu.ItemLabel>{language.t("common.archive")}</DropdownMenu.ItemLabel>
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Separator />
|
||||
<DropdownMenu.Item
|
||||
onSelect={() => dialog.show(() => <DialogDeleteSession sessionID={id} />)}
|
||||
>
|
||||
<DropdownMenu.ItemLabel>{language.t("common.delete")}</DropdownMenu.ItemLabel>
|
||||
</DropdownMenu.Item>
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Portal>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
)}
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<div>
|
||||
<div
|
||||
ref={props.setContentRef}
|
||||
role="log"
|
||||
class="flex flex-col gap-12 items-start justify-start pb-16 transition-[margin]"
|
||||
class="flex flex-col gap-0 items-start justify-start pb-16 transition-[margin]"
|
||||
style={{ "padding-top": "var(--session-title-height)" }}
|
||||
classList={{
|
||||
"w-full": true,
|
||||
"md:max-w-200 md:mx-auto 2xl:max-w-[1000px]": props.centered,
|
||||
@@ -692,6 +439,15 @@ export function MessageTimeline(props: {
|
||||
</Show>
|
||||
<For each={rendered()}>
|
||||
{(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 +456,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 (
|
||||
@@ -757,10 +516,10 @@ 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()}
|
||||
shellToolDefaultOpen={false}
|
||||
editToolDefaultOpen={false}
|
||||
classes={{
|
||||
root: "min-w-0 w-full relative",
|
||||
content: "flex flex-col justify-between !overflow-visible",
|
||||
|
||||
522
packages/app/src/pages/session/session-timeline-header.tsx
Normal file
522
packages/app/src/pages/session/session-timeline-header.tsx
Normal file
@@ -0,0 +1,522 @@
|
||||
import { createEffect, createMemo, on, onCleanup, Show } from "solid-js"
|
||||
import { createStore, produce } from "solid-js/store"
|
||||
import { useNavigate, useParams } from "@solidjs/router"
|
||||
import { Button } from "@opencode-ai/ui/button"
|
||||
import { IconButton } from "@opencode-ai/ui/icon-button"
|
||||
import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu"
|
||||
import { Dialog } from "@opencode-ai/ui/dialog"
|
||||
import { prefersReducedMotion } from "@opencode-ai/ui/hooks"
|
||||
import { InlineInput } from "@opencode-ai/ui/inline-input"
|
||||
import { animate, type AnimationPlaybackControls, clearFadeStyles, FAST_SPRING } from "@opencode-ai/ui/motion"
|
||||
import { showToast } from "@opencode-ai/ui/toast"
|
||||
import { errorMessage } from "@/pages/layout/helpers"
|
||||
import { SessionContextUsage } from "@/components/session-context-usage"
|
||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { useSDK } from "@/context/sdk"
|
||||
import { useSync } from "@/context/sync"
|
||||
|
||||
export function SessionTimelineHeader(props: {
|
||||
centered: boolean
|
||||
showHeader: () => boolean
|
||||
sessionKey: () => string
|
||||
sessionID: () => string | undefined
|
||||
parentID: () => string | undefined
|
||||
titleValue: () => string | undefined
|
||||
headerTitle: () => string | undefined
|
||||
placeholderTitle: () => boolean
|
||||
}) {
|
||||
const navigate = useNavigate()
|
||||
const params = useParams()
|
||||
const sdk = useSDK()
|
||||
const sync = useSync()
|
||||
const dialog = useDialog()
|
||||
const language = useLanguage()
|
||||
const reduce = prefersReducedMotion
|
||||
|
||||
const [title, setTitle] = createStore({
|
||||
draft: "",
|
||||
editing: false,
|
||||
saving: false,
|
||||
menuOpen: false,
|
||||
pendingRename: false,
|
||||
})
|
||||
const [headerText, setHeaderText] = createStore({
|
||||
session: props.sessionKey(),
|
||||
value: props.headerTitle(),
|
||||
prev: undefined as string | undefined,
|
||||
muted: props.placeholderTitle(),
|
||||
prevMuted: false,
|
||||
})
|
||||
let headerAnim: AnimationPlaybackControls | undefined
|
||||
let enterAnim: AnimationPlaybackControls | undefined
|
||||
let leaveAnim: AnimationPlaybackControls | undefined
|
||||
let titleRef: HTMLInputElement | undefined
|
||||
let headerRef: HTMLDivElement | undefined
|
||||
let enterRef: HTMLSpanElement | undefined
|
||||
let leaveRef: HTMLSpanElement | undefined
|
||||
|
||||
const clearHeaderAnim = () => {
|
||||
headerAnim?.stop()
|
||||
headerAnim = undefined
|
||||
}
|
||||
|
||||
const animateHeader = () => {
|
||||
const el = headerRef
|
||||
if (!el) return
|
||||
|
||||
clearHeaderAnim()
|
||||
if (!headerText.muted || reduce()) {
|
||||
el.style.opacity = "1"
|
||||
return
|
||||
}
|
||||
|
||||
headerAnim = animate(el, { opacity: [0, 1] }, { type: "spring", visualDuration: 1.0, bounce: 0 })
|
||||
headerAnim.finished.then(() => {
|
||||
if (headerRef !== el) return
|
||||
clearFadeStyles(el)
|
||||
})
|
||||
}
|
||||
|
||||
const clearTitleAnims = () => {
|
||||
enterAnim?.stop()
|
||||
enterAnim = undefined
|
||||
leaveAnim?.stop()
|
||||
leaveAnim = undefined
|
||||
}
|
||||
|
||||
const settleTitleEnter = () => {
|
||||
if (enterRef) clearFadeStyles(enterRef)
|
||||
}
|
||||
|
||||
const hideLeave = () => {
|
||||
if (!leaveRef) return
|
||||
leaveRef.style.opacity = "0"
|
||||
leaveRef.style.filter = ""
|
||||
leaveRef.style.transform = ""
|
||||
}
|
||||
|
||||
const animateEnterSpan = () => {
|
||||
if (!enterRef) return
|
||||
if (reduce()) {
|
||||
settleTitleEnter()
|
||||
return
|
||||
}
|
||||
enterAnim = animate(
|
||||
enterRef,
|
||||
{ opacity: [0, 1], filter: ["blur(2px)", "blur(0px)"], transform: ["translateY(-2px)", "translateY(0)"] },
|
||||
FAST_SPRING,
|
||||
)
|
||||
enterAnim.finished.then(() => settleTitleEnter())
|
||||
}
|
||||
|
||||
const crossfadeTitle = (nextTitle: string, nextMuted: boolean) => {
|
||||
clearTitleAnims()
|
||||
setHeaderText({ prev: headerText.value, prevMuted: headerText.muted })
|
||||
setHeaderText({ value: nextTitle, muted: nextMuted })
|
||||
|
||||
if (reduce()) {
|
||||
setHeaderText({ prev: undefined, prevMuted: false })
|
||||
hideLeave()
|
||||
settleTitleEnter()
|
||||
return
|
||||
}
|
||||
|
||||
if (leaveRef) {
|
||||
leaveAnim = animate(
|
||||
leaveRef,
|
||||
{ opacity: [1, 0], filter: ["blur(0px)", "blur(2px)"], transform: ["translateY(0)", "translateY(2px)"] },
|
||||
FAST_SPRING,
|
||||
)
|
||||
leaveAnim.finished.then(() => {
|
||||
setHeaderText({ prev: undefined, prevMuted: false })
|
||||
hideLeave()
|
||||
})
|
||||
}
|
||||
|
||||
animateEnterSpan()
|
||||
}
|
||||
|
||||
const fadeInTitle = (nextTitle: string, nextMuted: boolean) => {
|
||||
clearTitleAnims()
|
||||
setHeaderText({ value: nextTitle, muted: nextMuted, prev: undefined, prevMuted: false })
|
||||
animateEnterSpan()
|
||||
}
|
||||
|
||||
const snapTitle = (nextTitle: string | undefined, nextMuted: boolean) => {
|
||||
clearTitleAnims()
|
||||
setHeaderText({ value: nextTitle, muted: nextMuted, prev: undefined, prevMuted: false })
|
||||
settleTitleEnter()
|
||||
}
|
||||
|
||||
createEffect(
|
||||
on(props.showHeader, (show, prev) => {
|
||||
if (!show) {
|
||||
clearHeaderAnim()
|
||||
return
|
||||
}
|
||||
if (show === prev) return
|
||||
animateHeader()
|
||||
}),
|
||||
)
|
||||
|
||||
createEffect(
|
||||
on(
|
||||
() => [props.sessionKey(), props.headerTitle(), props.placeholderTitle()] as const,
|
||||
([nextSession, nextTitle, nextMuted]) => {
|
||||
if (nextSession !== headerText.session) {
|
||||
setHeaderText("session", nextSession)
|
||||
if (nextTitle && nextMuted) {
|
||||
fadeInTitle(nextTitle, nextMuted)
|
||||
return
|
||||
}
|
||||
snapTitle(nextTitle, nextMuted)
|
||||
return
|
||||
}
|
||||
if (nextTitle === headerText.value && nextMuted === headerText.muted) return
|
||||
if (!nextTitle) {
|
||||
snapTitle(undefined, false)
|
||||
return
|
||||
}
|
||||
if (!headerText.value) {
|
||||
fadeInTitle(nextTitle, nextMuted)
|
||||
return
|
||||
}
|
||||
if (title.saving || title.editing) {
|
||||
snapTitle(nextTitle, nextMuted)
|
||||
return
|
||||
}
|
||||
crossfadeTitle(nextTitle, nextMuted)
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
onCleanup(() => {
|
||||
clearHeaderAnim()
|
||||
clearTitleAnims()
|
||||
})
|
||||
|
||||
const toastError = (err: unknown) => errorMessage(err, language.t("common.requestFailed"))
|
||||
|
||||
createEffect(
|
||||
on(
|
||||
props.sessionKey,
|
||||
() => setTitle({ draft: "", editing: false, saving: false, menuOpen: false, pendingRename: false }),
|
||||
{ defer: true },
|
||||
),
|
||||
)
|
||||
|
||||
const openTitleEditor = () => {
|
||||
if (!props.sessionID()) return
|
||||
setTitle({ editing: true, draft: props.titleValue() ?? "" })
|
||||
requestAnimationFrame(() => {
|
||||
titleRef?.focus()
|
||||
titleRef?.select()
|
||||
})
|
||||
}
|
||||
|
||||
const closeTitleEditor = () => {
|
||||
if (title.saving) return
|
||||
setTitle({ editing: false, saving: false })
|
||||
}
|
||||
|
||||
const saveTitleEditor = async () => {
|
||||
const id = props.sessionID()
|
||||
if (!id) return
|
||||
if (title.saving) return
|
||||
|
||||
const next = title.draft.trim()
|
||||
if (!next || next === (props.titleValue() ?? "")) {
|
||||
setTitle({ editing: false, saving: false })
|
||||
return
|
||||
}
|
||||
|
||||
setTitle("saving", true)
|
||||
await sdk.client.session
|
||||
.update({ sessionID: id, title: next })
|
||||
.then(() => {
|
||||
sync.set(
|
||||
produce((draft) => {
|
||||
const index = draft.session.findIndex((session) => session.id === id)
|
||||
if (index !== -1) draft.session[index].title = next
|
||||
}),
|
||||
)
|
||||
setTitle({ editing: false, saving: false })
|
||||
})
|
||||
.catch((err) => {
|
||||
setTitle("saving", false)
|
||||
showToast({
|
||||
title: language.t("common.requestFailed"),
|
||||
description: toastError(err),
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
const navigateAfterSessionRemoval = (sessionID: string, parentID?: string, nextSessionID?: string) => {
|
||||
if (params.id !== sessionID) return
|
||||
if (parentID) {
|
||||
navigate(`/${params.dir}/session/${parentID}`)
|
||||
return
|
||||
}
|
||||
if (nextSessionID) {
|
||||
navigate(`/${params.dir}/session/${nextSessionID}`)
|
||||
return
|
||||
}
|
||||
navigate(`/${params.dir}/session`)
|
||||
}
|
||||
|
||||
const archiveSession = async (sessionID: string) => {
|
||||
const session = sync.session.get(sessionID)
|
||||
if (!session) return
|
||||
|
||||
const sessions = sync.data.session ?? []
|
||||
const index = sessions.findIndex((item) => item.id === sessionID)
|
||||
const nextSession = index === -1 ? undefined : (sessions[index + 1] ?? sessions[index - 1])
|
||||
|
||||
await sdk.client.session
|
||||
.update({ sessionID, time: { archived: Date.now() } })
|
||||
.then(() => {
|
||||
sync.set(
|
||||
produce((draft) => {
|
||||
const index = draft.session.findIndex((item) => item.id === sessionID)
|
||||
if (index !== -1) draft.session.splice(index, 1)
|
||||
}),
|
||||
)
|
||||
navigateAfterSessionRemoval(sessionID, session.parentID, nextSession?.id)
|
||||
})
|
||||
.catch((err) => {
|
||||
showToast({
|
||||
title: language.t("common.requestFailed"),
|
||||
description: toastError(err),
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
const deleteSession = async (sessionID: string) => {
|
||||
const session = sync.session.get(sessionID)
|
||||
if (!session) return false
|
||||
|
||||
const sessions = (sync.data.session ?? []).filter((item) => !item.parentID && !item.time?.archived)
|
||||
const index = sessions.findIndex((item) => item.id === sessionID)
|
||||
const nextSession = index === -1 ? undefined : (sessions[index + 1] ?? sessions[index - 1])
|
||||
|
||||
const result = await sdk.client.session
|
||||
.delete({ sessionID })
|
||||
.then((x) => x.data)
|
||||
.catch((err) => {
|
||||
showToast({
|
||||
title: language.t("session.delete.failed.title"),
|
||||
description: toastError(err),
|
||||
})
|
||||
return false
|
||||
})
|
||||
|
||||
if (!result) return false
|
||||
|
||||
sync.set(
|
||||
produce((draft) => {
|
||||
const removed = new Set<string>([sessionID])
|
||||
const byParent = new Map<string, string[]>()
|
||||
|
||||
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 (
|
||||
<Dialog title={language.t("session.delete.title")} fit>
|
||||
<div class="flex flex-col gap-4 pl-6 pr-2.5 pb-3">
|
||||
<div class="flex flex-col gap-1">
|
||||
<span class="text-14-regular text-text-strong">
|
||||
{language.t("session.delete.confirm", { name: name() })}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex justify-end gap-2">
|
||||
<Button variant="ghost" size="large" onClick={() => dialog.close()}>
|
||||
{language.t("common.cancel")}
|
||||
</Button>
|
||||
<Button variant="primary" size="large" onClick={handleDelete}>
|
||||
{language.t("session.delete.button")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Show when={props.showHeader()}>
|
||||
<div
|
||||
data-session-title
|
||||
ref={(el) => {
|
||||
headerRef = el
|
||||
el.style.opacity = "0"
|
||||
}}
|
||||
class="pointer-events-none absolute inset-x-0 top-0 z-30"
|
||||
>
|
||||
<div
|
||||
classList={{
|
||||
"bg-[linear-gradient(to_bottom,var(--background-stronger)_38px,transparent)]": true,
|
||||
"w-full": true,
|
||||
"pb-10": true,
|
||||
"px-4 md:px-5": true,
|
||||
"md:max-w-200 md:mx-auto 2xl:max-w-[1000px]": props.centered,
|
||||
}}
|
||||
>
|
||||
<div class="pointer-events-auto h-12 w-full flex items-center justify-between gap-2">
|
||||
<div class="flex items-center gap-1 min-w-0 flex-1">
|
||||
<Show when={props.parentID()}>
|
||||
<div>
|
||||
<IconButton
|
||||
tabIndex={-1}
|
||||
icon="arrow-left"
|
||||
variant="ghost"
|
||||
onClick={navigateParent}
|
||||
aria-label={language.t("common.goBack")}
|
||||
/>
|
||||
</div>
|
||||
</Show>
|
||||
<Show when={!!headerText.value || title.editing}>
|
||||
<Show
|
||||
when={title.editing}
|
||||
fallback={
|
||||
<h1 class="text-14-medium text-text-strong grow-1 min-w-0" onDblClick={openTitleEditor}>
|
||||
<span class="grid min-w-0" style={{ overflow: "clip" }}>
|
||||
<span ref={enterRef} class="col-start-1 row-start-1 min-w-0 truncate">
|
||||
<span classList={{ "opacity-60": headerText.muted }}>{headerText.value}</span>
|
||||
</span>
|
||||
<span
|
||||
ref={leaveRef}
|
||||
class="col-start-1 row-start-1 min-w-0 truncate pointer-events-none"
|
||||
style={{ opacity: "0" }}
|
||||
>
|
||||
<span classList={{ "opacity-60": headerText.prevMuted }}>{headerText.prev}</span>
|
||||
</span>
|
||||
</span>
|
||||
</h1>
|
||||
}
|
||||
>
|
||||
<InlineInput
|
||||
ref={(el) => {
|
||||
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}
|
||||
/>
|
||||
</Show>
|
||||
</Show>
|
||||
</div>
|
||||
<Show when={props.sessionID()}>
|
||||
{(id) => (
|
||||
<div class="shrink-0 flex items-center gap-3">
|
||||
<SessionContextUsage placement="bottom" />
|
||||
<DropdownMenu
|
||||
gutter={4}
|
||||
placement="bottom-end"
|
||||
open={title.menuOpen}
|
||||
onOpenChange={(open) => setTitle("menuOpen", open)}
|
||||
>
|
||||
<DropdownMenu.Trigger
|
||||
as={IconButton}
|
||||
icon="dot-grid"
|
||||
variant="ghost"
|
||||
class="size-6 rounded-md data-[expanded]:bg-surface-base-active"
|
||||
aria-label={language.t("common.moreOptions")}
|
||||
/>
|
||||
<DropdownMenu.Portal>
|
||||
<DropdownMenu.Content
|
||||
style={{ "min-width": "104px" }}
|
||||
onCloseAutoFocus={(event) => {
|
||||
if (!title.pendingRename) return
|
||||
event.preventDefault()
|
||||
setTitle("pendingRename", false)
|
||||
openTitleEditor()
|
||||
}}
|
||||
>
|
||||
<DropdownMenu.Item
|
||||
onSelect={() => {
|
||||
setTitle("pendingRename", true)
|
||||
setTitle("menuOpen", false)
|
||||
}}
|
||||
>
|
||||
<DropdownMenu.ItemLabel>{language.t("common.rename")}</DropdownMenu.ItemLabel>
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Item onSelect={() => void archiveSession(id())}>
|
||||
<DropdownMenu.ItemLabel>{language.t("common.archive")}</DropdownMenu.ItemLabel>
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Separator />
|
||||
<DropdownMenu.Item onSelect={() => dialog.show(() => <DialogDeleteSession sessionID={id()} />)}>
|
||||
<DropdownMenu.ItemLabel>{language.t("common.delete")}</DropdownMenu.ItemLabel>
|
||||
</DropdownMenu.Item>
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Portal>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
)}
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -1,3 +1,12 @@
|
||||
export function isEditableTarget(target: EventTarget | null | undefined) {
|
||||
if (!(target instanceof HTMLElement)) return false
|
||||
if (/^(INPUT|TEXTAREA|SELECT|BUTTON)$/.test(target.tagName)) return true
|
||||
if (target.isContentEditable) return true
|
||||
if (target.closest("[contenteditable='true']")) return true
|
||||
if (target.closest("input, textarea, select")) return true
|
||||
return false
|
||||
}
|
||||
|
||||
export function getCharacterOffsetInLine(lineElement: Element, targetNode: Node, offset: number): number {
|
||||
const r = document.createRange()
|
||||
r.selectNodeContents(lineElement)
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
export function same<T>(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])
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/console-app",
|
||||
"version": "1.2.19",
|
||||
"version": "1.2.20",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode-ai/console-core",
|
||||
"version": "1.2.19",
|
||||
"version": "1.2.20",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/console-function",
|
||||
"version": "1.2.19",
|
||||
"version": "1.2.20",
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/console-mail",
|
||||
"version": "1.2.19",
|
||||
"version": "1.2.20",
|
||||
"dependencies": {
|
||||
"@jsx-email/all": "2.2.3",
|
||||
"@jsx-email/cli": "1.4.3",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@opencode-ai/desktop-electron",
|
||||
"private": true,
|
||||
"version": "1.2.19",
|
||||
"version": "1.2.20",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"homepage": "https://opencode.ai",
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@opencode-ai/desktop",
|
||||
"private": true,
|
||||
"version": "1.2.19",
|
||||
"version": "1.2.20",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/enterprise",
|
||||
"version": "1.2.19",
|
||||
"version": "1.2.20",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -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<typeof Data>
|
||||
|
||||
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<string, Data>()
|
||||
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<Snapshot>(["share_snapshot", shareID]))?.data
|
||||
}
|
||||
|
||||
async function writeSnapshot(shareID: string, data: Data[]) {
|
||||
await Storage.write<Snapshot>(["share_snapshot", shareID], { data })
|
||||
}
|
||||
|
||||
async function legacy(shareID: string) {
|
||||
const compaction: Compaction = (await Storage.read<Compaction>(["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<Data[]>(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<Compaction>(["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<Data[]>(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(
|
||||
|
||||
@@ -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))
|
||||
},
|
||||
)
|
||||
|
||||
@@ -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<any>[]
|
||||
}
|
||||
session_diff_preload_split: {
|
||||
[sessionID: string]: PreloadMultiFileDiffResult<any>[]
|
||||
}
|
||||
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<any>({
|
||||
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<any>({
|
||||
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 = () => (
|
||||
<div class="flex flex-col gap-4">
|
||||
@@ -380,18 +329,9 @@ export default function () {
|
||||
<Show when={diffs().length > 0}>
|
||||
<div class="@container relative grow pt-14 flex-1 min-h-0 border-l border-border-weak-base">
|
||||
<SessionReview
|
||||
class="@4xl:hidden"
|
||||
diffs={diffs()}
|
||||
classes={{
|
||||
root: "pb-20",
|
||||
header: "px-6",
|
||||
container: "px-6",
|
||||
}}
|
||||
/>
|
||||
<SessionReview
|
||||
split
|
||||
class="hidden @4xl:flex"
|
||||
diffs={splitDiffs()}
|
||||
diffStyle={diffStyle()}
|
||||
onDiffStyleChange={setDiffStyle}
|
||||
classes={{
|
||||
root: "pb-20",
|
||||
header: "px-6",
|
||||
@@ -419,11 +359,7 @@ export default function () {
|
||||
<Tabs.Content value="session" class="!overflow-hidden">
|
||||
{turns()}
|
||||
</Tabs.Content>
|
||||
<Tabs.Content
|
||||
forceMount
|
||||
value="review"
|
||||
class="!overflow-hidden hidden data-[selected]:block"
|
||||
>
|
||||
<Tabs.Content value="review" class="!overflow-hidden hidden data-[selected]:block">
|
||||
<div class="relative h-full pt-8 overflow-y-auto no-scrollbar">
|
||||
<SessionReview
|
||||
diffs={diffs()}
|
||||
|
||||
@@ -30,8 +30,8 @@ describe.concurrent("core.share", () => {
|
||||
data,
|
||||
})
|
||||
|
||||
const events = await Storage.list({ prefix: ["share_event", share.id] })
|
||||
expect(events.length).toBe(1)
|
||||
const snapshot = await Storage.read<{ data: Share.Data[] }>(["share_snapshot", share.id])
|
||||
expect(snapshot?.data).toHaveLength(1)
|
||||
|
||||
await Share.remove({ id: share.id, secret: share.secret })
|
||||
})
|
||||
@@ -64,8 +64,8 @@ describe.concurrent("core.share", () => {
|
||||
data: data2,
|
||||
})
|
||||
|
||||
const events = await Storage.list({ prefix: ["share_event", share.id] })
|
||||
expect(events.length).toBe(2)
|
||||
const snapshot = await Storage.read<{ data: Share.Data[] }>(["share_snapshot", share.id])
|
||||
expect(snapshot?.data).toHaveLength(2)
|
||||
|
||||
await Share.remove({ id: share.id, secret: share.secret })
|
||||
})
|
||||
@@ -194,6 +194,28 @@ describe.concurrent("core.share", () => {
|
||||
await Share.remove({ id: share.id, secret: share.secret })
|
||||
})
|
||||
|
||||
test("should migrate legacy event data into the snapshot", async () => {
|
||||
const sessionID = Identifier.descending()
|
||||
const share = await Share.create({ sessionID })
|
||||
const data: Share.Data[] = [
|
||||
{
|
||||
type: "part",
|
||||
data: { id: "part1", sessionID, messageID: "msg1", type: "text", text: "Hello" },
|
||||
},
|
||||
]
|
||||
|
||||
await Storage.remove(["share_snapshot", share.id])
|
||||
await Storage.write(["share_event", share.id, Identifier.descending()], data)
|
||||
|
||||
const result = await Share.data(share.id)
|
||||
const snapshot = await Storage.read<{ data: Share.Data[] }>(["share_snapshot", share.id])
|
||||
|
||||
expect(result).toHaveLength(1)
|
||||
expect(snapshot?.data).toHaveLength(1)
|
||||
|
||||
await Share.remove({ id: share.id, secret: share.secret })
|
||||
})
|
||||
|
||||
test("should throw error for invalid secret", async () => {
|
||||
const sessionID = Identifier.descending()
|
||||
const share = await Share.create({ sessionID })
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
id = "opencode"
|
||||
name = "OpenCode"
|
||||
description = "The open source coding agent."
|
||||
version = "1.2.19"
|
||||
version = "1.2.20"
|
||||
schema_version = 1
|
||||
authors = ["Anomaly"]
|
||||
repository = "https://github.com/anomalyco/opencode"
|
||||
@@ -11,26 +11,26 @@ name = "OpenCode"
|
||||
icon = "./icons/opencode.svg"
|
||||
|
||||
[agent_servers.opencode.targets.darwin-aarch64]
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.2.19/opencode-darwin-arm64.zip"
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.2.20/opencode-darwin-arm64.zip"
|
||||
cmd = "./opencode"
|
||||
args = ["acp"]
|
||||
|
||||
[agent_servers.opencode.targets.darwin-x86_64]
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.2.19/opencode-darwin-x64.zip"
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.2.20/opencode-darwin-x64.zip"
|
||||
cmd = "./opencode"
|
||||
args = ["acp"]
|
||||
|
||||
[agent_servers.opencode.targets.linux-aarch64]
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.2.19/opencode-linux-arm64.tar.gz"
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.2.20/opencode-linux-arm64.tar.gz"
|
||||
cmd = "./opencode"
|
||||
args = ["acp"]
|
||||
|
||||
[agent_servers.opencode.targets.linux-x86_64]
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.2.19/opencode-linux-x64.tar.gz"
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.2.20/opencode-linux-x64.tar.gz"
|
||||
cmd = "./opencode"
|
||||
args = ["acp"]
|
||||
|
||||
[agent_servers.opencode.targets.windows-x86_64]
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.2.19/opencode-windows-x64.zip"
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.2.20/opencode-windows-x64.zip"
|
||||
cmd = "./opencode.exe"
|
||||
args = ["acp"]
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/function",
|
||||
"version": "1.2.19",
|
||||
"version": "1.2.20",
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"version": "1.2.19",
|
||||
"version": "1.2.20",
|
||||
"name": "opencode",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
@@ -43,9 +43,10 @@
|
||||
"@types/mime-types": "3.0.1",
|
||||
"@types/turndown": "5.0.5",
|
||||
"@types/yargs": "17.0.33",
|
||||
"@types/which": "3.0.4",
|
||||
"@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",
|
||||
@@ -105,7 +106,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",
|
||||
@@ -127,12 +128,13 @@
|
||||
"ulid": "catalog:",
|
||||
"vscode-jsonrpc": "8.2.1",
|
||||
"web-tree-sitter": "0.25.10",
|
||||
"which": "6.0.1",
|
||||
"xdg-basedir": "5.1.0",
|
||||
"yargs": "18.0.0",
|
||||
"zod": "catalog:",
|
||||
"zod-to-json-schema": "3.24.5"
|
||||
},
|
||||
"overrides": {
|
||||
"drizzle-orm": "1.0.0-beta.12-a5629fb"
|
||||
"drizzle-orm": "1.0.0-beta.16-ea816b6"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ import { $ } from "bun"
|
||||
import fs from "fs"
|
||||
import path from "path"
|
||||
import { fileURLToPath } from "url"
|
||||
import solidPlugin from "../node_modules/@opentui/solid/scripts/solid-plugin"
|
||||
import solidPlugin from "@opentui/solid/bun-plugin"
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url)
|
||||
const __dirname = path.dirname(__filename)
|
||||
@@ -161,7 +161,9 @@ for (const item of targets) {
|
||||
console.log(`building ${name}`)
|
||||
await $`mkdir -p dist/${name}/bin`
|
||||
|
||||
const parserWorker = fs.realpathSync(path.resolve(dir, "./node_modules/@opentui/core/parser.worker.js"))
|
||||
const localPath = path.resolve(dir, "node_modules/@opentui/core/parser.worker.js")
|
||||
const rootPath = path.resolve(dir, "../../node_modules/@opentui/core/parser.worker.js")
|
||||
const parserWorker = fs.realpathSync(fs.existsSync(localPath) ? localPath : rootPath)
|
||||
const workerPath = "./src/cli/cmd/tui/worker.ts"
|
||||
|
||||
// Use platform-specific bunfs root path based on target OS
|
||||
|
||||
@@ -6,7 +6,6 @@ import { cmd } from "./cmd"
|
||||
import { Flag } from "../../flag/flag"
|
||||
import { bootstrap } from "../bootstrap"
|
||||
import { EOL } from "os"
|
||||
import { text as streamText } from "node:stream/consumers"
|
||||
import { Filesystem } from "../../util/filesystem"
|
||||
import { createOpencodeClient, type Message, type OpencodeClient, type ToolPart } from "@opencode-ai/sdk/v2"
|
||||
import { Server } from "../../server/server"
|
||||
@@ -338,7 +337,7 @@ export const RunCommand = cmd({
|
||||
}
|
||||
}
|
||||
|
||||
if (!process.stdin.isTTY) message += "\n" + (await streamText(process.stdin))
|
||||
if (!process.stdin.isTTY) message += "\n" + (await Bun.stdin.text())
|
||||
|
||||
if (message.trim().length === 0 && !args.command) {
|
||||
UI.error("You must provide a message or a command")
|
||||
|
||||
@@ -9,6 +9,7 @@ import { Filesystem } from "../../util/filesystem"
|
||||
import { Process } from "../../util/process"
|
||||
import { EOL } from "os"
|
||||
import path from "path"
|
||||
import { which } from "../../util/which"
|
||||
|
||||
function pagerCmd(): string[] {
|
||||
const lessOptions = ["-R", "-S"]
|
||||
@@ -17,7 +18,7 @@ function pagerCmd(): string[] {
|
||||
}
|
||||
|
||||
// user could have less installed via other options
|
||||
const lessOnPath = Bun.which("less")
|
||||
const lessOnPath = which("less")
|
||||
if (lessOnPath) {
|
||||
if (Filesystem.stat(lessOnPath)?.size) return [lessOnPath, ...lessOptions]
|
||||
}
|
||||
@@ -27,7 +28,7 @@ function pagerCmd(): string[] {
|
||||
if (Filesystem.stat(less)?.size) return [less, ...lessOptions]
|
||||
}
|
||||
|
||||
const git = Bun.which("git")
|
||||
const git = which("git")
|
||||
if (git) {
|
||||
const less = path.join(git, "..", "..", "usr", "bin", "less.exe")
|
||||
if (Filesystem.stat(less)?.size) return [less, ...lessOptions]
|
||||
|
||||
@@ -3,7 +3,6 @@ import { tui } from "./app"
|
||||
import { Rpc } from "@/util/rpc"
|
||||
import { type rpc } from "./worker"
|
||||
import path from "path"
|
||||
import { text as streamText } from "node:stream/consumers"
|
||||
import { fileURLToPath } from "url"
|
||||
import { UI } from "@/cli/ui"
|
||||
import { Log } from "@/util/log"
|
||||
@@ -54,7 +53,7 @@ async function target() {
|
||||
}
|
||||
|
||||
async function input(value?: string) {
|
||||
const piped = process.stdin.isTTY ? undefined : await streamText(process.stdin)
|
||||
const piped = process.stdin.isTTY ? undefined : await Bun.stdin.text()
|
||||
if (!value) return piped
|
||||
if (!piped) return value
|
||||
return piped + "\n" + value
|
||||
|
||||
@@ -6,6 +6,7 @@ import { tmpdir } from "os"
|
||||
import path from "path"
|
||||
import { Filesystem } from "../../../../util/filesystem"
|
||||
import { Process } from "../../../../util/process"
|
||||
import { which } from "../../../../util/which"
|
||||
|
||||
/**
|
||||
* Writes text to clipboard via OSC 52 escape sequence.
|
||||
@@ -76,7 +77,7 @@ export namespace Clipboard {
|
||||
const getCopyMethod = lazy(() => {
|
||||
const os = platform()
|
||||
|
||||
if (os === "darwin" && Bun.which("osascript")) {
|
||||
if (os === "darwin" && which("osascript")) {
|
||||
console.log("clipboard: using osascript")
|
||||
return async (text: string) => {
|
||||
const escaped = text.replace(/\\/g, "\\\\").replace(/"/g, '\\"')
|
||||
@@ -85,7 +86,7 @@ export namespace Clipboard {
|
||||
}
|
||||
|
||||
if (os === "linux") {
|
||||
if (process.env["WAYLAND_DISPLAY"] && Bun.which("wl-copy")) {
|
||||
if (process.env["WAYLAND_DISPLAY"] && which("wl-copy")) {
|
||||
console.log("clipboard: using wl-copy")
|
||||
return async (text: string) => {
|
||||
const proc = Process.spawn(["wl-copy"], { stdin: "pipe", stdout: "ignore", stderr: "ignore" })
|
||||
@@ -95,7 +96,7 @@ export namespace Clipboard {
|
||||
await proc.exited.catch(() => {})
|
||||
}
|
||||
}
|
||||
if (Bun.which("xclip")) {
|
||||
if (which("xclip")) {
|
||||
console.log("clipboard: using xclip")
|
||||
return async (text: string) => {
|
||||
const proc = Process.spawn(["xclip", "-selection", "clipboard"], {
|
||||
@@ -109,7 +110,7 @@ export namespace Clipboard {
|
||||
await proc.exited.catch(() => {})
|
||||
}
|
||||
}
|
||||
if (Bun.which("xsel")) {
|
||||
if (which("xsel")) {
|
||||
console.log("clipboard: using xsel")
|
||||
return async (text: string) => {
|
||||
const proc = Process.spawn(["xsel", "--clipboard", "--input"], {
|
||||
|
||||
@@ -418,7 +418,7 @@ export namespace File {
|
||||
const project = Instance.project
|
||||
if (project.vcs !== "git") return []
|
||||
|
||||
const diffOutput = await $`git -c core.quotepath=false diff --numstat HEAD`
|
||||
const diffOutput = await $`git -c core.fsmonitor=false -c core.quotepath=false diff --numstat HEAD`
|
||||
.cwd(Instance.directory)
|
||||
.quiet()
|
||||
.nothrow()
|
||||
@@ -439,11 +439,12 @@ export namespace File {
|
||||
}
|
||||
}
|
||||
|
||||
const untrackedOutput = await $`git -c core.quotepath=false ls-files --others --exclude-standard`
|
||||
.cwd(Instance.directory)
|
||||
.quiet()
|
||||
.nothrow()
|
||||
.text()
|
||||
const untrackedOutput =
|
||||
await $`git -c core.fsmonitor=false -c core.quotepath=false ls-files --others --exclude-standard`
|
||||
.cwd(Instance.directory)
|
||||
.quiet()
|
||||
.nothrow()
|
||||
.text()
|
||||
|
||||
if (untrackedOutput.trim()) {
|
||||
const untrackedFiles = untrackedOutput.trim().split("\n")
|
||||
@@ -464,11 +465,12 @@ export namespace File {
|
||||
}
|
||||
|
||||
// Get deleted files
|
||||
const deletedOutput = await $`git -c core.quotepath=false diff --name-only --diff-filter=D HEAD`
|
||||
.cwd(Instance.directory)
|
||||
.quiet()
|
||||
.nothrow()
|
||||
.text()
|
||||
const deletedOutput =
|
||||
await $`git -c core.fsmonitor=false -c core.quotepath=false diff --name-only --diff-filter=D HEAD`
|
||||
.cwd(Instance.directory)
|
||||
.quiet()
|
||||
.nothrow()
|
||||
.text()
|
||||
|
||||
if (deletedOutput.trim()) {
|
||||
const deletedFiles = deletedOutput.trim().split("\n")
|
||||
@@ -539,8 +541,14 @@ export namespace File {
|
||||
const content = (await Filesystem.readText(full).catch(() => "")).trim()
|
||||
|
||||
if (project.vcs === "git") {
|
||||
let diff = await $`git diff ${file}`.cwd(Instance.directory).quiet().nothrow().text()
|
||||
if (!diff.trim()) diff = await $`git diff --staged ${file}`.cwd(Instance.directory).quiet().nothrow().text()
|
||||
let diff = await $`git -c core.fsmonitor=false diff ${file}`.cwd(Instance.directory).quiet().nothrow().text()
|
||||
if (!diff.trim()) {
|
||||
diff = await $`git -c core.fsmonitor=false diff --staged ${file}`
|
||||
.cwd(Instance.directory)
|
||||
.quiet()
|
||||
.nothrow()
|
||||
.text()
|
||||
}
|
||||
if (diff.trim()) {
|
||||
const original = await $`git show HEAD:${file}`.cwd(Instance.directory).quiet().nothrow().text()
|
||||
const patch = structuredPatch(file, file, original, content, "old", "new", {
|
||||
|
||||
@@ -8,6 +8,7 @@ import { lazy } from "../util/lazy"
|
||||
import { $ } from "bun"
|
||||
import { Filesystem } from "../util/filesystem"
|
||||
import { Process } from "../util/process"
|
||||
import { which } from "../util/which"
|
||||
import { text } from "node:stream/consumers"
|
||||
|
||||
import { ZipReader, BlobReader, BlobWriter } from "@zip.js/zip.js"
|
||||
@@ -126,7 +127,7 @@ export namespace Ripgrep {
|
||||
)
|
||||
|
||||
const state = lazy(async () => {
|
||||
const system = Bun.which("rg")
|
||||
const system = which("rg")
|
||||
if (system) {
|
||||
const stat = await fs.stat(system).catch(() => undefined)
|
||||
if (stat?.isFile()) return { filepath: system }
|
||||
|
||||
@@ -60,6 +60,7 @@ export namespace Flag {
|
||||
export const OPENCODE_EXPERIMENTAL_MARKDOWN = !falsy("OPENCODE_EXPERIMENTAL_MARKDOWN")
|
||||
export const OPENCODE_MODELS_URL = process.env["OPENCODE_MODELS_URL"]
|
||||
export const OPENCODE_MODELS_PATH = process.env["OPENCODE_MODELS_PATH"]
|
||||
export const OPENCODE_DISABLE_CHANNEL_DB = truthy("OPENCODE_DISABLE_CHANNEL_DB")
|
||||
|
||||
function number(key: string) {
|
||||
const value = process.env[key]
|
||||
|
||||
@@ -3,6 +3,7 @@ import { BunProc } from "../bun"
|
||||
import { Instance } from "../project/instance"
|
||||
import { Filesystem } from "../util/filesystem"
|
||||
import { Process } from "../util/process"
|
||||
import { which } from "../util/which"
|
||||
import { Flag } from "@/flag/flag"
|
||||
|
||||
export interface Info {
|
||||
@@ -18,7 +19,7 @@ export const gofmt: Info = {
|
||||
command: ["gofmt", "-w", "$FILE"],
|
||||
extensions: [".go"],
|
||||
async enabled() {
|
||||
return Bun.which("gofmt") !== null
|
||||
return which("gofmt") !== null
|
||||
},
|
||||
}
|
||||
|
||||
@@ -27,7 +28,7 @@ export const mix: Info = {
|
||||
command: ["mix", "format", "$FILE"],
|
||||
extensions: [".ex", ".exs", ".eex", ".heex", ".leex", ".neex", ".sface"],
|
||||
async enabled() {
|
||||
return Bun.which("mix") !== null
|
||||
return which("mix") !== null
|
||||
},
|
||||
}
|
||||
|
||||
@@ -152,7 +153,7 @@ export const zig: Info = {
|
||||
command: ["zig", "fmt", "$FILE"],
|
||||
extensions: [".zig", ".zon"],
|
||||
async enabled() {
|
||||
return Bun.which("zig") !== null
|
||||
return which("zig") !== null
|
||||
},
|
||||
}
|
||||
|
||||
@@ -171,7 +172,7 @@ export const ktlint: Info = {
|
||||
command: ["ktlint", "-F", "$FILE"],
|
||||
extensions: [".kt", ".kts"],
|
||||
async enabled() {
|
||||
return Bun.which("ktlint") !== null
|
||||
return which("ktlint") !== null
|
||||
},
|
||||
}
|
||||
|
||||
@@ -180,7 +181,7 @@ export const ruff: Info = {
|
||||
command: ["ruff", "format", "$FILE"],
|
||||
extensions: [".py", ".pyi"],
|
||||
async enabled() {
|
||||
if (!Bun.which("ruff")) return false
|
||||
if (!which("ruff")) return false
|
||||
const configs = ["pyproject.toml", "ruff.toml", ".ruff.toml"]
|
||||
for (const config of configs) {
|
||||
const found = await Filesystem.findUp(config, Instance.directory, Instance.worktree)
|
||||
@@ -210,7 +211,7 @@ export const rlang: Info = {
|
||||
command: ["air", "format", "$FILE"],
|
||||
extensions: [".R"],
|
||||
async enabled() {
|
||||
const airPath = Bun.which("air")
|
||||
const airPath = which("air")
|
||||
if (airPath == null) return false
|
||||
|
||||
try {
|
||||
@@ -239,7 +240,7 @@ export const uvformat: Info = {
|
||||
extensions: [".py", ".pyi"],
|
||||
async enabled() {
|
||||
if (await ruff.enabled()) return false
|
||||
if (Bun.which("uv") !== null) {
|
||||
if (which("uv") !== null) {
|
||||
const proc = Process.spawn(["uv", "format", "--help"], { stderr: "pipe", stdout: "pipe" })
|
||||
const code = await proc.exited
|
||||
return code === 0
|
||||
@@ -253,7 +254,7 @@ export const rubocop: Info = {
|
||||
command: ["rubocop", "--autocorrect", "$FILE"],
|
||||
extensions: [".rb", ".rake", ".gemspec", ".ru"],
|
||||
async enabled() {
|
||||
return Bun.which("rubocop") !== null
|
||||
return which("rubocop") !== null
|
||||
},
|
||||
}
|
||||
|
||||
@@ -262,7 +263,7 @@ export const standardrb: Info = {
|
||||
command: ["standardrb", "--fix", "$FILE"],
|
||||
extensions: [".rb", ".rake", ".gemspec", ".ru"],
|
||||
async enabled() {
|
||||
return Bun.which("standardrb") !== null
|
||||
return which("standardrb") !== null
|
||||
},
|
||||
}
|
||||
|
||||
@@ -271,7 +272,7 @@ export const htmlbeautifier: Info = {
|
||||
command: ["htmlbeautifier", "$FILE"],
|
||||
extensions: [".erb", ".html.erb"],
|
||||
async enabled() {
|
||||
return Bun.which("htmlbeautifier") !== null
|
||||
return which("htmlbeautifier") !== null
|
||||
},
|
||||
}
|
||||
|
||||
@@ -280,7 +281,7 @@ export const dart: Info = {
|
||||
command: ["dart", "format", "$FILE"],
|
||||
extensions: [".dart"],
|
||||
async enabled() {
|
||||
return Bun.which("dart") !== null
|
||||
return which("dart") !== null
|
||||
},
|
||||
}
|
||||
|
||||
@@ -289,7 +290,7 @@ export const ocamlformat: Info = {
|
||||
command: ["ocamlformat", "-i", "$FILE"],
|
||||
extensions: [".ml", ".mli"],
|
||||
async enabled() {
|
||||
if (!Bun.which("ocamlformat")) return false
|
||||
if (!which("ocamlformat")) return false
|
||||
const items = await Filesystem.findUp(".ocamlformat", Instance.directory, Instance.worktree)
|
||||
return items.length > 0
|
||||
},
|
||||
@@ -300,7 +301,7 @@ export const terraform: Info = {
|
||||
command: ["terraform", "fmt", "$FILE"],
|
||||
extensions: [".tf", ".tfvars"],
|
||||
async enabled() {
|
||||
return Bun.which("terraform") !== null
|
||||
return which("terraform") !== null
|
||||
},
|
||||
}
|
||||
|
||||
@@ -309,7 +310,7 @@ export const latexindent: Info = {
|
||||
command: ["latexindent", "-w", "-s", "$FILE"],
|
||||
extensions: [".tex"],
|
||||
async enabled() {
|
||||
return Bun.which("latexindent") !== null
|
||||
return which("latexindent") !== null
|
||||
},
|
||||
}
|
||||
|
||||
@@ -318,7 +319,7 @@ export const gleam: Info = {
|
||||
command: ["gleam", "format", "$FILE"],
|
||||
extensions: [".gleam"],
|
||||
async enabled() {
|
||||
return Bun.which("gleam") !== null
|
||||
return which("gleam") !== null
|
||||
},
|
||||
}
|
||||
|
||||
@@ -327,7 +328,7 @@ export const shfmt: Info = {
|
||||
command: ["shfmt", "-w", "$FILE"],
|
||||
extensions: [".sh", ".bash"],
|
||||
async enabled() {
|
||||
return Bun.which("shfmt") !== null
|
||||
return which("shfmt") !== null
|
||||
},
|
||||
}
|
||||
|
||||
@@ -336,7 +337,7 @@ export const nixfmt: Info = {
|
||||
command: ["nixfmt", "$FILE"],
|
||||
extensions: [".nix"],
|
||||
async enabled() {
|
||||
return Bun.which("nixfmt") !== null
|
||||
return which("nixfmt") !== null
|
||||
},
|
||||
}
|
||||
|
||||
@@ -345,7 +346,7 @@ export const rustfmt: Info = {
|
||||
command: ["rustfmt", "$FILE"],
|
||||
extensions: [".rs"],
|
||||
async enabled() {
|
||||
return Bun.which("rustfmt") !== null
|
||||
return which("rustfmt") !== null
|
||||
},
|
||||
}
|
||||
|
||||
@@ -372,7 +373,7 @@ export const ormolu: Info = {
|
||||
command: ["ormolu", "-i", "$FILE"],
|
||||
extensions: [".hs"],
|
||||
async enabled() {
|
||||
return Bun.which("ormolu") !== null
|
||||
return which("ormolu") !== null
|
||||
},
|
||||
}
|
||||
|
||||
@@ -381,7 +382,7 @@ export const cljfmt: Info = {
|
||||
command: ["cljfmt", "fix", "--quiet", "$FILE"],
|
||||
extensions: [".clj", ".cljs", ".cljc", ".edn"],
|
||||
async enabled() {
|
||||
return Bun.which("cljfmt") !== null
|
||||
return which("cljfmt") !== null
|
||||
},
|
||||
}
|
||||
|
||||
@@ -390,6 +391,6 @@ export const dfmt: Info = {
|
||||
command: ["dfmt", "-i", "$FILE"],
|
||||
extensions: [".d"],
|
||||
async enabled() {
|
||||
return Bun.which("dfmt") !== null
|
||||
return which("dfmt") !== null
|
||||
},
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ import { Instance } from "../project/instance"
|
||||
import { Flag } from "../flag/flag"
|
||||
import { Archive } from "../util/archive"
|
||||
import { Process } from "../util/process"
|
||||
import { which } from "../util/which"
|
||||
|
||||
export namespace LSPServer {
|
||||
const log = Log.create({ service: "lsp.server" })
|
||||
@@ -75,7 +76,7 @@ export namespace LSPServer {
|
||||
},
|
||||
extensions: [".ts", ".tsx", ".js", ".jsx", ".mjs"],
|
||||
async spawn(root) {
|
||||
const deno = Bun.which("deno")
|
||||
const deno = which("deno")
|
||||
if (!deno) {
|
||||
log.info("deno not found, please install deno first")
|
||||
return
|
||||
@@ -122,7 +123,7 @@ export namespace LSPServer {
|
||||
extensions: [".vue"],
|
||||
root: NearestRoot(["package-lock.json", "bun.lockb", "bun.lock", "pnpm-lock.yaml", "yarn.lock"]),
|
||||
async spawn(root) {
|
||||
let binary = Bun.which("vue-language-server")
|
||||
let binary = which("vue-language-server")
|
||||
const args: string[] = []
|
||||
if (!binary) {
|
||||
const js = path.join(
|
||||
@@ -260,7 +261,7 @@ export namespace LSPServer {
|
||||
|
||||
let lintBin = await resolveBin(lintTarget)
|
||||
if (!lintBin) {
|
||||
const found = Bun.which("oxlint")
|
||||
const found = which("oxlint")
|
||||
if (found) lintBin = found
|
||||
}
|
||||
|
||||
@@ -281,7 +282,7 @@ export namespace LSPServer {
|
||||
|
||||
let serverBin = await resolveBin(serverTarget)
|
||||
if (!serverBin) {
|
||||
const found = Bun.which("oxc_language_server")
|
||||
const found = which("oxc_language_server")
|
||||
if (found) serverBin = found
|
||||
}
|
||||
if (serverBin) {
|
||||
@@ -332,7 +333,7 @@ export namespace LSPServer {
|
||||
let bin: string | undefined
|
||||
if (await Filesystem.exists(localBin)) bin = localBin
|
||||
if (!bin) {
|
||||
const found = Bun.which("biome")
|
||||
const found = which("biome")
|
||||
if (found) bin = found
|
||||
}
|
||||
|
||||
@@ -368,11 +369,11 @@ export namespace LSPServer {
|
||||
},
|
||||
extensions: [".go"],
|
||||
async spawn(root) {
|
||||
let bin = Bun.which("gopls", {
|
||||
let bin = which("gopls", {
|
||||
PATH: process.env["PATH"] + path.delimiter + Global.Path.bin,
|
||||
})
|
||||
if (!bin) {
|
||||
if (!Bun.which("go")) return
|
||||
if (!which("go")) return
|
||||
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
|
||||
|
||||
log.info("installing gopls")
|
||||
@@ -405,12 +406,12 @@ export namespace LSPServer {
|
||||
root: NearestRoot(["Gemfile"]),
|
||||
extensions: [".rb", ".rake", ".gemspec", ".ru"],
|
||||
async spawn(root) {
|
||||
let bin = Bun.which("rubocop", {
|
||||
let bin = which("rubocop", {
|
||||
PATH: process.env["PATH"] + path.delimiter + Global.Path.bin,
|
||||
})
|
||||
if (!bin) {
|
||||
const ruby = Bun.which("ruby")
|
||||
const gem = Bun.which("gem")
|
||||
const ruby = which("ruby")
|
||||
const gem = which("gem")
|
||||
if (!ruby || !gem) {
|
||||
log.info("Ruby not found, please install Ruby first")
|
||||
return
|
||||
@@ -457,7 +458,7 @@ export namespace LSPServer {
|
||||
return undefined
|
||||
}
|
||||
|
||||
let binary = Bun.which("ty")
|
||||
let binary = which("ty")
|
||||
|
||||
const initialization: Record<string, string> = {}
|
||||
|
||||
@@ -509,7 +510,7 @@ export namespace LSPServer {
|
||||
extensions: [".py", ".pyi"],
|
||||
root: NearestRoot(["pyproject.toml", "setup.py", "setup.cfg", "requirements.txt", "Pipfile", "pyrightconfig.json"]),
|
||||
async spawn(root) {
|
||||
let binary = Bun.which("pyright-langserver")
|
||||
let binary = which("pyright-langserver")
|
||||
const args = []
|
||||
if (!binary) {
|
||||
const js = path.join(Global.Path.bin, "node_modules", "pyright", "dist", "pyright-langserver.js")
|
||||
@@ -563,7 +564,7 @@ export namespace LSPServer {
|
||||
extensions: [".ex", ".exs"],
|
||||
root: NearestRoot(["mix.exs", "mix.lock"]),
|
||||
async spawn(root) {
|
||||
let binary = Bun.which("elixir-ls")
|
||||
let binary = which("elixir-ls")
|
||||
if (!binary) {
|
||||
const elixirLsPath = path.join(Global.Path.bin, "elixir-ls")
|
||||
binary = path.join(
|
||||
@@ -574,7 +575,7 @@ export namespace LSPServer {
|
||||
)
|
||||
|
||||
if (!(await Filesystem.exists(binary))) {
|
||||
const elixir = Bun.which("elixir")
|
||||
const elixir = which("elixir")
|
||||
if (!elixir) {
|
||||
log.error("elixir is required to run elixir-ls")
|
||||
return
|
||||
@@ -625,12 +626,12 @@ export namespace LSPServer {
|
||||
extensions: [".zig", ".zon"],
|
||||
root: NearestRoot(["build.zig"]),
|
||||
async spawn(root) {
|
||||
let bin = Bun.which("zls", {
|
||||
let bin = which("zls", {
|
||||
PATH: process.env["PATH"] + path.delimiter + Global.Path.bin,
|
||||
})
|
||||
|
||||
if (!bin) {
|
||||
const zig = Bun.which("zig")
|
||||
const zig = which("zig")
|
||||
if (!zig) {
|
||||
log.error("Zig is required to use zls. Please install Zig first.")
|
||||
return
|
||||
@@ -737,11 +738,11 @@ export namespace LSPServer {
|
||||
root: NearestRoot([".slnx", ".sln", ".csproj", "global.json"]),
|
||||
extensions: [".cs"],
|
||||
async spawn(root) {
|
||||
let bin = Bun.which("csharp-ls", {
|
||||
let bin = which("csharp-ls", {
|
||||
PATH: process.env["PATH"] + path.delimiter + Global.Path.bin,
|
||||
})
|
||||
if (!bin) {
|
||||
if (!Bun.which("dotnet")) {
|
||||
if (!which("dotnet")) {
|
||||
log.error(".NET SDK is required to install csharp-ls")
|
||||
return
|
||||
}
|
||||
@@ -776,11 +777,11 @@ export namespace LSPServer {
|
||||
root: NearestRoot([".slnx", ".sln", ".fsproj", "global.json"]),
|
||||
extensions: [".fs", ".fsi", ".fsx", ".fsscript"],
|
||||
async spawn(root) {
|
||||
let bin = Bun.which("fsautocomplete", {
|
||||
let bin = which("fsautocomplete", {
|
||||
PATH: process.env["PATH"] + path.delimiter + Global.Path.bin,
|
||||
})
|
||||
if (!bin) {
|
||||
if (!Bun.which("dotnet")) {
|
||||
if (!which("dotnet")) {
|
||||
log.error(".NET SDK is required to install fsautocomplete")
|
||||
return
|
||||
}
|
||||
@@ -817,7 +818,7 @@ export namespace LSPServer {
|
||||
async spawn(root) {
|
||||
// Check if sourcekit-lsp is available in the PATH
|
||||
// This is installed with the Swift toolchain
|
||||
const sourcekit = Bun.which("sourcekit-lsp")
|
||||
const sourcekit = which("sourcekit-lsp")
|
||||
if (sourcekit) {
|
||||
return {
|
||||
process: spawn(sourcekit, {
|
||||
@@ -828,7 +829,7 @@ export namespace LSPServer {
|
||||
|
||||
// If sourcekit-lsp not found, check if xcrun is available
|
||||
// This is specific to macOS where sourcekit-lsp is typically installed with Xcode
|
||||
if (!Bun.which("xcrun")) return
|
||||
if (!which("xcrun")) return
|
||||
|
||||
const lspLoc = await $`xcrun --find sourcekit-lsp`.quiet().nothrow()
|
||||
|
||||
@@ -877,7 +878,7 @@ export namespace LSPServer {
|
||||
},
|
||||
extensions: [".rs"],
|
||||
async spawn(root) {
|
||||
const bin = Bun.which("rust-analyzer")
|
||||
const bin = which("rust-analyzer")
|
||||
if (!bin) {
|
||||
log.info("rust-analyzer not found in path, please install it")
|
||||
return
|
||||
@@ -896,7 +897,7 @@ export namespace LSPServer {
|
||||
extensions: [".c", ".cpp", ".cc", ".cxx", ".c++", ".h", ".hpp", ".hh", ".hxx", ".h++"],
|
||||
async spawn(root) {
|
||||
const args = ["--background-index", "--clang-tidy"]
|
||||
const fromPath = Bun.which("clangd")
|
||||
const fromPath = which("clangd")
|
||||
if (fromPath) {
|
||||
return {
|
||||
process: spawn(fromPath, args, {
|
||||
@@ -1041,7 +1042,7 @@ export namespace LSPServer {
|
||||
extensions: [".svelte"],
|
||||
root: NearestRoot(["package-lock.json", "bun.lockb", "bun.lock", "pnpm-lock.yaml", "yarn.lock"]),
|
||||
async spawn(root) {
|
||||
let binary = Bun.which("svelteserver")
|
||||
let binary = which("svelteserver")
|
||||
const args: string[] = []
|
||||
if (!binary) {
|
||||
const js = path.join(Global.Path.bin, "node_modules", "svelte-language-server", "bin", "server.js")
|
||||
@@ -1088,7 +1089,7 @@ export namespace LSPServer {
|
||||
}
|
||||
const tsdk = path.dirname(tsserver)
|
||||
|
||||
let binary = Bun.which("astro-ls")
|
||||
let binary = which("astro-ls")
|
||||
const args: string[] = []
|
||||
if (!binary) {
|
||||
const js = path.join(Global.Path.bin, "node_modules", "@astrojs", "language-server", "bin", "nodeServer.js")
|
||||
@@ -1132,7 +1133,7 @@ export namespace LSPServer {
|
||||
root: NearestRoot(["pom.xml", "build.gradle", "build.gradle.kts", ".project", ".classpath"]),
|
||||
extensions: [".java"],
|
||||
async spawn(root) {
|
||||
const java = Bun.which("java")
|
||||
const java = which("java")
|
||||
if (!java) {
|
||||
log.error("Java 21 or newer is required to run the JDTLS. Please install it first.")
|
||||
return
|
||||
@@ -1324,7 +1325,7 @@ export namespace LSPServer {
|
||||
extensions: [".yaml", ".yml"],
|
||||
root: NearestRoot(["package-lock.json", "bun.lockb", "bun.lock", "pnpm-lock.yaml", "yarn.lock"]),
|
||||
async spawn(root) {
|
||||
let binary = Bun.which("yaml-language-server")
|
||||
let binary = which("yaml-language-server")
|
||||
const args: string[] = []
|
||||
if (!binary) {
|
||||
const js = path.join(
|
||||
@@ -1380,7 +1381,7 @@ export namespace LSPServer {
|
||||
]),
|
||||
extensions: [".lua"],
|
||||
async spawn(root) {
|
||||
let bin = Bun.which("lua-language-server", {
|
||||
let bin = which("lua-language-server", {
|
||||
PATH: process.env["PATH"] + path.delimiter + Global.Path.bin,
|
||||
})
|
||||
|
||||
@@ -1512,7 +1513,7 @@ export namespace LSPServer {
|
||||
extensions: [".php"],
|
||||
root: NearestRoot(["composer.json", "composer.lock", ".php-version"]),
|
||||
async spawn(root) {
|
||||
let binary = Bun.which("intelephense")
|
||||
let binary = which("intelephense")
|
||||
const args: string[] = []
|
||||
if (!binary) {
|
||||
const js = path.join(Global.Path.bin, "node_modules", "intelephense", "lib", "intelephense.js")
|
||||
@@ -1556,7 +1557,7 @@ export namespace LSPServer {
|
||||
extensions: [".prisma"],
|
||||
root: NearestRoot(["schema.prisma", "prisma/schema.prisma", "prisma"], ["package.json"]),
|
||||
async spawn(root) {
|
||||
const prisma = Bun.which("prisma")
|
||||
const prisma = which("prisma")
|
||||
if (!prisma) {
|
||||
log.info("prisma not found, please install prisma")
|
||||
return
|
||||
@@ -1574,7 +1575,7 @@ export namespace LSPServer {
|
||||
extensions: [".dart"],
|
||||
root: NearestRoot(["pubspec.yaml", "analysis_options.yaml"]),
|
||||
async spawn(root) {
|
||||
const dart = Bun.which("dart")
|
||||
const dart = which("dart")
|
||||
if (!dart) {
|
||||
log.info("dart not found, please install dart first")
|
||||
return
|
||||
@@ -1592,7 +1593,7 @@ export namespace LSPServer {
|
||||
extensions: [".ml", ".mli"],
|
||||
root: NearestRoot(["dune-project", "dune-workspace", ".merlin", "opam"]),
|
||||
async spawn(root) {
|
||||
const bin = Bun.which("ocamllsp")
|
||||
const bin = which("ocamllsp")
|
||||
if (!bin) {
|
||||
log.info("ocamllsp not found, please install ocaml-lsp-server")
|
||||
return
|
||||
@@ -1609,7 +1610,7 @@ export namespace LSPServer {
|
||||
extensions: [".sh", ".bash", ".zsh", ".ksh"],
|
||||
root: async () => Instance.directory,
|
||||
async spawn(root) {
|
||||
let binary = Bun.which("bash-language-server")
|
||||
let binary = which("bash-language-server")
|
||||
const args: string[] = []
|
||||
if (!binary) {
|
||||
const js = path.join(Global.Path.bin, "node_modules", "bash-language-server", "out", "cli.js")
|
||||
@@ -1648,7 +1649,7 @@ export namespace LSPServer {
|
||||
extensions: [".tf", ".tfvars"],
|
||||
root: NearestRoot([".terraform.lock.hcl", "terraform.tfstate", "*.tf"]),
|
||||
async spawn(root) {
|
||||
let bin = Bun.which("terraform-ls", {
|
||||
let bin = which("terraform-ls", {
|
||||
PATH: process.env["PATH"] + path.delimiter + Global.Path.bin,
|
||||
})
|
||||
|
||||
@@ -1731,7 +1732,7 @@ export namespace LSPServer {
|
||||
extensions: [".tex", ".bib"],
|
||||
root: NearestRoot([".latexmkrc", "latexmkrc", ".texlabroot", "texlabroot"]),
|
||||
async spawn(root) {
|
||||
let bin = Bun.which("texlab", {
|
||||
let bin = which("texlab", {
|
||||
PATH: process.env["PATH"] + path.delimiter + Global.Path.bin,
|
||||
})
|
||||
|
||||
@@ -1821,7 +1822,7 @@ export namespace LSPServer {
|
||||
extensions: [".dockerfile", "Dockerfile"],
|
||||
root: async () => Instance.directory,
|
||||
async spawn(root) {
|
||||
let binary = Bun.which("docker-langserver")
|
||||
let binary = which("docker-langserver")
|
||||
const args: string[] = []
|
||||
if (!binary) {
|
||||
const js = path.join(Global.Path.bin, "node_modules", "dockerfile-language-server-nodejs", "lib", "server.js")
|
||||
@@ -1860,7 +1861,7 @@ export namespace LSPServer {
|
||||
extensions: [".gleam"],
|
||||
root: NearestRoot(["gleam.toml"]),
|
||||
async spawn(root) {
|
||||
const gleam = Bun.which("gleam")
|
||||
const gleam = which("gleam")
|
||||
if (!gleam) {
|
||||
log.info("gleam not found, please install gleam first")
|
||||
return
|
||||
@@ -1878,9 +1879,9 @@ export namespace LSPServer {
|
||||
extensions: [".clj", ".cljs", ".cljc", ".edn"],
|
||||
root: NearestRoot(["deps.edn", "project.clj", "shadow-cljs.edn", "bb.edn", "build.boot"]),
|
||||
async spawn(root) {
|
||||
let bin = Bun.which("clojure-lsp")
|
||||
let bin = which("clojure-lsp")
|
||||
if (!bin && process.platform === "win32") {
|
||||
bin = Bun.which("clojure-lsp.exe")
|
||||
bin = which("clojure-lsp.exe")
|
||||
}
|
||||
if (!bin) {
|
||||
log.info("clojure-lsp not found, please install clojure-lsp first")
|
||||
@@ -1909,7 +1910,7 @@ export namespace LSPServer {
|
||||
return Instance.directory
|
||||
},
|
||||
async spawn(root) {
|
||||
const nixd = Bun.which("nixd")
|
||||
const nixd = which("nixd")
|
||||
if (!nixd) {
|
||||
log.info("nixd not found, please install nixd first")
|
||||
return
|
||||
@@ -1930,7 +1931,7 @@ export namespace LSPServer {
|
||||
extensions: [".typ", ".typc"],
|
||||
root: NearestRoot(["typst.toml"]),
|
||||
async spawn(root) {
|
||||
let bin = Bun.which("tinymist", {
|
||||
let bin = which("tinymist", {
|
||||
PATH: process.env["PATH"] + path.delimiter + Global.Path.bin,
|
||||
})
|
||||
|
||||
@@ -2024,7 +2025,7 @@ export namespace LSPServer {
|
||||
extensions: [".hs", ".lhs"],
|
||||
root: NearestRoot(["stack.yaml", "cabal.project", "hie.yaml", "*.cabal"]),
|
||||
async spawn(root) {
|
||||
const bin = Bun.which("haskell-language-server-wrapper")
|
||||
const bin = which("haskell-language-server-wrapper")
|
||||
if (!bin) {
|
||||
log.info("haskell-language-server-wrapper not found, please install haskell-language-server")
|
||||
return
|
||||
@@ -2042,7 +2043,7 @@ export namespace LSPServer {
|
||||
extensions: [".jl"],
|
||||
root: NearestRoot(["Project.toml", "Manifest.toml", "*.jl"]),
|
||||
async spawn(root) {
|
||||
const julia = Bun.which("julia")
|
||||
const julia = which("julia")
|
||||
if (!julia) {
|
||||
log.info("julia not found, please install julia first (https://julialang.org/downloads/)")
|
||||
return
|
||||
|
||||
@@ -18,24 +18,60 @@ const disposal = {
|
||||
all: undefined as Promise<void> | undefined,
|
||||
}
|
||||
|
||||
function emit(directory: string) {
|
||||
GlobalBus.emit("event", {
|
||||
directory,
|
||||
payload: {
|
||||
type: "server.instance.disposed",
|
||||
properties: {
|
||||
directory,
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
function boot(input: { directory: string; init?: () => Promise<any>; project?: Project.Info; worktree?: string }) {
|
||||
return iife(async () => {
|
||||
const ctx =
|
||||
input.project && input.worktree
|
||||
? {
|
||||
directory: input.directory,
|
||||
worktree: input.worktree,
|
||||
project: input.project,
|
||||
}
|
||||
: await Project.fromDirectory(input.directory).then(({ project, sandbox }) => ({
|
||||
directory: input.directory,
|
||||
worktree: sandbox,
|
||||
project,
|
||||
}))
|
||||
await context.provide(ctx, async () => {
|
||||
await input.init?.()
|
||||
})
|
||||
return ctx
|
||||
})
|
||||
}
|
||||
|
||||
function track(directory: string, next: Promise<Context>) {
|
||||
const task = next.catch((error) => {
|
||||
if (cache.get(directory) === task) cache.delete(directory)
|
||||
throw error
|
||||
})
|
||||
cache.set(directory, task)
|
||||
return task
|
||||
}
|
||||
|
||||
export const Instance = {
|
||||
async provide<R>(input: { directory: string; init?: () => Promise<any>; fn: () => R }): Promise<R> {
|
||||
let existing = cache.get(input.directory)
|
||||
if (!existing) {
|
||||
Log.Default.info("creating instance", { directory: input.directory })
|
||||
existing = iife(async () => {
|
||||
const { project, sandbox } = await Project.fromDirectory(input.directory)
|
||||
const ctx = {
|
||||
existing = track(
|
||||
input.directory,
|
||||
boot({
|
||||
directory: input.directory,
|
||||
worktree: sandbox,
|
||||
project,
|
||||
}
|
||||
await context.provide(ctx, async () => {
|
||||
await input.init?.()
|
||||
})
|
||||
return ctx
|
||||
})
|
||||
cache.set(input.directory, existing)
|
||||
init: input.init,
|
||||
}),
|
||||
)
|
||||
}
|
||||
const ctx = await existing
|
||||
return context.provide(ctx, async () => {
|
||||
@@ -66,19 +102,19 @@ export const Instance = {
|
||||
state<S>(init: () => S, dispose?: (state: Awaited<S>) => Promise<void>): () => S {
|
||||
return State.create(() => Instance.directory, init, dispose)
|
||||
},
|
||||
async reload(input: { directory: string; init?: () => Promise<any>; project?: Project.Info; worktree?: string }) {
|
||||
Log.Default.info("reloading instance", { directory: input.directory })
|
||||
await State.dispose(input.directory)
|
||||
cache.delete(input.directory)
|
||||
const next = track(input.directory, boot(input))
|
||||
emit(input.directory)
|
||||
return await next
|
||||
},
|
||||
async dispose() {
|
||||
Log.Default.info("disposing instance", { directory: Instance.directory })
|
||||
await State.dispose(Instance.directory)
|
||||
cache.delete(Instance.directory)
|
||||
GlobalBus.emit("event", {
|
||||
directory: Instance.directory,
|
||||
payload: {
|
||||
type: "server.instance.disposed",
|
||||
properties: {
|
||||
directory: Instance.directory,
|
||||
},
|
||||
},
|
||||
})
|
||||
emit(Instance.directory)
|
||||
},
|
||||
async disposeAll() {
|
||||
if (disposal.all) return disposal.all
|
||||
|
||||
@@ -14,6 +14,7 @@ import { GlobalBus } from "@/bus/global"
|
||||
import { existsSync } from "fs"
|
||||
import { git } from "../util/git"
|
||||
import { Glob } from "../util/glob"
|
||||
import { which } from "../util/which"
|
||||
|
||||
export namespace Project {
|
||||
const log = Log.create({ service: "project" })
|
||||
@@ -97,7 +98,7 @@ export namespace Project {
|
||||
if (dotgit) {
|
||||
let sandbox = path.dirname(dotgit)
|
||||
|
||||
const gitBinary = Bun.which("git")
|
||||
const gitBinary = which("git")
|
||||
|
||||
// cached id calculation
|
||||
let id = await Filesystem.readText(path.join(dotgit, "opencode"))
|
||||
@@ -346,6 +347,21 @@ export namespace Project {
|
||||
return fromRow(row)
|
||||
}
|
||||
|
||||
export async function initGit(input: { directory: string; project: Info }) {
|
||||
if (input.project.vcs === "git") return input.project
|
||||
if (!which("git")) throw new Error("Git is not installed")
|
||||
|
||||
const result = await git(["init", "--quiet"], {
|
||||
cwd: input.directory,
|
||||
})
|
||||
if (result.exitCode !== 0) {
|
||||
const text = result.stderr.toString().trim() || result.text().trim()
|
||||
throw new Error(text || "Failed to initialize git repository")
|
||||
}
|
||||
|
||||
return (await fromDirectory(input.directory)).project
|
||||
}
|
||||
|
||||
export const update = fn(
|
||||
z.object({
|
||||
projectID: z.string(),
|
||||
|
||||
@@ -6,6 +6,7 @@ import { Project } from "../../project/project"
|
||||
import z from "zod"
|
||||
import { errors } from "../error"
|
||||
import { lazy } from "../../util/lazy"
|
||||
import { InstanceBootstrap } from "../../project/bootstrap"
|
||||
|
||||
export const ProjectRoutes = lazy(() =>
|
||||
new Hono()
|
||||
@@ -52,6 +53,40 @@ export const ProjectRoutes = lazy(() =>
|
||||
return c.json(Instance.project)
|
||||
},
|
||||
)
|
||||
.post(
|
||||
"/git/init",
|
||||
describeRoute({
|
||||
summary: "Initialize git repository",
|
||||
description: "Create a git repository for the current project and return the refreshed project info.",
|
||||
operationId: "project.initGit",
|
||||
responses: {
|
||||
200: {
|
||||
description: "Project information after git initialization",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(Project.Info),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
const dir = Instance.directory
|
||||
const prev = Instance.project
|
||||
const next = await Project.initGit({
|
||||
directory: dir,
|
||||
project: prev,
|
||||
})
|
||||
if (next.id === prev.id && next.vcs === prev.vcs && next.worktree === prev.worktree) return c.json(next)
|
||||
await Instance.reload({
|
||||
directory: dir,
|
||||
worktree: dir,
|
||||
project: next,
|
||||
init: InstanceBootstrap,
|
||||
})
|
||||
return c.json(next)
|
||||
},
|
||||
)
|
||||
.patch(
|
||||
"/:projectID",
|
||||
describeRoute({
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { Bus } from "@/bus"
|
||||
import { Config } from "@/config/config"
|
||||
import { ulid } from "ulid"
|
||||
import { Provider } from "@/provider/provider"
|
||||
import { Session } from "@/session"
|
||||
import { MessageV2 } from "@/session/message-v2"
|
||||
@@ -122,20 +121,35 @@ export namespace ShareNext {
|
||||
data: SDK.Model[]
|
||||
}
|
||||
|
||||
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"
|
||||
}
|
||||
}
|
||||
|
||||
const queue = new Map<string, { timeout: NodeJS.Timeout; data: Map<string, Data> }>()
|
||||
async function sync(sessionID: string, data: Data[]) {
|
||||
if (disabled) return
|
||||
const existing = queue.get(sessionID)
|
||||
if (existing) {
|
||||
for (const item of data) {
|
||||
existing.data.set("id" in item ? (item.id as string) : ulid(), item)
|
||||
existing.data.set(key(item), item)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
const dataMap = new Map<string, Data>()
|
||||
for (const item of data) {
|
||||
dataMap.set("id" in item ? (item.id as string) : ulid(), item)
|
||||
dataMap.set(key(item), item)
|
||||
}
|
||||
|
||||
const timeout = setTimeout(async () => {
|
||||
@@ -182,10 +196,14 @@ export namespace ShareNext {
|
||||
const diffs = await Session.diff(sessionID)
|
||||
const messages = await Array.fromAsync(MessageV2.stream(sessionID))
|
||||
const models = await Promise.all(
|
||||
messages
|
||||
.filter((m) => m.info.role === "user")
|
||||
.map((m) => (m.info as SDK.UserMessage).model)
|
||||
.map((m) => Provider.getModel(m.providerID, m.modelID).then((m) => m)),
|
||||
Array.from(
|
||||
new Map(
|
||||
messages
|
||||
.filter((m) => m.info.role === "user")
|
||||
.map((m) => (m.info as SDK.UserMessage).model)
|
||||
.map((m) => [`${m.providerID}/${m.modelID}`, m] as const),
|
||||
).values(),
|
||||
).map((m) => Provider.getModel(m.providerID, m.modelID).then((item) => item)),
|
||||
)
|
||||
await sync(sessionID, [
|
||||
{
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Flag } from "@/flag/flag"
|
||||
import { lazy } from "@/util/lazy"
|
||||
import { Filesystem } from "@/util/filesystem"
|
||||
import { which } from "@/util/which"
|
||||
import path from "path"
|
||||
import { spawn, type ChildProcess } from "child_process"
|
||||
import { setTimeout as sleep } from "node:timers/promises"
|
||||
@@ -40,7 +41,7 @@ export namespace Shell {
|
||||
function fallback() {
|
||||
if (process.platform === "win32") {
|
||||
if (Flag.OPENCODE_GIT_BASH_PATH) return Flag.OPENCODE_GIT_BASH_PATH
|
||||
const git = Bun.which("git")
|
||||
const git = which("git")
|
||||
if (git) {
|
||||
// git.exe is typically at: C:\Program Files\Git\cmd\git.exe
|
||||
// bash.exe is at: C:\Program Files\Git\bin\bash.exe
|
||||
@@ -50,7 +51,7 @@ export namespace Shell {
|
||||
return process.env.COMSPEC || "cmd.exe"
|
||||
}
|
||||
if (process.platform === "darwin") return "/bin/zsh"
|
||||
const bash = Bun.which("bash")
|
||||
const bash = which("bash")
|
||||
if (bash) return bash
|
||||
return "/bin/sh"
|
||||
}
|
||||
|
||||
@@ -12,8 +12,10 @@ import z from "zod"
|
||||
import path from "path"
|
||||
import { readFileSync, readdirSync, existsSync } from "fs"
|
||||
import * as schema from "./schema"
|
||||
import { Installation } from "../installation"
|
||||
import { Flag } from "../flag/flag"
|
||||
|
||||
declare const OPENCODE_MIGRATIONS: { sql: string; timestamp: number }[] | undefined
|
||||
declare const OPENCODE_MIGRATIONS: { sql: string; timestamp: number; name: string }[] | undefined
|
||||
|
||||
export const NotFoundError = NamedError.create(
|
||||
"NotFoundError",
|
||||
@@ -25,13 +27,20 @@ export const NotFoundError = NamedError.create(
|
||||
const log = Log.create({ service: "db" })
|
||||
|
||||
export namespace Database {
|
||||
export const Path = path.join(Global.Path.data, "opencode.db")
|
||||
export const Path = (() => {
|
||||
const name =
|
||||
Installation.CHANNEL !== "latest" && !Flag.OPENCODE_DISABLE_CHANNEL_DB
|
||||
? `opencode-${Installation.CHANNEL}.db`
|
||||
: "opencode.db"
|
||||
return path.join(Global.Path.data, name)
|
||||
})()
|
||||
|
||||
type Schema = typeof schema
|
||||
export type Transaction = SQLiteTransaction<"sync", void, Schema>
|
||||
|
||||
type Client = SQLiteBunDatabase<Schema>
|
||||
|
||||
type Journal = { sql: string; timestamp: number }[]
|
||||
type Journal = { sql: string; timestamp: number; name: string }[]
|
||||
|
||||
const state = {
|
||||
sqlite: undefined as BunDatabase | undefined,
|
||||
@@ -62,6 +71,7 @@ export namespace Database {
|
||||
return {
|
||||
sql: readFileSync(file, "utf-8"),
|
||||
timestamp: time(name),
|
||||
name,
|
||||
}
|
||||
})
|
||||
.filter(Boolean) as Journal
|
||||
@@ -70,9 +80,9 @@ export namespace Database {
|
||||
}
|
||||
|
||||
export const Client = lazy(() => {
|
||||
log.info("opening database", { path: path.join(Global.Path.data, "opencode.db") })
|
||||
log.info("opening database", { path: Path })
|
||||
|
||||
const sqlite = new BunDatabase(path.join(Global.Path.data, "opencode.db"), { create: true })
|
||||
const sqlite = new BunDatabase(Path, { create: true })
|
||||
state.sqlite = sqlite
|
||||
|
||||
sqlite.run("PRAGMA journal_mode = WAL")
|
||||
@@ -143,7 +153,7 @@ export namespace Database {
|
||||
} catch (err) {
|
||||
if (err instanceof Context.NotFound) {
|
||||
const effects: (() => void | Promise<void>)[] = []
|
||||
const result = Client().transaction((tx) => {
|
||||
const result = (Client().transaction as any)((tx: TxOrDb) => {
|
||||
return ctx.provide({ tx, effects }, () => callback(tx))
|
||||
})
|
||||
for (const effect of effects) effect()
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user