Compare commits
63
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
64d689171f | ||
|
|
4fba3280e8 | ||
|
|
5b5b791d75 | ||
|
|
0b7a5b1e7b | ||
|
|
28bb16ca2a | ||
|
|
8a95be492d | ||
|
|
c42c5a0cc6 | ||
|
|
b2c2478d9d | ||
|
|
1a9af8acb6 | ||
|
|
4c7fe60493 | ||
|
|
c108f304c6 | ||
|
|
2b8acfa0e2 | ||
|
|
b83282b940 | ||
|
|
c4fd677785 | ||
|
|
770cb66628 | ||
|
|
b0bc3d87f5 | ||
|
|
a2634337b8 | ||
|
|
7417c869fc | ||
|
|
091cf25de8 | ||
|
|
7a071eff5c | ||
|
|
7da24ebf5d | ||
|
|
d6e0f47361 | ||
|
|
95385eb652 | ||
|
|
a71b11caca | ||
|
|
e9568999c3 | ||
|
|
5e699c9426 | ||
|
|
e0ca52ed1f | ||
|
|
1d9dcd2a27 | ||
|
|
eeeb21ff86 | ||
|
|
2094e8b255 | ||
|
|
e1cf761d29 | ||
|
|
f64bb91257 | ||
|
|
eb9eb5e334 | ||
|
|
d4d1292a0e | ||
|
|
b7605add58 | ||
|
|
6c7d968c44 | ||
|
|
a34148bc06 | ||
|
|
326c70184d | ||
|
|
ef288cf93d | ||
|
|
aec6ca71fa | ||
|
|
c04da45be5 | ||
|
|
74effa8eec | ||
|
|
cb411248bf | ||
|
|
46d7d2fdc0 | ||
|
|
d68afcaa55 | ||
|
|
bf35a865ba | ||
|
|
6733a5a822 | ||
|
|
7e28098365 | ||
|
|
ae5c9ed3dd | ||
|
|
a9bf1c0505 | ||
|
|
dad248832d | ||
|
|
6e89d3e597 | ||
|
|
3ebba02d04 | ||
|
|
cf425d114e | ||
|
|
39691e5174 | ||
|
|
adaee66364 | ||
|
|
a6978167ae | ||
|
|
80c36c788c | ||
|
|
76cdc668e8 | ||
|
|
2ba1ecabc9 | ||
|
|
e3b6d84b57 | ||
|
|
0638e49b02 | ||
|
|
2c58964a6b |
@@ -1,6 +1,6 @@
|
|||||||
Use this tool to search GitHub pull requests by title and description.
|
Use this tool to search GitHub pull requests by title and description.
|
||||||
|
|
||||||
This tool searches PRs in the sst/opencode repository and returns LLM-friendly results including:
|
This tool searches PRs in the anomalyco/opencode repository and returns LLM-friendly results including:
|
||||||
- PR number and title
|
- PR number and title
|
||||||
- Author
|
- Author
|
||||||
- State (open/closed/merged)
|
- State (open/closed/merged)
|
||||||
|
|||||||
+2
-1
@@ -35,7 +35,8 @@
|
|||||||
<a href="README.tr.md">Türkçe</a> |
|
<a href="README.tr.md">Türkçe</a> |
|
||||||
<a href="README.uk.md">Українська</a> |
|
<a href="README.uk.md">Українська</a> |
|
||||||
<a href="README.bn.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>
|
</p>
|
||||||
|
|
||||||
[](https://opencode.ai)
|
[](https://opencode.ai)
|
||||||
|
|||||||
+2
-1
@@ -35,7 +35,8 @@
|
|||||||
<a href="README.tr.md">Türkçe</a> |
|
<a href="README.tr.md">Türkçe</a> |
|
||||||
<a href="README.uk.md">Українська</a> |
|
<a href="README.uk.md">Українська</a> |
|
||||||
<a href="README.bn.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>
|
</p>
|
||||||
|
|
||||||
[](https://opencode.ai)
|
[](https://opencode.ai)
|
||||||
|
|||||||
+3
-1
@@ -27,6 +27,7 @@
|
|||||||
<a href="README.ja.md">日本語</a> |
|
<a href="README.ja.md">日本語</a> |
|
||||||
<a href="README.pl.md">Polski</a> |
|
<a href="README.pl.md">Polski</a> |
|
||||||
<a href="README.ru.md">Русский</a> |
|
<a href="README.ru.md">Русский</a> |
|
||||||
|
<a href="README.bs.md">Bosanski</a> |
|
||||||
<a href="README.ar.md">العربية</a> |
|
<a href="README.ar.md">العربية</a> |
|
||||||
<a href="README.no.md">Norsk</a> |
|
<a href="README.no.md">Norsk</a> |
|
||||||
<a href="README.br.md">Português (Brasil)</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.tr.md">Türkçe</a> |
|
||||||
<a href="README.uk.md">Українська</a> |
|
<a href="README.uk.md">Українська</a> |
|
||||||
<a href="README.bn.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>
|
</p>
|
||||||
|
|
||||||
[](https://opencode.ai)
|
[](https://opencode.ai)
|
||||||
|
|||||||
+2
-1
@@ -35,7 +35,8 @@
|
|||||||
<a href="README.tr.md">Türkçe</a> |
|
<a href="README.tr.md">Türkçe</a> |
|
||||||
<a href="README.uk.md">Українська</a> |
|
<a href="README.uk.md">Українська</a> |
|
||||||
<a href="README.bn.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>
|
</p>
|
||||||
|
|
||||||
[](https://opencode.ai)
|
[](https://opencode.ai)
|
||||||
|
|||||||
+3
-1
@@ -27,6 +27,7 @@
|
|||||||
<a href="README.ja.md">日本語</a> |
|
<a href="README.ja.md">日本語</a> |
|
||||||
<a href="README.pl.md">Polski</a> |
|
<a href="README.pl.md">Polski</a> |
|
||||||
<a href="README.ru.md">Русский</a> |
|
<a href="README.ru.md">Русский</a> |
|
||||||
|
<a href="README.bs.md">Bosanski</a> |
|
||||||
<a href="README.ar.md">العربية</a> |
|
<a href="README.ar.md">العربية</a> |
|
||||||
<a href="README.no.md">Norsk</a> |
|
<a href="README.no.md">Norsk</a> |
|
||||||
<a href="README.br.md">Português (Brasil)</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.tr.md">Türkçe</a> |
|
||||||
<a href="README.uk.md">Українська</a> |
|
<a href="README.uk.md">Українська</a> |
|
||||||
<a href="README.bn.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>
|
</p>
|
||||||
|
|
||||||
[](https://opencode.ai)
|
[](https://opencode.ai)
|
||||||
|
|||||||
+3
-1
@@ -27,6 +27,7 @@
|
|||||||
<a href="README.ja.md">日本語</a> |
|
<a href="README.ja.md">日本語</a> |
|
||||||
<a href="README.pl.md">Polski</a> |
|
<a href="README.pl.md">Polski</a> |
|
||||||
<a href="README.ru.md">Русский</a> |
|
<a href="README.ru.md">Русский</a> |
|
||||||
|
<a href="README.bs.md">Bosanski</a> |
|
||||||
<a href="README.ar.md">العربية</a> |
|
<a href="README.ar.md">العربية</a> |
|
||||||
<a href="README.no.md">Norsk</a> |
|
<a href="README.no.md">Norsk</a> |
|
||||||
<a href="README.br.md">Português (Brasil)</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.tr.md">Türkçe</a> |
|
||||||
<a href="README.uk.md">Українська</a> |
|
<a href="README.uk.md">Українська</a> |
|
||||||
<a href="README.bn.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>
|
</p>
|
||||||
|
|
||||||
[](https://opencode.ai)
|
[](https://opencode.ai)
|
||||||
|
|||||||
+3
-1
@@ -27,6 +27,7 @@
|
|||||||
<a href="README.ja.md">日本語</a> |
|
<a href="README.ja.md">日本語</a> |
|
||||||
<a href="README.pl.md">Polski</a> |
|
<a href="README.pl.md">Polski</a> |
|
||||||
<a href="README.ru.md">Русский</a> |
|
<a href="README.ru.md">Русский</a> |
|
||||||
|
<a href="README.bs.md">Bosanski</a> |
|
||||||
<a href="README.ar.md">العربية</a> |
|
<a href="README.ar.md">العربية</a> |
|
||||||
<a href="README.no.md">Norsk</a> |
|
<a href="README.no.md">Norsk</a> |
|
||||||
<a href="README.br.md">Português (Brasil)</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.tr.md">Türkçe</a> |
|
||||||
<a href="README.uk.md">Українська</a> |
|
<a href="README.uk.md">Українська</a> |
|
||||||
<a href="README.bn.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>
|
</p>
|
||||||
|
|
||||||
[](https://opencode.ai)
|
[](https://opencode.ai)
|
||||||
|
|||||||
+3
-1
@@ -27,6 +27,7 @@
|
|||||||
<a href="README.ja.md">日本語</a> |
|
<a href="README.ja.md">日本語</a> |
|
||||||
<a href="README.pl.md">Polski</a> |
|
<a href="README.pl.md">Polski</a> |
|
||||||
<a href="README.ru.md">Русский</a> |
|
<a href="README.ru.md">Русский</a> |
|
||||||
|
<a href="README.bs.md">Bosanski</a> |
|
||||||
<a href="README.ar.md">العربية</a> |
|
<a href="README.ar.md">العربية</a> |
|
||||||
<a href="README.no.md">Norsk</a> |
|
<a href="README.no.md">Norsk</a> |
|
||||||
<a href="README.br.md">Português (Brasil)</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.tr.md">Türkçe</a> |
|
||||||
<a href="README.uk.md">Українська</a> |
|
<a href="README.uk.md">Українська</a> |
|
||||||
<a href="README.bn.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>
|
</p>
|
||||||
|
|
||||||
[](https://opencode.ai)
|
[](https://opencode.ai)
|
||||||
|
|||||||
+2
-1
@@ -35,7 +35,8 @@
|
|||||||
<a href="README.tr.md">Türkçe</a> |
|
<a href="README.tr.md">Türkçe</a> |
|
||||||
<a href="README.uk.md">Українська</a> |
|
<a href="README.uk.md">Українська</a> |
|
||||||
<a href="README.bn.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>
|
</p>
|
||||||
|
|
||||||
[](https://opencode.ai)
|
[](https://opencode.ai)
|
||||||
|
|||||||
+3
-1
@@ -27,6 +27,7 @@
|
|||||||
<a href="README.ja.md">日本語</a> |
|
<a href="README.ja.md">日本語</a> |
|
||||||
<a href="README.pl.md">Polski</a> |
|
<a href="README.pl.md">Polski</a> |
|
||||||
<a href="README.ru.md">Русский</a> |
|
<a href="README.ru.md">Русский</a> |
|
||||||
|
<a href="README.bs.md">Bosanski</a> |
|
||||||
<a href="README.ar.md">العربية</a> |
|
<a href="README.ar.md">العربية</a> |
|
||||||
<a href="README.no.md">Norsk</a> |
|
<a href="README.no.md">Norsk</a> |
|
||||||
<a href="README.br.md">Português (Brasil)</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.tr.md">Türkçe</a> |
|
||||||
<a href="README.uk.md">Українська</a> |
|
<a href="README.uk.md">Українська</a> |
|
||||||
<a href="README.bn.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>
|
</p>
|
||||||
|
|
||||||
[](https://opencode.ai)
|
[](https://opencode.ai)
|
||||||
|
|||||||
+3
-1
@@ -27,6 +27,7 @@
|
|||||||
<a href="README.ja.md">日本語</a> |
|
<a href="README.ja.md">日本語</a> |
|
||||||
<a href="README.pl.md">Polski</a> |
|
<a href="README.pl.md">Polski</a> |
|
||||||
<a href="README.ru.md">Русский</a> |
|
<a href="README.ru.md">Русский</a> |
|
||||||
|
<a href="README.bs.md">Bosanski</a> |
|
||||||
<a href="README.ar.md">العربية</a> |
|
<a href="README.ar.md">العربية</a> |
|
||||||
<a href="README.no.md">Norsk</a> |
|
<a href="README.no.md">Norsk</a> |
|
||||||
<a href="README.br.md">Português (Brasil)</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.tr.md">Türkçe</a> |
|
||||||
<a href="README.uk.md">Українська</a> |
|
<a href="README.uk.md">Українська</a> |
|
||||||
<a href="README.bn.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>
|
</p>
|
||||||
|
|
||||||
[](https://opencode.ai)
|
[](https://opencode.ai)
|
||||||
|
|||||||
+3
-1
@@ -27,6 +27,7 @@
|
|||||||
<a href="README.ja.md">日本語</a> |
|
<a href="README.ja.md">日本語</a> |
|
||||||
<a href="README.pl.md">Polski</a> |
|
<a href="README.pl.md">Polski</a> |
|
||||||
<a href="README.ru.md">Русский</a> |
|
<a href="README.ru.md">Русский</a> |
|
||||||
|
<a href="README.bs.md">Bosanski</a> |
|
||||||
<a href="README.ar.md">العربية</a> |
|
<a href="README.ar.md">العربية</a> |
|
||||||
<a href="README.no.md">Norsk</a> |
|
<a href="README.no.md">Norsk</a> |
|
||||||
<a href="README.br.md">Português (Brasil)</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.tr.md">Türkçe</a> |
|
||||||
<a href="README.uk.md">Українська</a> |
|
<a href="README.uk.md">Українська</a> |
|
||||||
<a href="README.bn.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>
|
</p>
|
||||||
|
|
||||||
[](https://opencode.ai)
|
[](https://opencode.ai)
|
||||||
|
|||||||
@@ -35,7 +35,8 @@
|
|||||||
<a href="README.tr.md">Türkçe</a> |
|
<a href="README.tr.md">Türkçe</a> |
|
||||||
<a href="README.uk.md">Українська</a> |
|
<a href="README.uk.md">Українська</a> |
|
||||||
<a href="README.bn.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>
|
</p>
|
||||||
|
|
||||||
[](https://opencode.ai)
|
[](https://opencode.ai)
|
||||||
|
|||||||
+3
-1
@@ -27,6 +27,7 @@
|
|||||||
<a href="README.ja.md">日本語</a> |
|
<a href="README.ja.md">日本語</a> |
|
||||||
<a href="README.pl.md">Polski</a> |
|
<a href="README.pl.md">Polski</a> |
|
||||||
<a href="README.ru.md">Русский</a> |
|
<a href="README.ru.md">Русский</a> |
|
||||||
|
<a href="README.bs.md">Bosanski</a> |
|
||||||
<a href="README.ar.md">العربية</a> |
|
<a href="README.ar.md">العربية</a> |
|
||||||
<a href="README.no.md">Norsk</a> |
|
<a href="README.no.md">Norsk</a> |
|
||||||
<a href="README.br.md">Português (Brasil)</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.tr.md">Türkçe</a> |
|
||||||
<a href="README.uk.md">Українська</a> |
|
<a href="README.uk.md">Українська</a> |
|
||||||
<a href="README.bn.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>
|
</p>
|
||||||
|
|
||||||
[](https://opencode.ai)
|
[](https://opencode.ai)
|
||||||
|
|||||||
+3
-1
@@ -27,6 +27,7 @@
|
|||||||
<a href="README.ja.md">日本語</a> |
|
<a href="README.ja.md">日本語</a> |
|
||||||
<a href="README.pl.md">Polski</a> |
|
<a href="README.pl.md">Polski</a> |
|
||||||
<a href="README.ru.md">Русский</a> |
|
<a href="README.ru.md">Русский</a> |
|
||||||
|
<a href="README.bs.md">Bosanski</a> |
|
||||||
<a href="README.ar.md">العربية</a> |
|
<a href="README.ar.md">العربية</a> |
|
||||||
<a href="README.no.md">Norsk</a> |
|
<a href="README.no.md">Norsk</a> |
|
||||||
<a href="README.br.md">Português (Brasil)</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.tr.md">Türkçe</a> |
|
||||||
<a href="README.uk.md">Українська</a> |
|
<a href="README.uk.md">Українська</a> |
|
||||||
<a href="README.bn.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>
|
</p>
|
||||||
|
|
||||||
[](https://opencode.ai)
|
[](https://opencode.ai)
|
||||||
|
|||||||
+3
-1
@@ -27,6 +27,7 @@
|
|||||||
<a href="README.ja.md">日本語</a> |
|
<a href="README.ja.md">日本語</a> |
|
||||||
<a href="README.pl.md">Polski</a> |
|
<a href="README.pl.md">Polski</a> |
|
||||||
<a href="README.ru.md">Русский</a> |
|
<a href="README.ru.md">Русский</a> |
|
||||||
|
<a href="README.bs.md">Bosanski</a> |
|
||||||
<a href="README.ar.md">العربية</a> |
|
<a href="README.ar.md">العربية</a> |
|
||||||
<a href="README.no.md">Norsk</a> |
|
<a href="README.no.md">Norsk</a> |
|
||||||
<a href="README.br.md">Português (Brasil)</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.tr.md">Türkçe</a> |
|
||||||
<a href="README.uk.md">Українська</a> |
|
<a href="README.uk.md">Українська</a> |
|
||||||
<a href="README.bn.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>
|
</p>
|
||||||
|
|
||||||
[](https://opencode.ai)
|
[](https://opencode.ai)
|
||||||
|
|||||||
+3
-1
@@ -27,6 +27,7 @@
|
|||||||
<a href="README.ja.md">日本語</a> |
|
<a href="README.ja.md">日本語</a> |
|
||||||
<a href="README.pl.md">Polski</a> |
|
<a href="README.pl.md">Polski</a> |
|
||||||
<a href="README.ru.md">Русский</a> |
|
<a href="README.ru.md">Русский</a> |
|
||||||
|
<a href="README.bs.md">Bosanski</a> |
|
||||||
<a href="README.ar.md">العربية</a> |
|
<a href="README.ar.md">العربية</a> |
|
||||||
<a href="README.no.md">Norsk</a> |
|
<a href="README.no.md">Norsk</a> |
|
||||||
<a href="README.br.md">Português (Brasil)</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.tr.md">Türkçe</a> |
|
||||||
<a href="README.uk.md">Українська</a> |
|
<a href="README.uk.md">Українська</a> |
|
||||||
<a href="README.bn.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>
|
</p>
|
||||||
|
|
||||||
[](https://opencode.ai)
|
[](https://opencode.ai)
|
||||||
|
|||||||
+3
-1
@@ -27,6 +27,7 @@
|
|||||||
<a href="README.ja.md">日本語</a> |
|
<a href="README.ja.md">日本語</a> |
|
||||||
<a href="README.pl.md">Polski</a> |
|
<a href="README.pl.md">Polski</a> |
|
||||||
<a href="README.ru.md">Русский</a> |
|
<a href="README.ru.md">Русский</a> |
|
||||||
|
<a href="README.bs.md">Bosanski</a> |
|
||||||
<a href="README.ar.md">العربية</a> |
|
<a href="README.ar.md">العربية</a> |
|
||||||
<a href="README.no.md">Norsk</a> |
|
<a href="README.no.md">Norsk</a> |
|
||||||
<a href="README.br.md">Português (Brasil)</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.tr.md">Türkçe</a> |
|
||||||
<a href="README.uk.md">Українська</a> |
|
<a href="README.uk.md">Українська</a> |
|
||||||
<a href="README.bn.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>
|
</p>
|
||||||
|
|
||||||
[](https://opencode.ai)
|
[](https://opencode.ai)
|
||||||
|
|||||||
+2
-1
@@ -35,7 +35,8 @@
|
|||||||
<a href="README.tr.md">Türkçe</a> |
|
<a href="README.tr.md">Türkçe</a> |
|
||||||
<a href="README.uk.md">Українська</a> |
|
<a href="README.uk.md">Українська</a> |
|
||||||
<a href="README.bn.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>
|
</p>
|
||||||
|
|
||||||
[](https://opencode.ai)
|
[](https://opencode.ai)
|
||||||
|
|||||||
+141
@@ -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)
|
||||||
+3
-1
@@ -27,6 +27,7 @@
|
|||||||
<a href="README.ja.md">日本語</a> |
|
<a href="README.ja.md">日本語</a> |
|
||||||
<a href="README.pl.md">Polski</a> |
|
<a href="README.pl.md">Polski</a> |
|
||||||
<a href="README.ru.md">Русский</a> |
|
<a href="README.ru.md">Русский</a> |
|
||||||
|
<a href="README.bs.md">Bosanski</a> |
|
||||||
<a href="README.ar.md">العربية</a> |
|
<a href="README.ar.md">العربية</a> |
|
||||||
<a href="README.no.md">Norsk</a> |
|
<a href="README.no.md">Norsk</a> |
|
||||||
<a href="README.br.md">Português (Brasil)</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.tr.md">Türkçe</a> |
|
||||||
<a href="README.uk.md">Українська</a> |
|
<a href="README.uk.md">Українська</a> |
|
||||||
<a href="README.bn.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>
|
</p>
|
||||||
|
|
||||||
[](https://opencode.ai)
|
[](https://opencode.ai)
|
||||||
|
|||||||
+3
-1
@@ -27,6 +27,7 @@
|
|||||||
<a href="README.ja.md">日本語</a> |
|
<a href="README.ja.md">日本語</a> |
|
||||||
<a href="README.pl.md">Polski</a> |
|
<a href="README.pl.md">Polski</a> |
|
||||||
<a href="README.ru.md">Русский</a> |
|
<a href="README.ru.md">Русский</a> |
|
||||||
|
<a href="README.bs.md">Bosanski</a> |
|
||||||
<a href="README.ar.md">العربية</a> |
|
<a href="README.ar.md">العربية</a> |
|
||||||
<a href="README.no.md">Norsk</a> |
|
<a href="README.no.md">Norsk</a> |
|
||||||
<a href="README.br.md">Português (Brasil)</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.tr.md">Türkçe</a> |
|
||||||
<a href="README.uk.md">Українська</a> |
|
<a href="README.uk.md">Українська</a> |
|
||||||
<a href="README.bn.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>
|
</p>
|
||||||
|
|
||||||
[](https://opencode.ai)
|
[](https://opencode.ai)
|
||||||
|
|||||||
@@ -26,7 +26,7 @@
|
|||||||
},
|
},
|
||||||
"packages/app": {
|
"packages/app": {
|
||||||
"name": "@opencode-ai/app",
|
"name": "@opencode-ai/app",
|
||||||
"version": "1.2.17",
|
"version": "1.2.20",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@kobalte/core": "catalog:",
|
"@kobalte/core": "catalog:",
|
||||||
"@opencode-ai/sdk": "workspace:*",
|
"@opencode-ai/sdk": "workspace:*",
|
||||||
@@ -76,7 +76,7 @@
|
|||||||
},
|
},
|
||||||
"packages/console/app": {
|
"packages/console/app": {
|
||||||
"name": "@opencode-ai/console-app",
|
"name": "@opencode-ai/console-app",
|
||||||
"version": "1.2.17",
|
"version": "1.2.20",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@cloudflare/vite-plugin": "1.15.2",
|
"@cloudflare/vite-plugin": "1.15.2",
|
||||||
"@ibm/plex": "6.4.1",
|
"@ibm/plex": "6.4.1",
|
||||||
@@ -110,7 +110,7 @@
|
|||||||
},
|
},
|
||||||
"packages/console/core": {
|
"packages/console/core": {
|
||||||
"name": "@opencode-ai/console-core",
|
"name": "@opencode-ai/console-core",
|
||||||
"version": "1.2.17",
|
"version": "1.2.20",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@aws-sdk/client-sts": "3.782.0",
|
"@aws-sdk/client-sts": "3.782.0",
|
||||||
"@jsx-email/render": "1.1.1",
|
"@jsx-email/render": "1.1.1",
|
||||||
@@ -137,7 +137,7 @@
|
|||||||
},
|
},
|
||||||
"packages/console/function": {
|
"packages/console/function": {
|
||||||
"name": "@opencode-ai/console-function",
|
"name": "@opencode-ai/console-function",
|
||||||
"version": "1.2.17",
|
"version": "1.2.20",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@ai-sdk/anthropic": "2.0.0",
|
"@ai-sdk/anthropic": "2.0.0",
|
||||||
"@ai-sdk/openai": "2.0.2",
|
"@ai-sdk/openai": "2.0.2",
|
||||||
@@ -161,7 +161,7 @@
|
|||||||
},
|
},
|
||||||
"packages/console/mail": {
|
"packages/console/mail": {
|
||||||
"name": "@opencode-ai/console-mail",
|
"name": "@opencode-ai/console-mail",
|
||||||
"version": "1.2.17",
|
"version": "1.2.20",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@jsx-email/all": "2.2.3",
|
"@jsx-email/all": "2.2.3",
|
||||||
"@jsx-email/cli": "1.4.3",
|
"@jsx-email/cli": "1.4.3",
|
||||||
@@ -185,7 +185,7 @@
|
|||||||
},
|
},
|
||||||
"packages/desktop": {
|
"packages/desktop": {
|
||||||
"name": "@opencode-ai/desktop",
|
"name": "@opencode-ai/desktop",
|
||||||
"version": "1.2.17",
|
"version": "1.2.20",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@opencode-ai/app": "workspace:*",
|
"@opencode-ai/app": "workspace:*",
|
||||||
"@opencode-ai/ui": "workspace:*",
|
"@opencode-ai/ui": "workspace:*",
|
||||||
@@ -218,7 +218,7 @@
|
|||||||
},
|
},
|
||||||
"packages/desktop-electron": {
|
"packages/desktop-electron": {
|
||||||
"name": "@opencode-ai/desktop-electron",
|
"name": "@opencode-ai/desktop-electron",
|
||||||
"version": "1.2.17",
|
"version": "1.2.20",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@opencode-ai/app": "workspace:*",
|
"@opencode-ai/app": "workspace:*",
|
||||||
"@opencode-ai/ui": "workspace:*",
|
"@opencode-ai/ui": "workspace:*",
|
||||||
@@ -248,7 +248,7 @@
|
|||||||
},
|
},
|
||||||
"packages/enterprise": {
|
"packages/enterprise": {
|
||||||
"name": "@opencode-ai/enterprise",
|
"name": "@opencode-ai/enterprise",
|
||||||
"version": "1.2.17",
|
"version": "1.2.20",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@opencode-ai/ui": "workspace:*",
|
"@opencode-ai/ui": "workspace:*",
|
||||||
"@opencode-ai/util": "workspace:*",
|
"@opencode-ai/util": "workspace:*",
|
||||||
@@ -277,7 +277,7 @@
|
|||||||
},
|
},
|
||||||
"packages/function": {
|
"packages/function": {
|
||||||
"name": "@opencode-ai/function",
|
"name": "@opencode-ai/function",
|
||||||
"version": "1.2.17",
|
"version": "1.2.20",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@octokit/auth-app": "8.0.1",
|
"@octokit/auth-app": "8.0.1",
|
||||||
"@octokit/rest": "catalog:",
|
"@octokit/rest": "catalog:",
|
||||||
@@ -293,7 +293,7 @@
|
|||||||
},
|
},
|
||||||
"packages/opencode": {
|
"packages/opencode": {
|
||||||
"name": "opencode",
|
"name": "opencode",
|
||||||
"version": "1.2.17",
|
"version": "1.2.20",
|
||||||
"bin": {
|
"bin": {
|
||||||
"opencode": "./bin/opencode",
|
"opencode": "./bin/opencode",
|
||||||
},
|
},
|
||||||
@@ -351,7 +351,7 @@
|
|||||||
"clipboardy": "4.0.0",
|
"clipboardy": "4.0.0",
|
||||||
"decimal.js": "10.5.0",
|
"decimal.js": "10.5.0",
|
||||||
"diff": "catalog:",
|
"diff": "catalog:",
|
||||||
"drizzle-orm": "1.0.0-beta.12-a5629fb",
|
"drizzle-orm": "1.0.0-beta.16-ea816b6",
|
||||||
"fuzzysort": "3.1.0",
|
"fuzzysort": "3.1.0",
|
||||||
"glob": "13.0.5",
|
"glob": "13.0.5",
|
||||||
"google-auth-library": "10.5.0",
|
"google-auth-library": "10.5.0",
|
||||||
@@ -373,6 +373,7 @@
|
|||||||
"ulid": "catalog:",
|
"ulid": "catalog:",
|
||||||
"vscode-jsonrpc": "8.2.1",
|
"vscode-jsonrpc": "8.2.1",
|
||||||
"web-tree-sitter": "0.25.10",
|
"web-tree-sitter": "0.25.10",
|
||||||
|
"which": "6.0.1",
|
||||||
"xdg-basedir": "5.1.0",
|
"xdg-basedir": "5.1.0",
|
||||||
"yargs": "18.0.0",
|
"yargs": "18.0.0",
|
||||||
"zod": "catalog:",
|
"zod": "catalog:",
|
||||||
@@ -395,10 +396,11 @@
|
|||||||
"@types/bun": "catalog:",
|
"@types/bun": "catalog:",
|
||||||
"@types/mime-types": "3.0.1",
|
"@types/mime-types": "3.0.1",
|
||||||
"@types/turndown": "5.0.5",
|
"@types/turndown": "5.0.5",
|
||||||
|
"@types/which": "3.0.4",
|
||||||
"@types/yargs": "17.0.33",
|
"@types/yargs": "17.0.33",
|
||||||
"@typescript/native-preview": "catalog:",
|
"@typescript/native-preview": "catalog:",
|
||||||
"drizzle-kit": "1.0.0-beta.12-a5629fb",
|
"drizzle-kit": "1.0.0-beta.16-ea816b6",
|
||||||
"drizzle-orm": "1.0.0-beta.12-a5629fb",
|
"drizzle-orm": "1.0.0-beta.16-ea816b6",
|
||||||
"typescript": "catalog:",
|
"typescript": "catalog:",
|
||||||
"vscode-languageserver-types": "3.17.5",
|
"vscode-languageserver-types": "3.17.5",
|
||||||
"why-is-node-running": "3.2.2",
|
"why-is-node-running": "3.2.2",
|
||||||
@@ -407,7 +409,7 @@
|
|||||||
},
|
},
|
||||||
"packages/plugin": {
|
"packages/plugin": {
|
||||||
"name": "@opencode-ai/plugin",
|
"name": "@opencode-ai/plugin",
|
||||||
"version": "1.2.17",
|
"version": "1.2.20",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@opencode-ai/sdk": "workspace:*",
|
"@opencode-ai/sdk": "workspace:*",
|
||||||
"zod": "catalog:",
|
"zod": "catalog:",
|
||||||
@@ -427,7 +429,7 @@
|
|||||||
},
|
},
|
||||||
"packages/sdk/js": {
|
"packages/sdk/js": {
|
||||||
"name": "@opencode-ai/sdk",
|
"name": "@opencode-ai/sdk",
|
||||||
"version": "1.2.17",
|
"version": "1.2.20",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@hey-api/openapi-ts": "0.90.10",
|
"@hey-api/openapi-ts": "0.90.10",
|
||||||
"@tsconfig/node22": "catalog:",
|
"@tsconfig/node22": "catalog:",
|
||||||
@@ -438,7 +440,7 @@
|
|||||||
},
|
},
|
||||||
"packages/slack": {
|
"packages/slack": {
|
||||||
"name": "@opencode-ai/slack",
|
"name": "@opencode-ai/slack",
|
||||||
"version": "1.2.17",
|
"version": "1.2.20",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@opencode-ai/sdk": "workspace:*",
|
"@opencode-ai/sdk": "workspace:*",
|
||||||
"@slack/bolt": "^3.17.1",
|
"@slack/bolt": "^3.17.1",
|
||||||
@@ -473,7 +475,7 @@
|
|||||||
},
|
},
|
||||||
"packages/ui": {
|
"packages/ui": {
|
||||||
"name": "@opencode-ai/ui",
|
"name": "@opencode-ai/ui",
|
||||||
"version": "1.2.17",
|
"version": "1.2.20",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@kobalte/core": "catalog:",
|
"@kobalte/core": "catalog:",
|
||||||
"@opencode-ai/sdk": "workspace:*",
|
"@opencode-ai/sdk": "workspace:*",
|
||||||
@@ -519,7 +521,7 @@
|
|||||||
},
|
},
|
||||||
"packages/util": {
|
"packages/util": {
|
||||||
"name": "@opencode-ai/util",
|
"name": "@opencode-ai/util",
|
||||||
"version": "1.2.17",
|
"version": "1.2.20",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"zod": "catalog:",
|
"zod": "catalog:",
|
||||||
},
|
},
|
||||||
@@ -530,7 +532,7 @@
|
|||||||
},
|
},
|
||||||
"packages/web": {
|
"packages/web": {
|
||||||
"name": "@opencode-ai/web",
|
"name": "@opencode-ai/web",
|
||||||
"version": "1.2.17",
|
"version": "1.2.20",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@astrojs/cloudflare": "12.6.3",
|
"@astrojs/cloudflare": "12.6.3",
|
||||||
"@astrojs/markdown-remark": "6.3.1",
|
"@astrojs/markdown-remark": "6.3.1",
|
||||||
@@ -599,8 +601,8 @@
|
|||||||
"ai": "5.0.124",
|
"ai": "5.0.124",
|
||||||
"diff": "8.0.2",
|
"diff": "8.0.2",
|
||||||
"dompurify": "3.3.1",
|
"dompurify": "3.3.1",
|
||||||
"drizzle-kit": "1.0.0-beta.12-a5629fb",
|
"drizzle-kit": "1.0.0-beta.16-ea816b6",
|
||||||
"drizzle-orm": "1.0.0-beta.12-a5629fb",
|
"drizzle-orm": "1.0.0-beta.16-ea816b6",
|
||||||
"fuzzysort": "3.1.0",
|
"fuzzysort": "3.1.0",
|
||||||
"hono": "4.10.7",
|
"hono": "4.10.7",
|
||||||
"hono-openapi": "1.1.2",
|
"hono-openapi": "1.1.2",
|
||||||
@@ -2120,6 +2122,8 @@
|
|||||||
|
|
||||||
"@types/whatwg-mimetype": ["@types/whatwg-mimetype@3.0.2", "", {}, "sha512-c2AKvDT8ToxLIOUlN51gTiHXflsfIFisS4pO7pDPoKouJCESkhZnEy623gwP9laCy5lnLDAw1vAzu2vM2YLOrA=="],
|
"@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/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=="],
|
"@types/yargs": ["@types/yargs@17.0.33", "", { "dependencies": { "@types/yargs-parser": "*" } }, "sha512-WpxBCKWPLr4xSsHgz511rFJAM+wS28w2zEO1QDNY5zM/S8ok70NNfztH0xwhqKyaK0OHCbN98LDAZuy1ctxDkA=="],
|
||||||
@@ -2680,9 +2684,9 @@
|
|||||||
|
|
||||||
"dotenv-expand": ["dotenv-expand@11.0.7", "", { "dependencies": { "dotenv": "^16.4.5" } }, "sha512-zIHwmZPRshsCdpMDyVsqGmgyP0yT8GAgXUnkdAoJisxvf33k7yO6OuoKmcTGuXPWSsm8Oh88nZicRLA9Y0rUeA=="],
|
"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=="],
|
"dset": ["dset@3.1.4", "", {}, "sha512-2QF/g9/zTaPDc3BjNcVTGoBbXBgYfMTTceLaYcFJ/W9kggFUkhxD/hMEeuLKbugyef9SqAx8cpgwlIP/jinUTA=="],
|
||||||
|
|
||||||
@@ -3236,7 +3240,7 @@
|
|||||||
|
|
||||||
"isbinaryfile": ["isbinaryfile@5.0.7", "", {}, "sha512-gnWD14Jh3FzS3CPhF0AxNOJ8CxqeblPTADzI38r0wt8ZyQl5edpy75myt08EG2oKvpyiqSqsx+Wkz9vtkbTqYQ=="],
|
"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=="],
|
"isomorphic-ws": ["isomorphic-ws@5.0.0", "", { "peerDependencies": { "ws": "*" } }, "sha512-muId7Zzn9ywDsyXgTIafTry2sV3nySZeUDe6YedVd1Hvuuep5AsIlqK+XefWpYTyJG5e503F2xIuT2lcU6rCSw=="],
|
||||||
|
|
||||||
@@ -4586,7 +4590,7 @@
|
|||||||
|
|
||||||
"when-exit": ["when-exit@2.1.5", "", {}, "sha512-VGkKJ564kzt6Ms1dbgPP/yuIoQCrsFAnRbptpC5wOEsDaNsbCB2bnfnaA8i/vRs5tjUSEOtIuvl9/MyVsvQZCg=="],
|
"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=="],
|
"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 +5206,8 @@
|
|||||||
|
|
||||||
"app-builder-lib/minimatch": ["minimatch@10.2.1", "", { "dependencies": { "brace-expansion": "^5.0.2" } }, "sha512-MClCe8IL5nRRmawL6ib/eT4oLyeKMGCghibcDWK+J0hh0Q8kqSdia6BvbRMVk6mPa6WqUa5uR2oxt6C5jd533A=="],
|
"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/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=="],
|
"archiver-utils/is-stream": ["is-stream@2.0.1", "", {}, "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg=="],
|
||||||
@@ -5264,6 +5270,8 @@
|
|||||||
|
|
||||||
"cross-spawn/which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="],
|
"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=="],
|
"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=="],
|
"dir-compare/minimatch": ["minimatch@3.1.5", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w=="],
|
||||||
@@ -5388,6 +5396,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/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=="],
|
"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=="],
|
"nypm/citty": ["citty@0.2.1", "", {}, "sha512-kEV95lFBhQgtogAPlQfJJ0WGVSokvLr/UEoFPiKKOXF7pl98HfUVUD0ejsuTCld/9xH9vogSywZ5KqHzXrZpqg=="],
|
||||||
@@ -5918,6 +5928,8 @@
|
|||||||
|
|
||||||
"app-builder-lib/@electron/get/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
|
"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/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=="],
|
"archiver-utils/glob/minimatch": ["minimatch@9.0.5", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow=="],
|
||||||
@@ -6000,6 +6012,8 @@
|
|||||||
|
|
||||||
"node-gyp/nopt/abbrev": ["abbrev@3.0.1", "", {}, "sha512-AO2ac6pjRB3SJmGJo+v5/aK6Omggp6fsLrs6wN9bd35ulu4cCwaAU9+7ZhXjeqHVkaHThLuzH0nZr0YpCDhygg=="],
|
"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/@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=="],
|
"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=="],
|
||||||
|
|||||||
+2
-1
@@ -8,6 +8,7 @@ import type { Context as GitHubContext } from "@actions/github/lib/context"
|
|||||||
import type { IssueCommentEvent, PullRequestReviewCommentEvent } from "@octokit/webhooks-types"
|
import type { IssueCommentEvent, PullRequestReviewCommentEvent } from "@octokit/webhooks-types"
|
||||||
import { createOpencodeClient } from "@opencode-ai/sdk"
|
import { createOpencodeClient } from "@opencode-ai/sdk"
|
||||||
import { spawn } from "node:child_process"
|
import { spawn } from "node:child_process"
|
||||||
|
import { setTimeout as sleep } from "node:timers/promises"
|
||||||
|
|
||||||
type GitHubAuthor = {
|
type GitHubAuthor = {
|
||||||
login: string
|
login: string
|
||||||
@@ -281,7 +282,7 @@ async function assertOpencodeConnected() {
|
|||||||
connected = true
|
connected = true
|
||||||
break
|
break
|
||||||
} catch (e) {}
|
} catch (e) {}
|
||||||
await Bun.sleep(300)
|
await sleep(300)
|
||||||
} while (retry++ < 30)
|
} while (retry++ < 30)
|
||||||
|
|
||||||
if (!connected) {
|
if (!connected) {
|
||||||
|
|||||||
+4
-4
@@ -1,8 +1,8 @@
|
|||||||
{
|
{
|
||||||
"nodeModules": {
|
"nodeModules": {
|
||||||
"x86_64-linux": "sha256-v83hWzYVg/g4zJiBpGsQ71wTdndPk3BQVZ2mjMApUIQ=",
|
"x86_64-linux": "sha256-4kjoJ06VNvHltPHfzQRBG0bC6R39jao10ffGzrNZ230=",
|
||||||
"aarch64-linux": "sha256-inpMwkQqwBFP2wL8w/pTOP7q3fg1aOqvE0wgzVd3/B8=",
|
"aarch64-linux": "sha256-6Uio+S2rcyBWbBEeOZb9N1CCKgkbKi68lOIKi3Ws/pQ=",
|
||||||
"aarch64-darwin": "sha256-r42LGrQWqDyIy62mBSU5Nf3M22dJ3NNo7mjN/1h8d8Y=",
|
"aarch64-darwin": "sha256-8ngN5KVN4vhdsk0QJ11BGgSVBrcaEbwSj23c77HBpgs=",
|
||||||
"x86_64-darwin": "sha256-J6XrrdK5qBK3sQBQOO/B3ZluOnsAf5f65l4q/K1nDTI="
|
"x86_64-darwin": "sha256-v/ueYGb9a0Nymzy+mkO4uQr78DAuJnES1qOT0onFgnQ="
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+2
-2
@@ -41,8 +41,8 @@
|
|||||||
"@tailwindcss/vite": "4.1.11",
|
"@tailwindcss/vite": "4.1.11",
|
||||||
"diff": "8.0.2",
|
"diff": "8.0.2",
|
||||||
"dompurify": "3.3.1",
|
"dompurify": "3.3.1",
|
||||||
"drizzle-kit": "1.0.0-beta.12-a5629fb",
|
"drizzle-kit": "1.0.0-beta.16-ea816b6",
|
||||||
"drizzle-orm": "1.0.0-beta.12-a5629fb",
|
"drizzle-orm": "1.0.0-beta.16-ea816b6",
|
||||||
"ai": "5.0.124",
|
"ai": "5.0.124",
|
||||||
"hono": "4.10.7",
|
"hono": "4.10.7",
|
||||||
"hono-openapi": "1.1.2",
|
"hono-openapi": "1.1.2",
|
||||||
|
|||||||
@@ -71,6 +71,9 @@ test("test description", async ({ page, sdk, gotoSession }) => {
|
|||||||
- `closeDialog(page, dialog)` - Close any dialog
|
- `closeDialog(page, dialog)` - Close any dialog
|
||||||
- `openSidebar(page)` / `closeSidebar(page)` - Toggle sidebar
|
- `openSidebar(page)` / `closeSidebar(page)` - Toggle sidebar
|
||||||
- `withSession(sdk, title, callback)` - Create temp session
|
- `withSession(sdk, title, callback)` - Create temp session
|
||||||
|
- `withProject(...)` - Create temp project/workspace
|
||||||
|
- `trackSession(sessionID, directory?)` - Register session for fixture cleanup
|
||||||
|
- `trackDirectory(directory)` - Register directory for fixture cleanup
|
||||||
- `clickListItem(container, filter)` - Click list item by key/text
|
- `clickListItem(container, filter)` - Click list item by key/text
|
||||||
|
|
||||||
**Selectors** (`selectors.ts`):
|
**Selectors** (`selectors.ts`):
|
||||||
@@ -109,7 +112,7 @@ import { test, expect } from "@playwright/test"
|
|||||||
|
|
||||||
### Error Handling
|
### Error Handling
|
||||||
|
|
||||||
Tests should clean up after themselves:
|
Tests should clean up after themselves. Prefer fixture-managed cleanup:
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
test("test with cleanup", async ({ page, sdk, gotoSession }) => {
|
test("test with cleanup", async ({ page, sdk, gotoSession }) => {
|
||||||
@@ -120,6 +123,11 @@ test("test with cleanup", async ({ page, sdk, gotoSession }) => {
|
|||||||
})
|
})
|
||||||
```
|
```
|
||||||
|
|
||||||
|
- Prefer `withSession(...)` for temp sessions
|
||||||
|
- In `withProject(...)` tests that create sessions or extra workspaces, call `trackSession(sessionID, directory?)` and `trackDirectory(directory)`
|
||||||
|
- This lets fixture teardown abort, wait for idle, and clean up safely under CI concurrency
|
||||||
|
- Avoid calling `sdk.session.delete(...)` directly
|
||||||
|
|
||||||
### Timeouts
|
### Timeouts
|
||||||
|
|
||||||
Default: 60s per test, 10s per assertion. Override when needed:
|
Default: 60s per test, 10s per assertion. Override when needed:
|
||||||
|
|||||||
+158
-50
@@ -3,12 +3,12 @@ import fs from "node:fs/promises"
|
|||||||
import os from "node:os"
|
import os from "node:os"
|
||||||
import path from "node:path"
|
import path from "node:path"
|
||||||
import { execSync } from "node:child_process"
|
import { execSync } from "node:child_process"
|
||||||
import { modKey, serverUrl } from "./utils"
|
import { createSdk, modKey, resolveDirectory, serverUrl } from "./utils"
|
||||||
import {
|
import {
|
||||||
sessionItemSelector,
|
|
||||||
dropdownMenuTriggerSelector,
|
dropdownMenuTriggerSelector,
|
||||||
dropdownMenuContentSelector,
|
dropdownMenuContentSelector,
|
||||||
projectMenuTriggerSelector,
|
projectMenuTriggerSelector,
|
||||||
|
projectCloseMenuSelector,
|
||||||
projectWorkspacesToggleSelector,
|
projectWorkspacesToggleSelector,
|
||||||
titlebarRightSelector,
|
titlebarRightSelector,
|
||||||
popoverBodySelector,
|
popoverBodySelector,
|
||||||
@@ -18,7 +18,6 @@ import {
|
|||||||
workspaceItemSelector,
|
workspaceItemSelector,
|
||||||
workspaceMenuTriggerSelector,
|
workspaceMenuTriggerSelector,
|
||||||
} from "./selectors"
|
} from "./selectors"
|
||||||
import type { createSdk } from "./utils"
|
|
||||||
|
|
||||||
export async function defocus(page: Page) {
|
export async function defocus(page: Page) {
|
||||||
await page
|
await page
|
||||||
@@ -61,9 +60,9 @@ export async function closeDialog(page: Page, dialog: Locator) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function isSidebarClosed(page: Page) {
|
export async function isSidebarClosed(page: Page) {
|
||||||
const main = page.locator("main")
|
const button = page.getByRole("button", { name: /toggle sidebar/i }).first()
|
||||||
const classes = (await main.getAttribute("class")) ?? ""
|
await expect(button).toBeVisible()
|
||||||
return classes.includes("xl:border-l")
|
return (await button.getAttribute("aria-expanded")) !== "true"
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function toggleSidebar(page: Page) {
|
export async function toggleSidebar(page: Page) {
|
||||||
@@ -75,48 +74,34 @@ export async function openSidebar(page: Page) {
|
|||||||
if (!(await isSidebarClosed(page))) return
|
if (!(await isSidebarClosed(page))) return
|
||||||
|
|
||||||
const button = page.getByRole("button", { name: /toggle sidebar/i }).first()
|
const button = page.getByRole("button", { name: /toggle sidebar/i }).first()
|
||||||
const visible = await button
|
await button.click()
|
||||||
.isVisible()
|
|
||||||
.then((x) => x)
|
|
||||||
.catch(() => false)
|
|
||||||
|
|
||||||
if (visible) await button.click()
|
const opened = await expect(button)
|
||||||
if (!visible) await toggleSidebar(page)
|
.toHaveAttribute("aria-expanded", "true", { timeout: 1500 })
|
||||||
|
|
||||||
const main = page.locator("main")
|
|
||||||
const opened = await expect(main)
|
|
||||||
.not.toHaveClass(/xl:border-l/, { timeout: 1500 })
|
|
||||||
.then(() => true)
|
.then(() => true)
|
||||||
.catch(() => false)
|
.catch(() => false)
|
||||||
|
|
||||||
if (opened) return
|
if (opened) return
|
||||||
|
|
||||||
await toggleSidebar(page)
|
await toggleSidebar(page)
|
||||||
await expect(main).not.toHaveClass(/xl:border-l/)
|
await expect(button).toHaveAttribute("aria-expanded", "true")
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function closeSidebar(page: Page) {
|
export async function closeSidebar(page: Page) {
|
||||||
if (await isSidebarClosed(page)) return
|
if (await isSidebarClosed(page)) return
|
||||||
|
|
||||||
const button = page.getByRole("button", { name: /toggle sidebar/i }).first()
|
const button = page.getByRole("button", { name: /toggle sidebar/i }).first()
|
||||||
const visible = await button
|
await button.click()
|
||||||
.isVisible()
|
|
||||||
.then((x) => x)
|
|
||||||
.catch(() => false)
|
|
||||||
|
|
||||||
if (visible) await button.click()
|
const closed = await expect(button)
|
||||||
if (!visible) await toggleSidebar(page)
|
.toHaveAttribute("aria-expanded", "false", { timeout: 1500 })
|
||||||
|
|
||||||
const main = page.locator("main")
|
|
||||||
const closed = await expect(main)
|
|
||||||
.toHaveClass(/xl:border-l/, { timeout: 1500 })
|
|
||||||
.then(() => true)
|
.then(() => true)
|
||||||
.catch(() => false)
|
.catch(() => false)
|
||||||
|
|
||||||
if (closed) return
|
if (closed) return
|
||||||
|
|
||||||
await toggleSidebar(page)
|
await toggleSidebar(page)
|
||||||
await expect(main).toHaveClass(/xl:border-l/)
|
await expect(button).toHaveAttribute("aria-expanded", "false")
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function openSettings(page: Page) {
|
export async function openSettings(page: Page) {
|
||||||
@@ -197,17 +182,21 @@ export async function createTestProject() {
|
|||||||
await fs.writeFile(path.join(root, "README.md"), "# e2e\n")
|
await fs.writeFile(path.join(root, "README.md"), "# e2e\n")
|
||||||
|
|
||||||
execSync("git init", { cwd: root, stdio: "ignore" })
|
execSync("git init", { cwd: root, stdio: "ignore" })
|
||||||
|
execSync("git config core.fsmonitor false", { cwd: root, stdio: "ignore" })
|
||||||
execSync("git add -A", { cwd: root, stdio: "ignore" })
|
execSync("git add -A", { cwd: root, stdio: "ignore" })
|
||||||
execSync('git -c user.name="e2e" -c user.email="e2e@example.com" commit -m "init" --allow-empty', {
|
execSync('git -c user.name="e2e" -c user.email="e2e@example.com" commit -m "init" --allow-empty', {
|
||||||
cwd: root,
|
cwd: root,
|
||||||
stdio: "ignore",
|
stdio: "ignore",
|
||||||
})
|
})
|
||||||
|
|
||||||
return root
|
return resolveDirectory(root)
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function cleanupTestProject(directory: string) {
|
export async function cleanupTestProject(directory: string) {
|
||||||
await fs.rm(directory, { recursive: true, force: true }).catch(() => undefined)
|
try {
|
||||||
|
execSync("git fsmonitor--daemon stop", { cwd: directory, stdio: "ignore" })
|
||||||
|
} catch {}
|
||||||
|
await fs.rm(directory, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }).catch(() => undefined)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function sessionIDFromUrl(url: string) {
|
export function sessionIDFromUrl(url: string) {
|
||||||
@@ -216,7 +205,7 @@ export function sessionIDFromUrl(url: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function hoverSessionItem(page: Page, sessionID: string) {
|
export async function hoverSessionItem(page: Page, sessionID: string) {
|
||||||
const sessionEl = page.locator(sessionItemSelector(sessionID)).first()
|
const sessionEl = page.locator(`[data-session-id="${sessionID}"]`).last()
|
||||||
await expect(sessionEl).toBeVisible()
|
await expect(sessionEl).toBeVisible()
|
||||||
await sessionEl.hover()
|
await sessionEl.hover()
|
||||||
return sessionEl
|
return sessionEl
|
||||||
@@ -317,6 +306,57 @@ export async function clickListItem(
|
|||||||
return item
|
return item
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function status(sdk: ReturnType<typeof createSdk>, sessionID: string) {
|
||||||
|
const data = await sdk.session
|
||||||
|
.status()
|
||||||
|
.then((x) => x.data ?? {})
|
||||||
|
.catch(() => undefined)
|
||||||
|
return data?.[sessionID]
|
||||||
|
}
|
||||||
|
|
||||||
|
async function stable(sdk: ReturnType<typeof createSdk>, sessionID: string, timeout = 10_000) {
|
||||||
|
let prev = ""
|
||||||
|
await expect
|
||||||
|
.poll(
|
||||||
|
async () => {
|
||||||
|
const info = await sdk.session
|
||||||
|
.get({ sessionID })
|
||||||
|
.then((x) => x.data)
|
||||||
|
.catch(() => undefined)
|
||||||
|
if (!info) return true
|
||||||
|
const next = `${info.title}:${info.time.updated ?? info.time.created}`
|
||||||
|
if (next !== prev) {
|
||||||
|
prev = next
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
},
|
||||||
|
{ timeout },
|
||||||
|
)
|
||||||
|
.toBe(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function waitSessionIdle(sdk: ReturnType<typeof createSdk>, sessionID: string, timeout = 30_000) {
|
||||||
|
await expect.poll(() => status(sdk, sessionID).then((x) => !x || x.type === "idle"), { timeout }).toBe(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function cleanupSession(input: {
|
||||||
|
sessionID: string
|
||||||
|
directory?: string
|
||||||
|
sdk?: ReturnType<typeof createSdk>
|
||||||
|
}) {
|
||||||
|
const sdk = input.sdk ?? (input.directory ? createSdk(input.directory) : undefined)
|
||||||
|
if (!sdk) throw new Error("cleanupSession requires sdk or directory")
|
||||||
|
await waitSessionIdle(sdk, input.sessionID, 5_000).catch(() => undefined)
|
||||||
|
const current = await status(sdk, input.sessionID).catch(() => undefined)
|
||||||
|
if (current && current.type !== "idle") {
|
||||||
|
await sdk.session.abort({ sessionID: input.sessionID }).catch(() => undefined)
|
||||||
|
await waitSessionIdle(sdk, input.sessionID).catch(() => undefined)
|
||||||
|
}
|
||||||
|
await stable(sdk, input.sessionID).catch(() => undefined)
|
||||||
|
await sdk.session.delete({ sessionID: input.sessionID }).catch(() => undefined)
|
||||||
|
}
|
||||||
|
|
||||||
export async function withSession<T>(
|
export async function withSession<T>(
|
||||||
sdk: ReturnType<typeof createSdk>,
|
sdk: ReturnType<typeof createSdk>,
|
||||||
title: string,
|
title: string,
|
||||||
@@ -328,7 +368,7 @@ export async function withSession<T>(
|
|||||||
try {
|
try {
|
||||||
return await callback(session)
|
return await callback(session)
|
||||||
} finally {
|
} finally {
|
||||||
await sdk.session.delete({ sessionID: session.id }).catch(() => undefined)
|
await cleanupSession({ sdk, sessionID: session.id })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -441,6 +481,57 @@ export async function seedSessionPermission(
|
|||||||
return { id: result.id }
|
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(
|
export async function seedSessionTodos(
|
||||||
sdk: ReturnType<typeof createSdk>,
|
sdk: ReturnType<typeof createSdk>,
|
||||||
input: {
|
input: {
|
||||||
@@ -515,32 +606,42 @@ export async function openProjectMenu(page: Page, projectSlug: string) {
|
|||||||
const trigger = page.locator(projectMenuTriggerSelector(projectSlug)).first()
|
const trigger = page.locator(projectMenuTriggerSelector(projectSlug)).first()
|
||||||
await expect(trigger).toHaveCount(1)
|
await expect(trigger).toHaveCount(1)
|
||||||
|
|
||||||
|
const menu = page
|
||||||
|
.locator(dropdownMenuContentSelector)
|
||||||
|
.filter({ has: page.locator(projectCloseMenuSelector(projectSlug)) })
|
||||||
|
.first()
|
||||||
|
const close = menu.locator(projectCloseMenuSelector(projectSlug)).first()
|
||||||
|
|
||||||
|
const clicked = await trigger
|
||||||
|
.click({ timeout: 1500 })
|
||||||
|
.then(() => true)
|
||||||
|
.catch(() => false)
|
||||||
|
|
||||||
|
if (clicked) {
|
||||||
|
const opened = await menu
|
||||||
|
.waitFor({ state: "visible", timeout: 1500 })
|
||||||
|
.then(() => true)
|
||||||
|
.catch(() => false)
|
||||||
|
if (opened) {
|
||||||
|
await expect(close).toBeVisible()
|
||||||
|
return menu
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
await trigger.focus()
|
await trigger.focus()
|
||||||
await page.keyboard.press("Enter")
|
await page.keyboard.press("Enter")
|
||||||
|
|
||||||
const menu = page.locator(dropdownMenuContentSelector).first()
|
|
||||||
const opened = await menu
|
const opened = await menu
|
||||||
.waitFor({ state: "visible", timeout: 1500 })
|
.waitFor({ state: "visible", timeout: 1500 })
|
||||||
.then(() => true)
|
.then(() => true)
|
||||||
.catch(() => false)
|
.catch(() => false)
|
||||||
|
|
||||||
if (opened) {
|
if (opened) {
|
||||||
const viewport = page.viewportSize()
|
await expect(close).toBeVisible()
|
||||||
const x = viewport ? Math.max(viewport.width - 5, 0) : 1200
|
|
||||||
const y = viewport ? Math.max(viewport.height - 5, 0) : 800
|
|
||||||
await page.mouse.move(x, y)
|
|
||||||
return menu
|
return menu
|
||||||
}
|
}
|
||||||
|
|
||||||
await trigger.click({ force: true })
|
throw new Error(`Failed to open project menu: ${projectSlug}`)
|
||||||
|
|
||||||
await expect(menu).toBeVisible()
|
|
||||||
|
|
||||||
const viewport = page.viewportSize()
|
|
||||||
const x = viewport ? Math.max(viewport.width - 5, 0) : 1200
|
|
||||||
const y = viewport ? Math.max(viewport.height - 5, 0) : 800
|
|
||||||
await page.mouse.move(x, y)
|
|
||||||
return menu
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function setWorkspacesEnabled(page: Page, projectSlug: string, enabled: boolean) {
|
export async function setWorkspacesEnabled(page: Page, projectSlug: string, enabled: boolean) {
|
||||||
@@ -553,11 +654,18 @@ export async function setWorkspacesEnabled(page: Page, projectSlug: string, enab
|
|||||||
|
|
||||||
if (current === enabled) return
|
if (current === enabled) return
|
||||||
|
|
||||||
await openProjectMenu(page, projectSlug)
|
const flip = async (timeout?: number) => {
|
||||||
|
const menu = await openProjectMenu(page, projectSlug)
|
||||||
|
const toggle = menu.locator(projectWorkspacesToggleSelector(projectSlug)).first()
|
||||||
|
await expect(toggle).toBeVisible()
|
||||||
|
return toggle.click({ force: true, timeout })
|
||||||
|
}
|
||||||
|
|
||||||
const toggle = page.locator(projectWorkspacesToggleSelector(projectSlug)).first()
|
const flipped = await flip(1500)
|
||||||
await expect(toggle).toBeVisible()
|
.then(() => true)
|
||||||
await toggle.click({ force: true })
|
.catch(() => false)
|
||||||
|
|
||||||
|
if (!flipped) await flip()
|
||||||
|
|
||||||
const expected = enabled ? "New workspace" : "New session"
|
const expected = enabled ? "New workspace" : "New session"
|
||||||
await expect(page.getByRole("button", { name: expected }).first()).toBeVisible()
|
await expect(page.getByRole("button", { name: expected }).first()).toBeVisible()
|
||||||
|
|||||||
@@ -16,7 +16,6 @@ test("titlebar back/forward navigates between sessions", async ({ page, slug, sd
|
|||||||
|
|
||||||
const link = page.locator(`[data-session-id="${two.id}"] a`).first()
|
const link = page.locator(`[data-session-id="${two.id}"] a`).first()
|
||||||
await expect(link).toBeVisible()
|
await expect(link).toBeVisible()
|
||||||
await link.scrollIntoViewIfNeeded()
|
|
||||||
await link.click()
|
await link.click()
|
||||||
|
|
||||||
await expect(page).toHaveURL(new RegExp(`/${slug}/session/${two.id}(?:\\?|#|$)`))
|
await expect(page).toHaveURL(new RegExp(`/${slug}/session/${two.id}(?:\\?|#|$)`))
|
||||||
@@ -56,7 +55,6 @@ test("titlebar forward is cleared after branching history from sidebar", async (
|
|||||||
|
|
||||||
const second = page.locator(`[data-session-id="${b.id}"] a`).first()
|
const second = page.locator(`[data-session-id="${b.id}"] a`).first()
|
||||||
await expect(second).toBeVisible()
|
await expect(second).toBeVisible()
|
||||||
await second.scrollIntoViewIfNeeded()
|
|
||||||
await second.click()
|
await second.click()
|
||||||
|
|
||||||
await expect(page).toHaveURL(new RegExp(`/${slug}/session/${b.id}(?:\\?|#|$)`))
|
await expect(page).toHaveURL(new RegExp(`/${slug}/session/${b.id}(?:\\?|#|$)`))
|
||||||
@@ -76,7 +74,6 @@ test("titlebar forward is cleared after branching history from sidebar", async (
|
|||||||
|
|
||||||
const third = page.locator(`[data-session-id="${c.id}"] a`).first()
|
const third = page.locator(`[data-session-id="${c.id}"] a`).first()
|
||||||
await expect(third).toBeVisible()
|
await expect(third).toBeVisible()
|
||||||
await third.scrollIntoViewIfNeeded()
|
|
||||||
await third.click()
|
await third.click()
|
||||||
|
|
||||||
await expect(page).toHaveURL(new RegExp(`/${slug}/session/${c.id}(?:\\?|#|$)`))
|
await expect(page).toHaveURL(new RegExp(`/${slug}/session/${c.id}(?:\\?|#|$)`))
|
||||||
@@ -102,7 +99,6 @@ test("keyboard shortcuts navigate titlebar history", async ({ page, slug, sdk, g
|
|||||||
|
|
||||||
const link = page.locator(`[data-session-id="${two.id}"] a`).first()
|
const link = page.locator(`[data-session-id="${two.id}"] a`).first()
|
||||||
await expect(link).toBeVisible()
|
await expect(link).toBeVisible()
|
||||||
await link.scrollIntoViewIfNeeded()
|
|
||||||
await link.click()
|
await link.click()
|
||||||
|
|
||||||
await expect(page).toHaveURL(new RegExp(`/${slug}/session/${two.id}(?:\\?|#|$)`))
|
await expect(page).toHaveURL(new RegExp(`/${slug}/session/${two.id}(?:\\?|#|$)`))
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { test as base, expect, type Page } from "@playwright/test"
|
import { test as base, expect, type Page } from "@playwright/test"
|
||||||
import { cleanupTestProject, createTestProject, seedProjects } from "./actions"
|
import { cleanupSession, cleanupTestProject, createTestProject, seedProjects, sessionIDFromUrl } from "./actions"
|
||||||
import { promptSelector } from "./selectors"
|
import { promptSelector } from "./selectors"
|
||||||
import { createSdk, dirSlug, getWorktree, sessionPath } from "./utils"
|
import { createSdk, dirSlug, getWorktree, sessionPath } from "./utils"
|
||||||
|
|
||||||
@@ -13,6 +13,8 @@ type TestFixtures = {
|
|||||||
directory: string
|
directory: string
|
||||||
slug: string
|
slug: string
|
||||||
gotoSession: (sessionID?: string) => Promise<void>
|
gotoSession: (sessionID?: string) => Promise<void>
|
||||||
|
trackSession: (sessionID: string, directory?: string) => void
|
||||||
|
trackDirectory: (directory: string) => void
|
||||||
}) => Promise<T>,
|
}) => Promise<T>,
|
||||||
options?: { extra?: string[] },
|
options?: { extra?: string[] },
|
||||||
) => Promise<T>
|
) => Promise<T>
|
||||||
@@ -51,20 +53,36 @@ export const test = base.extend<TestFixtures, WorkerFixtures>({
|
|||||||
},
|
},
|
||||||
withProject: async ({ page }, use) => {
|
withProject: async ({ page }, use) => {
|
||||||
await use(async (callback, options) => {
|
await use(async (callback, options) => {
|
||||||
const directory = await createTestProject()
|
const root = await createTestProject()
|
||||||
const slug = dirSlug(directory)
|
const slug = dirSlug(root)
|
||||||
await seedStorage(page, { directory, extra: options?.extra })
|
const sessions = new Map<string, string>()
|
||||||
|
const dirs = new Set<string>()
|
||||||
|
await seedStorage(page, { directory: root, extra: options?.extra })
|
||||||
|
|
||||||
const gotoSession = async (sessionID?: string) => {
|
const gotoSession = async (sessionID?: string) => {
|
||||||
await page.goto(sessionPath(directory, sessionID))
|
await page.goto(sessionPath(root, sessionID))
|
||||||
await expect(page.locator(promptSelector)).toBeVisible()
|
await expect(page.locator(promptSelector)).toBeVisible()
|
||||||
|
const current = sessionIDFromUrl(page.url())
|
||||||
|
if (current) trackSession(current)
|
||||||
|
}
|
||||||
|
|
||||||
|
const trackSession = (sessionID: string, directory?: string) => {
|
||||||
|
sessions.set(sessionID, directory ?? root)
|
||||||
|
}
|
||||||
|
|
||||||
|
const trackDirectory = (directory: string) => {
|
||||||
|
if (directory !== root) dirs.add(directory)
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await gotoSession()
|
await gotoSession()
|
||||||
return await callback({ directory, slug, gotoSession })
|
return await callback({ directory: root, slug, gotoSession, trackSession, trackDirectory })
|
||||||
} finally {
|
} finally {
|
||||||
await cleanupTestProject(directory)
|
await Promise.allSettled(
|
||||||
|
Array.from(sessions, ([sessionID, directory]) => cleanupSession({ sessionID, directory })),
|
||||||
|
)
|
||||||
|
await Promise.allSettled(Array.from(dirs, (directory) => cleanupTestProject(directory)))
|
||||||
|
await cleanupTestProject(root)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,25 +1,15 @@
|
|||||||
import { test, expect } from "../fixtures"
|
import { test, expect } from "../fixtures"
|
||||||
import { openSidebar } from "../actions"
|
import { clickMenuItem, openProjectMenu, openSidebar } from "../actions"
|
||||||
|
|
||||||
test("dialog edit project updates name and startup script", async ({ page, withProject }) => {
|
test("dialog edit project updates name and startup script", async ({ page, withProject }) => {
|
||||||
await page.setViewportSize({ width: 1400, height: 800 })
|
await page.setViewportSize({ width: 1400, height: 800 })
|
||||||
|
|
||||||
await withProject(async () => {
|
await withProject(async ({ slug }) => {
|
||||||
await openSidebar(page)
|
await openSidebar(page)
|
||||||
|
|
||||||
const open = async () => {
|
const open = async () => {
|
||||||
const header = page.locator(".group\\/project").first()
|
const menu = await openProjectMenu(page, slug)
|
||||||
await header.hover()
|
await clickMenuItem(menu, /^Edit$/i, { force: true })
|
||||||
const trigger = header.getByRole("button", { name: "More options" }).first()
|
|
||||||
await expect(trigger).toBeVisible()
|
|
||||||
await trigger.click({ force: true })
|
|
||||||
|
|
||||||
const menu = page.locator('[data-component="dropdown-menu-content"]').first()
|
|
||||||
await expect(menu).toBeVisible()
|
|
||||||
|
|
||||||
const editItem = menu.getByRole("menuitem", { name: "Edit" }).first()
|
|
||||||
await expect(editItem).toBeVisible()
|
|
||||||
await editItem.click({ force: true })
|
|
||||||
|
|
||||||
const dialog = page.getByRole("dialog")
|
const dialog = page.getByRole("dialog")
|
||||||
await expect(dialog).toBeVisible()
|
await expect(dialog).toBeVisible()
|
||||||
|
|||||||
@@ -1,20 +1,45 @@
|
|||||||
import { base64Decode } from "@opencode-ai/util/encode"
|
import { base64Decode } from "@opencode-ai/util/encode"
|
||||||
|
import type { Page } from "@playwright/test"
|
||||||
import { test, expect } from "../fixtures"
|
import { test, expect } from "../fixtures"
|
||||||
import {
|
import { defocus, createTestProject, cleanupTestProject, openSidebar, sessionIDFromUrl } from "../actions"
|
||||||
defocus,
|
|
||||||
createTestProject,
|
|
||||||
cleanupTestProject,
|
|
||||||
openSidebar,
|
|
||||||
setWorkspacesEnabled,
|
|
||||||
sessionIDFromUrl,
|
|
||||||
} from "../actions"
|
|
||||||
import { projectSwitchSelector, promptSelector, workspaceItemSelector, workspaceNewSessionSelector } from "../selectors"
|
import { projectSwitchSelector, promptSelector, workspaceItemSelector, workspaceNewSessionSelector } from "../selectors"
|
||||||
import { createSdk, dirSlug, sessionPath } from "../utils"
|
import { dirSlug } from "../utils"
|
||||||
|
|
||||||
function slugFromUrl(url: string) {
|
function slugFromUrl(url: string) {
|
||||||
return /\/([^/]+)\/session(?:\/|$)/.exec(url)?.[1] ?? ""
|
return /\/([^/]+)\/session(?:\/|$)/.exec(url)?.[1] ?? ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function workspaces(page: Page, directory: string, enabled: boolean) {
|
||||||
|
await page.evaluate(
|
||||||
|
({ directory, enabled }: { directory: string; enabled: boolean }) => {
|
||||||
|
const key = "opencode.global.dat:layout"
|
||||||
|
const raw = localStorage.getItem(key)
|
||||||
|
const data = raw ? JSON.parse(raw) : {}
|
||||||
|
const sidebar = data.sidebar && typeof data.sidebar === "object" ? data.sidebar : {}
|
||||||
|
const current =
|
||||||
|
sidebar.workspaces && typeof sidebar.workspaces === "object" && !Array.isArray(sidebar.workspaces)
|
||||||
|
? sidebar.workspaces
|
||||||
|
: {}
|
||||||
|
const next = { ...current }
|
||||||
|
|
||||||
|
if (enabled) next[directory] = true
|
||||||
|
if (!enabled) delete next[directory]
|
||||||
|
|
||||||
|
localStorage.setItem(
|
||||||
|
key,
|
||||||
|
JSON.stringify({
|
||||||
|
...data,
|
||||||
|
sidebar: {
|
||||||
|
...sidebar,
|
||||||
|
workspaces: next,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
{ directory, enabled },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
test("can switch between projects from sidebar", async ({ page, withProject }) => {
|
test("can switch between projects from sidebar", async ({ page, withProject }) => {
|
||||||
await page.setViewportSize({ width: 1400, height: 800 })
|
await page.setViewportSize({ width: 1400, height: 800 })
|
||||||
|
|
||||||
@@ -51,17 +76,16 @@ test("switching back to a project opens the latest workspace session", async ({
|
|||||||
|
|
||||||
const other = await createTestProject()
|
const other = await createTestProject()
|
||||||
const otherSlug = dirSlug(other)
|
const otherSlug = dirSlug(other)
|
||||||
let rootDir: string | undefined
|
|
||||||
let workspaceDir: string | undefined
|
let workspaceDir: string | undefined
|
||||||
let sessionID: string | undefined
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await withProject(
|
await withProject(
|
||||||
async ({ directory, slug }) => {
|
async ({ directory, slug, trackSession, trackDirectory }) => {
|
||||||
rootDir = directory
|
|
||||||
await defocus(page)
|
await defocus(page)
|
||||||
|
await workspaces(page, directory, true)
|
||||||
|
await page.reload()
|
||||||
|
await expect(page.locator(promptSelector)).toBeVisible()
|
||||||
await openSidebar(page)
|
await openSidebar(page)
|
||||||
await setWorkspacesEnabled(page, slug, true)
|
await expect(page.getByRole("button", { name: "New workspace" }).first()).toBeVisible()
|
||||||
|
|
||||||
await page.getByRole("button", { name: "New workspace" }).first().click()
|
await page.getByRole("button", { name: "New workspace" }).first().click()
|
||||||
|
|
||||||
@@ -80,6 +104,7 @@ test("switching back to a project opens the latest workspace session", async ({
|
|||||||
const workspaceSlug = slugFromUrl(page.url())
|
const workspaceSlug = slugFromUrl(page.url())
|
||||||
workspaceDir = base64Decode(workspaceSlug)
|
workspaceDir = base64Decode(workspaceSlug)
|
||||||
if (!workspaceDir) throw new Error(`Failed to decode workspace slug: ${workspaceSlug}`)
|
if (!workspaceDir) throw new Error(`Failed to decode workspace slug: ${workspaceSlug}`)
|
||||||
|
trackDirectory(workspaceDir)
|
||||||
await openSidebar(page)
|
await openSidebar(page)
|
||||||
|
|
||||||
const workspace = page.locator(workspaceItemSelector(workspaceSlug)).first()
|
const workspace = page.locator(workspaceItemSelector(workspaceSlug)).first()
|
||||||
@@ -103,7 +128,7 @@ test("switching back to a project opens the latest workspace session", async ({
|
|||||||
|
|
||||||
const created = sessionIDFromUrl(page.url())
|
const created = sessionIDFromUrl(page.url())
|
||||||
if (!created) throw new Error(`Failed to get session ID from url: ${page.url()}`)
|
if (!created) throw new Error(`Failed to get session ID from url: ${page.url()}`)
|
||||||
sessionID = created
|
trackSession(created, workspaceDir)
|
||||||
|
|
||||||
await expect(page).toHaveURL(new RegExp(`/${workspaceSlug}/session/${created}(?:[/?#]|$)`))
|
await expect(page).toHaveURL(new RegExp(`/${workspaceSlug}/session/${created}(?:[/?#]|$)`))
|
||||||
|
|
||||||
@@ -124,20 +149,6 @@ test("switching back to a project opens the latest workspace session", async ({
|
|||||||
{ extra: [other] },
|
{ extra: [other] },
|
||||||
)
|
)
|
||||||
} finally {
|
} finally {
|
||||||
if (sessionID) {
|
|
||||||
const id = sessionID
|
|
||||||
const dirs = [rootDir, workspaceDir].filter((x): x is string => !!x)
|
|
||||||
await Promise.all(
|
|
||||||
dirs.map((directory) =>
|
|
||||||
createSdk(directory)
|
|
||||||
.session.delete({ sessionID: id })
|
|
||||||
.catch(() => undefined),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
if (workspaceDir) {
|
|
||||||
await cleanupTestProject(workspaceDir)
|
|
||||||
}
|
|
||||||
await cleanupTestProject(other)
|
await cleanupTestProject(other)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { base64Decode } from "@opencode-ai/util/encode"
|
import { base64Decode } from "@opencode-ai/util/encode"
|
||||||
import type { Page } from "@playwright/test"
|
import type { Page } from "@playwright/test"
|
||||||
import { test, expect } from "../fixtures"
|
import { test, expect } from "../fixtures"
|
||||||
import { cleanupTestProject, openSidebar, sessionIDFromUrl, setWorkspacesEnabled } from "../actions"
|
import { openSidebar, sessionIDFromUrl, setWorkspacesEnabled } from "../actions"
|
||||||
import { promptSelector, workspaceItemSelector, workspaceNewSessionSelector } from "../selectors"
|
import { promptSelector, workspaceItemSelector, workspaceNewSessionSelector } from "../selectors"
|
||||||
import { createSdk } from "../utils"
|
import { createSdk } from "../utils"
|
||||||
|
|
||||||
@@ -9,6 +9,26 @@ function slugFromUrl(url: string) {
|
|||||||
return /\/([^/]+)\/session(?:\/|$)/.exec(url)?.[1] ?? ""
|
return /\/([^/]+)\/session(?:\/|$)/.exec(url)?.[1] ?? ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function waitSlug(page: Page, skip: string[] = []) {
|
||||||
|
let prev = ""
|
||||||
|
await expect
|
||||||
|
.poll(
|
||||||
|
() => {
|
||||||
|
const slug = slugFromUrl(page.url())
|
||||||
|
if (!slug) return ""
|
||||||
|
if (skip.includes(slug)) return ""
|
||||||
|
if (slug !== prev) {
|
||||||
|
prev = slug
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return slug
|
||||||
|
},
|
||||||
|
{ timeout: 45_000 },
|
||||||
|
)
|
||||||
|
.not.toBe("")
|
||||||
|
return slugFromUrl(page.url())
|
||||||
|
}
|
||||||
|
|
||||||
async function waitWorkspaceReady(page: Page, slug: string) {
|
async function waitWorkspaceReady(page: Page, slug: string) {
|
||||||
await openSidebar(page)
|
await openSidebar(page)
|
||||||
await expect
|
await expect
|
||||||
@@ -31,20 +51,7 @@ async function createWorkspace(page: Page, root: string, seen: string[]) {
|
|||||||
await openSidebar(page)
|
await openSidebar(page)
|
||||||
await page.getByRole("button", { name: "New workspace" }).first().click()
|
await page.getByRole("button", { name: "New workspace" }).first().click()
|
||||||
|
|
||||||
await expect
|
const slug = await waitSlug(page, [root, ...seen])
|
||||||
.poll(
|
|
||||||
() => {
|
|
||||||
const slug = slugFromUrl(page.url())
|
|
||||||
if (!slug) return ""
|
|
||||||
if (slug === root) return ""
|
|
||||||
if (seen.includes(slug)) return ""
|
|
||||||
return slug
|
|
||||||
},
|
|
||||||
{ timeout: 45_000 },
|
|
||||||
)
|
|
||||||
.not.toBe("")
|
|
||||||
|
|
||||||
const slug = slugFromUrl(page.url())
|
|
||||||
const directory = base64Decode(slug)
|
const directory = base64Decode(slug)
|
||||||
if (!directory) throw new Error(`Failed to decode workspace slug: ${slug}`)
|
if (!directory) throw new Error(`Failed to decode workspace slug: ${slug}`)
|
||||||
return { slug, directory }
|
return { slug, directory }
|
||||||
@@ -60,12 +67,13 @@ async function openWorkspaceNewSession(page: Page, slug: string) {
|
|||||||
await expect(button).toBeVisible()
|
await expect(button).toBeVisible()
|
||||||
await button.click({ force: true })
|
await button.click({ force: true })
|
||||||
|
|
||||||
await expect.poll(() => slugFromUrl(page.url())).toBe(slug)
|
const next = await waitSlug(page)
|
||||||
await expect(page).toHaveURL(new RegExp(`/${slug}/session(?:[/?#]|$)`))
|
await expect(page).toHaveURL(new RegExp(`/${next}/session(?:[/?#]|$)`))
|
||||||
|
return next
|
||||||
}
|
}
|
||||||
|
|
||||||
async function createSessionFromWorkspace(page: Page, slug: string, text: string) {
|
async function createSessionFromWorkspace(page: Page, slug: string, text: string) {
|
||||||
await openWorkspaceNewSession(page, slug)
|
const next = await openWorkspaceNewSession(page, slug)
|
||||||
|
|
||||||
const prompt = page.locator(promptSelector)
|
const prompt = page.locator(promptSelector)
|
||||||
await expect(prompt).toBeVisible()
|
await expect(prompt).toBeVisible()
|
||||||
@@ -76,13 +84,13 @@ async function createSessionFromWorkspace(page: Page, slug: string, text: string
|
|||||||
await expect.poll(async () => ((await prompt.textContent()) ?? "").trim()).toContain(text)
|
await expect.poll(async () => ((await prompt.textContent()) ?? "").trim()).toContain(text)
|
||||||
await prompt.press("Enter")
|
await prompt.press("Enter")
|
||||||
|
|
||||||
await expect.poll(() => slugFromUrl(page.url())).toBe(slug)
|
await expect.poll(() => slugFromUrl(page.url())).toBe(next)
|
||||||
await expect.poll(() => sessionIDFromUrl(page.url()) ?? "", { timeout: 30_000 }).not.toBe("")
|
await expect.poll(() => sessionIDFromUrl(page.url()) ?? "", { timeout: 30_000 }).not.toBe("")
|
||||||
|
|
||||||
const sessionID = sessionIDFromUrl(page.url())
|
const sessionID = sessionIDFromUrl(page.url())
|
||||||
if (!sessionID) throw new Error(`Failed to parse session id from url: ${page.url()}`)
|
if (!sessionID) throw new Error(`Failed to parse session id from url: ${page.url()}`)
|
||||||
await expect(page).toHaveURL(new RegExp(`/${slug}/session/${sessionID}(?:[/?#]|$)`))
|
await expect(page).toHaveURL(new RegExp(`/${next}/session/${sessionID}(?:[/?#]|$)`))
|
||||||
return sessionID
|
return { sessionID, slug: next }
|
||||||
}
|
}
|
||||||
|
|
||||||
async function sessionDirectory(directory: string, sessionID: string) {
|
async function sessionDirectory(directory: string, sessionID: string) {
|
||||||
@@ -97,48 +105,29 @@ async function sessionDirectory(directory: string, sessionID: string) {
|
|||||||
test("new sessions from sidebar workspace actions stay in selected workspace", async ({ page, withProject }) => {
|
test("new sessions from sidebar workspace actions stay in selected workspace", async ({ page, withProject }) => {
|
||||||
await page.setViewportSize({ width: 1400, height: 800 })
|
await page.setViewportSize({ width: 1400, height: 800 })
|
||||||
|
|
||||||
await withProject(async ({ directory, slug: root }) => {
|
await withProject(async ({ directory, slug: root, trackSession, trackDirectory }) => {
|
||||||
const workspaces = [] as { slug: string; directory: string }[]
|
await openSidebar(page)
|
||||||
const sessions = [] as string[]
|
await setWorkspacesEnabled(page, root, true)
|
||||||
|
|
||||||
try {
|
const first = await createWorkspace(page, root, [])
|
||||||
await openSidebar(page)
|
trackDirectory(first.directory)
|
||||||
await setWorkspacesEnabled(page, root, true)
|
await waitWorkspaceReady(page, first.slug)
|
||||||
|
|
||||||
const first = await createWorkspace(page, root, [])
|
const second = await createWorkspace(page, root, [first.slug])
|
||||||
workspaces.push(first)
|
trackDirectory(second.directory)
|
||||||
await waitWorkspaceReady(page, first.slug)
|
await waitWorkspaceReady(page, second.slug)
|
||||||
|
|
||||||
const second = await createWorkspace(page, root, [first.slug])
|
const firstSession = await createSessionFromWorkspace(page, first.slug, `workspace one ${Date.now()}`)
|
||||||
workspaces.push(second)
|
trackSession(firstSession.sessionID, first.directory)
|
||||||
await waitWorkspaceReady(page, second.slug)
|
|
||||||
|
|
||||||
const firstSession = await createSessionFromWorkspace(page, first.slug, `workspace one ${Date.now()}`)
|
const secondSession = await createSessionFromWorkspace(page, second.slug, `workspace two ${Date.now()}`)
|
||||||
sessions.push(firstSession)
|
trackSession(secondSession.sessionID, second.directory)
|
||||||
|
|
||||||
const secondSession = await createSessionFromWorkspace(page, second.slug, `workspace two ${Date.now()}`)
|
const thirdSession = await createSessionFromWorkspace(page, first.slug, `workspace one again ${Date.now()}`)
|
||||||
sessions.push(secondSession)
|
trackSession(thirdSession.sessionID, first.directory)
|
||||||
|
|
||||||
const thirdSession = await createSessionFromWorkspace(page, first.slug, `workspace one again ${Date.now()}`)
|
await expect.poll(() => sessionDirectory(first.directory, firstSession.sessionID)).toBe(first.directory)
|
||||||
sessions.push(thirdSession)
|
await expect.poll(() => sessionDirectory(second.directory, secondSession.sessionID)).toBe(second.directory)
|
||||||
|
await expect.poll(() => sessionDirectory(first.directory, thirdSession.sessionID)).toBe(first.directory)
|
||||||
await expect.poll(() => sessionDirectory(first.directory, firstSession)).toBe(first.directory)
|
|
||||||
await expect.poll(() => sessionDirectory(second.directory, secondSession)).toBe(second.directory)
|
|
||||||
await expect.poll(() => sessionDirectory(first.directory, thirdSession)).toBe(first.directory)
|
|
||||||
} finally {
|
|
||||||
const dirs = [directory, ...workspaces.map((workspace) => workspace.directory)]
|
|
||||||
await Promise.all(
|
|
||||||
sessions.map((sessionID) =>
|
|
||||||
Promise.all(
|
|
||||||
dirs.map((dir) =>
|
|
||||||
createSdk(dir)
|
|
||||||
.session.delete({ sessionID })
|
|
||||||
.catch(() => undefined),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
await Promise.all(workspaces.map((workspace) => cleanupTestProject(workspace.directory)))
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -22,6 +22,26 @@ function slugFromUrl(url: string) {
|
|||||||
return /\/([^/]+)\/session(?:\/|$)/.exec(url)?.[1] ?? ""
|
return /\/([^/]+)\/session(?:\/|$)/.exec(url)?.[1] ?? ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function waitSlug(page: Page, skip: string[] = []) {
|
||||||
|
let prev = ""
|
||||||
|
await expect
|
||||||
|
.poll(
|
||||||
|
() => {
|
||||||
|
const slug = slugFromUrl(page.url())
|
||||||
|
if (!slug) return ""
|
||||||
|
if (skip.includes(slug)) return ""
|
||||||
|
if (slug !== prev) {
|
||||||
|
prev = slug
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return slug
|
||||||
|
},
|
||||||
|
{ timeout: 45_000 },
|
||||||
|
)
|
||||||
|
.not.toBe("")
|
||||||
|
return slugFromUrl(page.url())
|
||||||
|
}
|
||||||
|
|
||||||
async function setupWorkspaceTest(page: Page, project: { slug: string }) {
|
async function setupWorkspaceTest(page: Page, project: { slug: string }) {
|
||||||
const rootSlug = project.slug
|
const rootSlug = project.slug
|
||||||
await openSidebar(page)
|
await openSidebar(page)
|
||||||
@@ -29,17 +49,7 @@ async function setupWorkspaceTest(page: Page, project: { slug: string }) {
|
|||||||
await setWorkspacesEnabled(page, rootSlug, true)
|
await setWorkspacesEnabled(page, rootSlug, true)
|
||||||
|
|
||||||
await page.getByRole("button", { name: "New workspace" }).first().click()
|
await page.getByRole("button", { name: "New workspace" }).first().click()
|
||||||
await expect
|
const slug = await waitSlug(page, [rootSlug])
|
||||||
.poll(
|
|
||||||
() => {
|
|
||||||
const slug = slugFromUrl(page.url())
|
|
||||||
return slug.length > 0 && slug !== rootSlug
|
|
||||||
},
|
|
||||||
{ timeout: 45_000 },
|
|
||||||
)
|
|
||||||
.toBe(true)
|
|
||||||
|
|
||||||
const slug = slugFromUrl(page.url())
|
|
||||||
const dir = base64Decode(slug)
|
const dir = base64Decode(slug)
|
||||||
|
|
||||||
await openSidebar(page)
|
await openSidebar(page)
|
||||||
@@ -91,18 +101,7 @@ test("can create a workspace", async ({ page, withProject }) => {
|
|||||||
await expect(page.getByRole("button", { name: "New workspace" }).first()).toBeVisible()
|
await expect(page.getByRole("button", { name: "New workspace" }).first()).toBeVisible()
|
||||||
|
|
||||||
await page.getByRole("button", { name: "New workspace" }).first().click()
|
await page.getByRole("button", { name: "New workspace" }).first().click()
|
||||||
|
const workspaceSlug = await waitSlug(page, [slug])
|
||||||
await expect
|
|
||||||
.poll(
|
|
||||||
() => {
|
|
||||||
const currentSlug = slugFromUrl(page.url())
|
|
||||||
return currentSlug.length > 0 && currentSlug !== slug
|
|
||||||
},
|
|
||||||
{ timeout: 45_000 },
|
|
||||||
)
|
|
||||||
.toBe(true)
|
|
||||||
|
|
||||||
const workspaceSlug = slugFromUrl(page.url())
|
|
||||||
const workspaceDir = base64Decode(workspaceSlug)
|
const workspaceDir = base64Decode(workspaceSlug)
|
||||||
|
|
||||||
await openSidebar(page)
|
await openSidebar(page)
|
||||||
@@ -279,7 +278,7 @@ test("can delete a workspace", async ({ page, withProject }) => {
|
|||||||
await clickMenuItem(menu, /^Delete$/i, { force: true })
|
await clickMenuItem(menu, /^Delete$/i, { force: true })
|
||||||
await confirmDialog(page, /^Delete workspace$/i)
|
await confirmDialog(page, /^Delete workspace$/i)
|
||||||
|
|
||||||
await expect(page).toHaveURL(new RegExp(`/${rootSlug}/session`))
|
await expect.poll(() => base64Decode(slugFromUrl(page.url()))).toBe(project.directory)
|
||||||
|
|
||||||
await expect
|
await expect
|
||||||
.poll(
|
.poll(
|
||||||
@@ -336,9 +335,6 @@ test("can reorder workspaces by drag and drop", async ({ page, withProject }) =>
|
|||||||
const src = page.locator(workspaceItemSelector(from)).first()
|
const src = page.locator(workspaceItemSelector(from)).first()
|
||||||
const dst = page.locator(workspaceItemSelector(to)).first()
|
const dst = page.locator(workspaceItemSelector(to)).first()
|
||||||
|
|
||||||
await src.scrollIntoViewIfNeeded()
|
|
||||||
await dst.scrollIntoViewIfNeeded()
|
|
||||||
|
|
||||||
const a = await src.boundingBox()
|
const a = await src.boundingBox()
|
||||||
const b = await dst.boundingBox()
|
const b = await dst.boundingBox()
|
||||||
if (!a || !b) throw new Error("Failed to resolve workspace drag bounds")
|
if (!a || !b) throw new Error("Failed to resolve workspace drag bounds")
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import { test, expect } from "../fixtures"
|
import { test, expect } from "../fixtures"
|
||||||
import { promptSelector } from "../selectors"
|
import { promptSelector } from "../selectors"
|
||||||
import { sessionIDFromUrl } from "../actions"
|
import { cleanupSession, sessionIDFromUrl, withSession } from "../actions"
|
||||||
|
|
||||||
|
const text = (value: string | null) => (value ?? "").replace(/\u200B/g, "").trim()
|
||||||
|
|
||||||
// Regression test for Issue #12453: the synchronous POST /message endpoint holds
|
// Regression test for Issue #12453: the synchronous POST /message endpoint holds
|
||||||
// the connection open while the agent works, causing "Failed to fetch" over
|
// the connection open while the agent works, causing "Failed to fetch" over
|
||||||
@@ -38,6 +40,37 @@ test("prompt succeeds when sync message endpoint is unreachable", async ({ page,
|
|||||||
)
|
)
|
||||||
.toContain(token)
|
.toContain(token)
|
||||||
} finally {
|
} finally {
|
||||||
await sdk.session.delete({ sessionID }).catch(() => undefined)
|
await cleanupSession({ sdk, sessionID })
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("failed prompt send restores the composer input", async ({ page, sdk, gotoSession }) => {
|
||||||
|
await withSession(sdk, `e2e prompt failure ${Date.now()}`, async (session) => {
|
||||||
|
const prompt = page.locator(promptSelector)
|
||||||
|
const value = `restore ${Date.now()}`
|
||||||
|
|
||||||
|
await page.route(`**/session/${session.id}/prompt_async`, (route) =>
|
||||||
|
route.fulfill({
|
||||||
|
status: 500,
|
||||||
|
contentType: "application/json",
|
||||||
|
body: JSON.stringify({ message: "e2e prompt failure" }),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
await gotoSession(session.id)
|
||||||
|
await prompt.click()
|
||||||
|
await page.keyboard.type(value)
|
||||||
|
await page.keyboard.press("Enter")
|
||||||
|
|
||||||
|
await expect.poll(async () => text(await prompt.textContent())).toBe(value)
|
||||||
|
await expect
|
||||||
|
.poll(
|
||||||
|
async () => {
|
||||||
|
const messages = await sdk.session.messages({ sessionID: session.id, limit: 50 }).then((r) => r.data ?? [])
|
||||||
|
return messages.length
|
||||||
|
},
|
||||||
|
{ timeout: 15_000 },
|
||||||
|
)
|
||||||
|
.toBe(0)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
@@ -0,0 +1,181 @@
|
|||||||
|
import type { ToolPart } from "@opencode-ai/sdk/v2/client"
|
||||||
|
import type { Page } from "@playwright/test"
|
||||||
|
import { test, expect } from "../fixtures"
|
||||||
|
import { withSession } from "../actions"
|
||||||
|
import { promptSelector } from "../selectors"
|
||||||
|
|
||||||
|
const text = (value: string | null) => (value ?? "").replace(/\u200B/g, "").trim()
|
||||||
|
|
||||||
|
const isBash = (part: unknown): part is ToolPart => {
|
||||||
|
if (!part || typeof part !== "object") return false
|
||||||
|
if (!("type" in part) || part.type !== "tool") return false
|
||||||
|
if (!("tool" in part) || part.tool !== "bash") return false
|
||||||
|
return "state" in part
|
||||||
|
}
|
||||||
|
|
||||||
|
async function edge(page: Page, pos: "start" | "end") {
|
||||||
|
await page.locator(promptSelector).evaluate((el: HTMLDivElement, pos: "start" | "end") => {
|
||||||
|
const selection = window.getSelection()
|
||||||
|
if (!selection) return
|
||||||
|
|
||||||
|
const walk = document.createTreeWalker(el, NodeFilter.SHOW_TEXT)
|
||||||
|
const nodes: Text[] = []
|
||||||
|
for (let node = walk.nextNode(); node; node = walk.nextNode()) {
|
||||||
|
nodes.push(node as Text)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (nodes.length === 0) {
|
||||||
|
const node = document.createTextNode("")
|
||||||
|
el.appendChild(node)
|
||||||
|
nodes.push(node)
|
||||||
|
}
|
||||||
|
|
||||||
|
const node = pos === "start" ? nodes[0]! : nodes[nodes.length - 1]!
|
||||||
|
const range = document.createRange()
|
||||||
|
range.setStart(node, pos === "start" ? 0 : (node.textContent ?? "").length)
|
||||||
|
range.collapse(true)
|
||||||
|
selection.removeAllRanges()
|
||||||
|
selection.addRange(range)
|
||||||
|
}, pos)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function wait(page: Page, value: string) {
|
||||||
|
await expect.poll(async () => text(await page.locator(promptSelector).textContent())).toBe(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function reply(sdk: Parameters<typeof withSession>[0], sessionID: string, token: string) {
|
||||||
|
await expect
|
||||||
|
.poll(
|
||||||
|
async () => {
|
||||||
|
const messages = await sdk.session.messages({ sessionID, limit: 50 }).then((r) => r.data ?? [])
|
||||||
|
return messages
|
||||||
|
.filter((item) => item.info.role === "assistant")
|
||||||
|
.flatMap((item) => item.parts)
|
||||||
|
.filter((item) => item.type === "text")
|
||||||
|
.map((item) => item.text)
|
||||||
|
.join("\n")
|
||||||
|
},
|
||||||
|
{ timeout: 90_000 },
|
||||||
|
)
|
||||||
|
.toContain(token)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function shell(sdk: Parameters<typeof withSession>[0], sessionID: string, cmd: string, token: string) {
|
||||||
|
await expect
|
||||||
|
.poll(
|
||||||
|
async () => {
|
||||||
|
const messages = await sdk.session.messages({ sessionID, limit: 50 }).then((r) => r.data ?? [])
|
||||||
|
const part = messages
|
||||||
|
.filter((item) => item.info.role === "assistant")
|
||||||
|
.flatMap((item) => item.parts)
|
||||||
|
.filter(isBash)
|
||||||
|
.find((item) => item.state.input?.command === cmd && item.state.status === "completed")
|
||||||
|
|
||||||
|
if (!part || part.state.status !== "completed") return
|
||||||
|
return typeof part.state.metadata?.output === "string" ? part.state.metadata.output : part.state.output
|
||||||
|
},
|
||||||
|
{ timeout: 90_000 },
|
||||||
|
)
|
||||||
|
.toContain(token)
|
||||||
|
}
|
||||||
|
|
||||||
|
test("prompt history restores unsent draft with arrow navigation", async ({ page, sdk, gotoSession }) => {
|
||||||
|
test.setTimeout(120_000)
|
||||||
|
|
||||||
|
await withSession(sdk, `e2e prompt history ${Date.now()}`, async (session) => {
|
||||||
|
await gotoSession(session.id)
|
||||||
|
|
||||||
|
const prompt = page.locator(promptSelector)
|
||||||
|
const firstToken = `E2E_HISTORY_ONE_${Date.now()}`
|
||||||
|
const secondToken = `E2E_HISTORY_TWO_${Date.now()}`
|
||||||
|
const first = `Reply with exactly: ${firstToken}`
|
||||||
|
const second = `Reply with exactly: ${secondToken}`
|
||||||
|
const draft = `draft ${Date.now()}`
|
||||||
|
|
||||||
|
await prompt.click()
|
||||||
|
await page.keyboard.type(first)
|
||||||
|
await page.keyboard.press("Enter")
|
||||||
|
await wait(page, "")
|
||||||
|
await reply(sdk, session.id, firstToken)
|
||||||
|
|
||||||
|
await prompt.click()
|
||||||
|
await page.keyboard.type(second)
|
||||||
|
await page.keyboard.press("Enter")
|
||||||
|
await wait(page, "")
|
||||||
|
await reply(sdk, session.id, secondToken)
|
||||||
|
|
||||||
|
await prompt.click()
|
||||||
|
await page.keyboard.type(draft)
|
||||||
|
await wait(page, draft)
|
||||||
|
|
||||||
|
await edge(page, "start")
|
||||||
|
await page.keyboard.press("ArrowUp")
|
||||||
|
await wait(page, second)
|
||||||
|
|
||||||
|
await page.keyboard.press("ArrowUp")
|
||||||
|
await wait(page, first)
|
||||||
|
|
||||||
|
await page.keyboard.press("ArrowDown")
|
||||||
|
await wait(page, second)
|
||||||
|
|
||||||
|
await page.keyboard.press("ArrowDown")
|
||||||
|
await wait(page, draft)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test("shell history stays separate from normal prompt history", async ({ page, sdk, gotoSession }) => {
|
||||||
|
test.setTimeout(120_000)
|
||||||
|
|
||||||
|
await withSession(sdk, `e2e shell history ${Date.now()}`, async (session) => {
|
||||||
|
await gotoSession(session.id)
|
||||||
|
|
||||||
|
const prompt = page.locator(promptSelector)
|
||||||
|
const firstToken = `E2E_SHELL_ONE_${Date.now()}`
|
||||||
|
const secondToken = `E2E_SHELL_TWO_${Date.now()}`
|
||||||
|
const normalToken = `E2E_NORMAL_${Date.now()}`
|
||||||
|
const first = `echo ${firstToken}`
|
||||||
|
const second = `echo ${secondToken}`
|
||||||
|
const normal = `Reply with exactly: ${normalToken}`
|
||||||
|
|
||||||
|
await prompt.click()
|
||||||
|
await page.keyboard.type("!")
|
||||||
|
await page.keyboard.type(first)
|
||||||
|
await page.keyboard.press("Enter")
|
||||||
|
await wait(page, "")
|
||||||
|
await shell(sdk, session.id, first, firstToken)
|
||||||
|
|
||||||
|
await prompt.click()
|
||||||
|
await page.keyboard.type("!")
|
||||||
|
await page.keyboard.type(second)
|
||||||
|
await page.keyboard.press("Enter")
|
||||||
|
await wait(page, "")
|
||||||
|
await shell(sdk, session.id, second, secondToken)
|
||||||
|
|
||||||
|
await prompt.click()
|
||||||
|
await page.keyboard.type("!")
|
||||||
|
await page.keyboard.press("ArrowUp")
|
||||||
|
await wait(page, second)
|
||||||
|
|
||||||
|
await page.keyboard.press("ArrowUp")
|
||||||
|
await wait(page, first)
|
||||||
|
|
||||||
|
await page.keyboard.press("ArrowDown")
|
||||||
|
await wait(page, second)
|
||||||
|
|
||||||
|
await page.keyboard.press("ArrowDown")
|
||||||
|
await wait(page, "")
|
||||||
|
|
||||||
|
await page.keyboard.press("Escape")
|
||||||
|
await wait(page, "")
|
||||||
|
|
||||||
|
await prompt.click()
|
||||||
|
await page.keyboard.type(normal)
|
||||||
|
await page.keyboard.press("Enter")
|
||||||
|
await wait(page, "")
|
||||||
|
await reply(sdk, session.id, normalToken)
|
||||||
|
|
||||||
|
await prompt.click()
|
||||||
|
await page.keyboard.press("ArrowUp")
|
||||||
|
await wait(page, normal)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import type { ToolPart } from "@opencode-ai/sdk/v2/client"
|
||||||
|
import { test, expect } from "../fixtures"
|
||||||
|
import { sessionIDFromUrl } from "../actions"
|
||||||
|
import { promptSelector } from "../selectors"
|
||||||
|
import { createSdk } from "../utils"
|
||||||
|
|
||||||
|
const isBash = (part: unknown): part is ToolPart => {
|
||||||
|
if (!part || typeof part !== "object") return false
|
||||||
|
if (!("type" in part) || part.type !== "tool") return false
|
||||||
|
if (!("tool" in part) || part.tool !== "bash") return false
|
||||||
|
return "state" in part
|
||||||
|
}
|
||||||
|
|
||||||
|
test("shell mode runs a command in the project directory", async ({ page, withProject }) => {
|
||||||
|
test.setTimeout(120_000)
|
||||||
|
|
||||||
|
await withProject(async ({ directory, gotoSession, trackSession }) => {
|
||||||
|
const sdk = createSdk(directory)
|
||||||
|
const prompt = page.locator(promptSelector)
|
||||||
|
const cmd = process.platform === "win32" ? "dir" : "ls"
|
||||||
|
|
||||||
|
await gotoSession()
|
||||||
|
await prompt.click()
|
||||||
|
await page.keyboard.type("!")
|
||||||
|
await expect(prompt).toHaveAttribute("aria-label", /enter shell command/i)
|
||||||
|
|
||||||
|
await page.keyboard.type(cmd)
|
||||||
|
await page.keyboard.press("Enter")
|
||||||
|
|
||||||
|
await expect(page).toHaveURL(/\/session\/[^/?#]+/, { timeout: 30_000 })
|
||||||
|
|
||||||
|
const id = sessionIDFromUrl(page.url())
|
||||||
|
if (!id) throw new Error(`Failed to parse session id from url: ${page.url()}`)
|
||||||
|
trackSession(id, directory)
|
||||||
|
|
||||||
|
await expect
|
||||||
|
.poll(
|
||||||
|
async () => {
|
||||||
|
const list = await sdk.session.messages({ sessionID: id, limit: 50 }).then((x) => x.data ?? [])
|
||||||
|
const msg = list.findLast(
|
||||||
|
(item) => item.info.role === "assistant" && "path" in item.info && item.info.path.cwd === directory,
|
||||||
|
)
|
||||||
|
if (!msg) return
|
||||||
|
|
||||||
|
const part = msg.parts
|
||||||
|
.filter(isBash)
|
||||||
|
.find((item) => item.state.input?.command === cmd && item.state.status === "completed")
|
||||||
|
|
||||||
|
if (!part || part.state.status !== "completed") return
|
||||||
|
const output =
|
||||||
|
typeof part.state.metadata?.output === "string" ? part.state.metadata.output : part.state.output
|
||||||
|
if (!output.includes("README.md")) return
|
||||||
|
|
||||||
|
return { cwd: directory, output }
|
||||||
|
},
|
||||||
|
{ timeout: 90_000 },
|
||||||
|
)
|
||||||
|
.toEqual(expect.objectContaining({ cwd: directory, output: expect.stringContaining("README.md") }))
|
||||||
|
|
||||||
|
await expect(prompt).toHaveText("")
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import { test, expect } from "../fixtures"
|
||||||
|
import { promptSelector } from "../selectors"
|
||||||
|
import { withSession } from "../actions"
|
||||||
|
|
||||||
|
const shareDisabled = process.env.OPENCODE_DISABLE_SHARE === "true" || process.env.OPENCODE_DISABLE_SHARE === "1"
|
||||||
|
|
||||||
|
async function seed(sdk: Parameters<typeof withSession>[0], sessionID: string) {
|
||||||
|
await sdk.session.promptAsync({
|
||||||
|
sessionID,
|
||||||
|
noReply: true,
|
||||||
|
parts: [{ type: "text", text: "e2e share seed" }],
|
||||||
|
})
|
||||||
|
|
||||||
|
await expect
|
||||||
|
.poll(
|
||||||
|
async () => {
|
||||||
|
const messages = await sdk.session.messages({ sessionID, limit: 1 }).then((r) => r.data ?? [])
|
||||||
|
return messages.length
|
||||||
|
},
|
||||||
|
{ timeout: 30_000 },
|
||||||
|
)
|
||||||
|
.toBeGreaterThan(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
test("/share and /unshare update session share state", async ({ page, sdk, gotoSession }) => {
|
||||||
|
test.skip(shareDisabled, "Share is disabled in this environment (OPENCODE_DISABLE_SHARE).")
|
||||||
|
|
||||||
|
await withSession(sdk, `e2e slash share ${Date.now()}`, async (session) => {
|
||||||
|
const prompt = page.locator(promptSelector)
|
||||||
|
|
||||||
|
await seed(sdk, session.id)
|
||||||
|
await gotoSession(session.id)
|
||||||
|
|
||||||
|
await prompt.click()
|
||||||
|
await page.keyboard.type("/share")
|
||||||
|
await expect(page.locator('[data-slash-id="session.share"]').first()).toBeVisible()
|
||||||
|
await page.keyboard.press("Enter")
|
||||||
|
|
||||||
|
await expect
|
||||||
|
.poll(
|
||||||
|
async () => {
|
||||||
|
const data = await sdk.session.get({ sessionID: session.id }).then((r) => r.data)
|
||||||
|
return data?.share?.url || undefined
|
||||||
|
},
|
||||||
|
{ timeout: 30_000 },
|
||||||
|
)
|
||||||
|
.not.toBeUndefined()
|
||||||
|
|
||||||
|
await prompt.click()
|
||||||
|
await page.keyboard.type("/unshare")
|
||||||
|
await expect(page.locator('[data-slash-id="session.unshare"]').first()).toBeVisible()
|
||||||
|
await page.keyboard.press("Enter")
|
||||||
|
|
||||||
|
await expect
|
||||||
|
.poll(
|
||||||
|
async () => {
|
||||||
|
const data = await sdk.session.get({ sessionID: session.id }).then((r) => r.data)
|
||||||
|
return data?.share?.url || undefined
|
||||||
|
},
|
||||||
|
{ timeout: 30_000 },
|
||||||
|
)
|
||||||
|
.toBeUndefined()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { test, expect } from "../fixtures"
|
import { test, expect } from "../fixtures"
|
||||||
import { promptSelector } from "../selectors"
|
import { promptSelector } from "../selectors"
|
||||||
import { sessionIDFromUrl, withSession } from "../actions"
|
import { cleanupSession, sessionIDFromUrl, withSession } from "../actions"
|
||||||
|
|
||||||
test("can send a prompt and receive a reply", async ({ page, sdk, gotoSession }) => {
|
test("can send a prompt and receive a reply", async ({ page, sdk, gotoSession }) => {
|
||||||
test.setTimeout(120_000)
|
test.setTimeout(120_000)
|
||||||
@@ -46,7 +46,7 @@ test("can send a prompt and receive a reply", async ({ page, sdk, gotoSession })
|
|||||||
.toContain(token)
|
.toContain(token)
|
||||||
} finally {
|
} finally {
|
||||||
page.off("pageerror", onPageError)
|
page.off("pageerror", onPageError)
|
||||||
await sdk.session.delete({ sessionID }).catch(() => undefined)
|
await cleanupSession({ sdk, sessionID })
|
||||||
}
|
}
|
||||||
|
|
||||||
if (pageErrors.length > 0) {
|
if (pageErrors.length > 0) {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { test, expect } from "../fixtures"
|
import { test, expect } from "../fixtures"
|
||||||
import { clearSessionDockSeed, seedSessionQuestion, seedSessionTodos } from "../actions"
|
import { cleanupSession, clearSessionDockSeed, seedSessionQuestion, seedSessionTodos } from "../actions"
|
||||||
import {
|
import {
|
||||||
permissionDockSelector,
|
permissionDockSelector,
|
||||||
promptSelector,
|
promptSelector,
|
||||||
@@ -26,7 +26,7 @@ async function withDockSession<T>(
|
|||||||
try {
|
try {
|
||||||
return await fn(session)
|
return await fn(session)
|
||||||
} finally {
|
} finally {
|
||||||
await sdk.session.delete({ sessionID: session.id }).catch(() => undefined)
|
await cleanupSession({ sdk, sessionID: session.id })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -311,7 +311,7 @@ test("child session question request blocks parent dock and unblocks after submi
|
|||||||
await expect(page.locator(promptSelector)).toBeVisible()
|
await expect(page.locator(promptSelector)).toBeVisible()
|
||||||
})
|
})
|
||||||
} finally {
|
} finally {
|
||||||
await sdk.session.delete({ sessionID: child.id }).catch(() => undefined)
|
await cleanupSession({ sdk, sessionID: child.id })
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
@@ -358,7 +358,7 @@ test("child session permission request blocks parent dock and supports allow onc
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
} finally {
|
} finally {
|
||||||
await sdk.session.delete({ sessionID: child.id }).catch(() => undefined)
|
await cleanupSession({ sdk, sessionID: child.id })
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -32,22 +32,19 @@ test("changing sidebar toggle keybind works", async ({ page, gotoSession }) => {
|
|||||||
|
|
||||||
await closeDialog(page, dialog)
|
await closeDialog(page, dialog)
|
||||||
|
|
||||||
const main = page.locator("main")
|
const button = page.getByRole("button", { name: /toggle sidebar/i }).first()
|
||||||
const initialClasses = (await main.getAttribute("class")) ?? ""
|
const initiallyClosed = (await button.getAttribute("aria-expanded")) !== "true"
|
||||||
const initiallyClosed = initialClasses.includes("xl:border-l")
|
|
||||||
|
|
||||||
await page.keyboard.press(`${modKey}+Shift+H`)
|
await page.keyboard.press(`${modKey}+Shift+H`)
|
||||||
await page.waitForTimeout(100)
|
await expect(button).toHaveAttribute("aria-expanded", initiallyClosed ? "true" : "false")
|
||||||
|
|
||||||
const afterToggleClasses = (await main.getAttribute("class")) ?? ""
|
const afterToggleClosed = (await button.getAttribute("aria-expanded")) !== "true"
|
||||||
const afterToggleClosed = afterToggleClasses.includes("xl:border-l")
|
|
||||||
expect(afterToggleClosed).toBe(!initiallyClosed)
|
expect(afterToggleClosed).toBe(!initiallyClosed)
|
||||||
|
|
||||||
await page.keyboard.press(`${modKey}+Shift+H`)
|
await page.keyboard.press(`${modKey}+Shift+H`)
|
||||||
await page.waitForTimeout(100)
|
await expect(button).toHaveAttribute("aria-expanded", initiallyClosed ? "false" : "true")
|
||||||
|
|
||||||
const finalClasses = (await main.getAttribute("class")) ?? ""
|
const finalClosed = (await button.getAttribute("aria-expanded")) !== "true"
|
||||||
const finalClosed = finalClasses.includes("xl:border-l")
|
|
||||||
expect(finalClosed).toBe(initiallyClosed)
|
expect(finalClosed).toBe(initiallyClosed)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { test, expect } from "../fixtures"
|
import { test, expect } from "../fixtures"
|
||||||
import { closeSidebar, hoverSessionItem } from "../actions"
|
import { cleanupSession, closeSidebar, hoverSessionItem } from "../actions"
|
||||||
import { projectSwitchSelector, sessionItemSelector } from "../selectors"
|
import { projectSwitchSelector } from "../selectors"
|
||||||
|
|
||||||
test("collapsed sidebar popover stays open when archiving a session", async ({ page, slug, sdk, gotoSession }) => {
|
test("collapsed sidebar popover stays open when archiving a session", async ({ page, slug, sdk, gotoSession }) => {
|
||||||
const stamp = Date.now()
|
const stamp = Date.now()
|
||||||
@@ -15,12 +15,15 @@ test("collapsed sidebar popover stays open when archiving a session", async ({ p
|
|||||||
await gotoSession(one.id)
|
await gotoSession(one.id)
|
||||||
await closeSidebar(page)
|
await closeSidebar(page)
|
||||||
|
|
||||||
|
const oneItem = page.locator(`[data-session-id="${one.id}"]`).last()
|
||||||
|
const twoItem = page.locator(`[data-session-id="${two.id}"]`).last()
|
||||||
|
|
||||||
const project = page.locator(projectSwitchSelector(slug)).first()
|
const project = page.locator(projectSwitchSelector(slug)).first()
|
||||||
await expect(project).toBeVisible()
|
await expect(project).toBeVisible()
|
||||||
await project.hover()
|
await project.hover()
|
||||||
|
|
||||||
await expect(page.locator(sessionItemSelector(one.id)).first()).toBeVisible()
|
await expect(oneItem).toBeVisible()
|
||||||
await expect(page.locator(sessionItemSelector(two.id)).first()).toBeVisible()
|
await expect(twoItem).toBeVisible()
|
||||||
|
|
||||||
const item = await hoverSessionItem(page, one.id)
|
const item = await hoverSessionItem(page, one.id)
|
||||||
await item
|
await item
|
||||||
@@ -28,9 +31,9 @@ test("collapsed sidebar popover stays open when archiving a session", async ({ p
|
|||||||
.first()
|
.first()
|
||||||
.click()
|
.click()
|
||||||
|
|
||||||
await expect(page.locator(sessionItemSelector(two.id)).first()).toBeVisible()
|
await expect(twoItem).toBeVisible()
|
||||||
} finally {
|
} finally {
|
||||||
await sdk.session.delete({ sessionID: one.id }).catch(() => undefined)
|
await cleanupSession({ sdk, sessionID: one.id })
|
||||||
await sdk.session.delete({ sessionID: two.id }).catch(() => undefined)
|
await cleanupSession({ sdk, sessionID: two.id })
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { test, expect } from "../fixtures"
|
import { test, expect } from "../fixtures"
|
||||||
import { openSidebar, withSession } from "../actions"
|
import { cleanupSession, openSidebar, withSession } from "../actions"
|
||||||
import { promptSelector } from "../selectors"
|
import { promptSelector } from "../selectors"
|
||||||
|
|
||||||
test("sidebar session links navigate to the selected session", async ({ page, slug, sdk, gotoSession }) => {
|
test("sidebar session links navigate to the selected session", async ({ page, slug, sdk, gotoSession }) => {
|
||||||
@@ -18,14 +18,13 @@ test("sidebar session links navigate to the selected session", async ({ page, sl
|
|||||||
|
|
||||||
const target = page.locator(`[data-session-id="${two.id}"] a`).first()
|
const target = page.locator(`[data-session-id="${two.id}"] a`).first()
|
||||||
await expect(target).toBeVisible()
|
await expect(target).toBeVisible()
|
||||||
await target.scrollIntoViewIfNeeded()
|
|
||||||
await target.click()
|
await target.click()
|
||||||
|
|
||||||
await expect(page).toHaveURL(new RegExp(`/${slug}/session/${two.id}(?:\\?|#|$)`))
|
await expect(page).toHaveURL(new RegExp(`/${slug}/session/${two.id}(?:\\?|#|$)`))
|
||||||
await expect(page.locator(promptSelector)).toBeVisible()
|
await expect(page.locator(promptSelector)).toBeVisible()
|
||||||
await expect(page.locator(`[data-session-id="${two.id}"] a`).first()).toHaveClass(/\bactive\b/)
|
await expect(page.locator(`[data-session-id="${two.id}"] a`).first()).toHaveClass(/\bactive\b/)
|
||||||
} finally {
|
} finally {
|
||||||
await sdk.session.delete({ sessionID: one.id }).catch(() => undefined)
|
await cleanupSession({ sdk, sessionID: one.id })
|
||||||
await sdk.session.delete({ sessionID: two.id }).catch(() => undefined)
|
await cleanupSession({ sdk, sessionID: two.id })
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -5,12 +5,14 @@ test("sidebar can be collapsed and expanded", async ({ page, gotoSession }) => {
|
|||||||
await gotoSession()
|
await gotoSession()
|
||||||
|
|
||||||
await openSidebar(page)
|
await openSidebar(page)
|
||||||
|
const button = page.getByRole("button", { name: /toggle sidebar/i }).first()
|
||||||
|
await expect(button).toHaveAttribute("aria-expanded", "true")
|
||||||
|
|
||||||
await toggleSidebar(page)
|
await toggleSidebar(page)
|
||||||
await expect(page.locator("main")).toHaveClass(/xl:border-l/)
|
await expect(button).toHaveAttribute("aria-expanded", "false")
|
||||||
|
|
||||||
await toggleSidebar(page)
|
await toggleSidebar(page)
|
||||||
await expect(page.locator("main")).not.toHaveClass(/xl:border-l/)
|
await expect(button).toHaveAttribute("aria-expanded", "true")
|
||||||
})
|
})
|
||||||
|
|
||||||
test("sidebar collapsed state persists across navigation and reload", async ({ page, sdk, gotoSession }) => {
|
test("sidebar collapsed state persists across navigation and reload", async ({ page, sdk, gotoSession }) => {
|
||||||
@@ -19,14 +21,15 @@ test("sidebar collapsed state persists across navigation and reload", async ({ p
|
|||||||
await gotoSession(session1.id)
|
await gotoSession(session1.id)
|
||||||
|
|
||||||
await openSidebar(page)
|
await openSidebar(page)
|
||||||
|
const button = page.getByRole("button", { name: /toggle sidebar/i }).first()
|
||||||
await toggleSidebar(page)
|
await toggleSidebar(page)
|
||||||
await expect(page.locator("main")).toHaveClass(/xl:border-l/)
|
await expect(button).toHaveAttribute("aria-expanded", "false")
|
||||||
|
|
||||||
await gotoSession(session2.id)
|
await gotoSession(session2.id)
|
||||||
await expect(page.locator("main")).toHaveClass(/xl:border-l/)
|
await expect(button).toHaveAttribute("aria-expanded", "false")
|
||||||
|
|
||||||
await page.reload()
|
await page.reload()
|
||||||
await expect(page.locator("main")).toHaveClass(/xl:border-l/)
|
await expect(button).toHaveAttribute("aria-expanded", "false")
|
||||||
|
|
||||||
const opened = await page.evaluate(
|
const opened = await page.evaluate(
|
||||||
() => JSON.parse(localStorage.getItem("opencode.global.dat:layout") ?? "{}").sidebar?.opened,
|
() => JSON.parse(localStorage.getItem("opencode.global.dat:layout") ?? "{}").sidebar?.opened,
|
||||||
|
|||||||
@@ -0,0 +1,120 @@
|
|||||||
|
import type { Page } from "@playwright/test"
|
||||||
|
import { test, expect } from "../fixtures"
|
||||||
|
import { terminalSelector } from "../selectors"
|
||||||
|
import { terminalToggleKey, workspacePersistKey } from "../utils"
|
||||||
|
|
||||||
|
type State = {
|
||||||
|
active?: string
|
||||||
|
all: Array<{
|
||||||
|
id: string
|
||||||
|
title: string
|
||||||
|
titleNumber: number
|
||||||
|
buffer?: string
|
||||||
|
}>
|
||||||
|
}
|
||||||
|
|
||||||
|
async function open(page: Page) {
|
||||||
|
const terminal = page.locator(terminalSelector)
|
||||||
|
const visible = await terminal.isVisible().catch(() => false)
|
||||||
|
if (!visible) await page.keyboard.press(terminalToggleKey)
|
||||||
|
await expect(terminal).toBeVisible()
|
||||||
|
await expect(terminal.locator("textarea")).toHaveCount(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function run(page: Page, cmd: string) {
|
||||||
|
const terminal = page.locator(terminalSelector)
|
||||||
|
await expect(terminal).toBeVisible()
|
||||||
|
await terminal.click()
|
||||||
|
await page.keyboard.type(cmd)
|
||||||
|
await page.keyboard.press("Enter")
|
||||||
|
}
|
||||||
|
|
||||||
|
async function store(page: Page, key: string) {
|
||||||
|
return page.evaluate((key) => {
|
||||||
|
const raw = localStorage.getItem(key)
|
||||||
|
if (raw) return JSON.parse(raw) as State
|
||||||
|
|
||||||
|
for (let i = 0; i < localStorage.length; i++) {
|
||||||
|
const next = localStorage.key(i)
|
||||||
|
if (!next?.endsWith(":workspace:terminal")) continue
|
||||||
|
const value = localStorage.getItem(next)
|
||||||
|
if (!value) continue
|
||||||
|
return JSON.parse(value) as State
|
||||||
|
}
|
||||||
|
}, key)
|
||||||
|
}
|
||||||
|
|
||||||
|
test("terminal tab buffers persist across tab switches", async ({ page, withProject }) => {
|
||||||
|
await withProject(async ({ directory, gotoSession }) => {
|
||||||
|
const key = workspacePersistKey(directory, "terminal")
|
||||||
|
const one = `E2E_TERM_ONE_${Date.now()}`
|
||||||
|
const two = `E2E_TERM_TWO_${Date.now()}`
|
||||||
|
const tabs = page.locator('#terminal-panel [data-slot="tabs-trigger"]')
|
||||||
|
|
||||||
|
await gotoSession()
|
||||||
|
await open(page)
|
||||||
|
|
||||||
|
await run(page, `echo ${one}`)
|
||||||
|
|
||||||
|
await page.getByRole("button", { name: /new terminal/i }).click()
|
||||||
|
await expect(tabs).toHaveCount(2)
|
||||||
|
|
||||||
|
await run(page, `echo ${two}`)
|
||||||
|
|
||||||
|
await tabs
|
||||||
|
.filter({ hasText: /Terminal 1/ })
|
||||||
|
.first()
|
||||||
|
.click()
|
||||||
|
|
||||||
|
await expect
|
||||||
|
.poll(
|
||||||
|
async () => {
|
||||||
|
const state = await store(page, key)
|
||||||
|
const first = state?.all.find((item) => item.titleNumber === 1)?.buffer ?? ""
|
||||||
|
const second = state?.all.find((item) => item.titleNumber === 2)?.buffer ?? ""
|
||||||
|
return first.includes(one) && second.includes(two)
|
||||||
|
},
|
||||||
|
{ timeout: 30_000 },
|
||||||
|
)
|
||||||
|
.toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test("closing the active terminal tab falls back to the previous tab", async ({ page, withProject }) => {
|
||||||
|
await withProject(async ({ directory, gotoSession }) => {
|
||||||
|
const key = workspacePersistKey(directory, "terminal")
|
||||||
|
const tabs = page.locator('#terminal-panel [data-slot="tabs-trigger"]')
|
||||||
|
|
||||||
|
await gotoSession()
|
||||||
|
await open(page)
|
||||||
|
|
||||||
|
await page.getByRole("button", { name: /new terminal/i }).click()
|
||||||
|
await expect(tabs).toHaveCount(2)
|
||||||
|
|
||||||
|
const second = tabs.filter({ hasText: /Terminal 2/ }).first()
|
||||||
|
await second.click()
|
||||||
|
await expect(second).toHaveAttribute("aria-selected", "true")
|
||||||
|
|
||||||
|
await second.hover()
|
||||||
|
await page
|
||||||
|
.getByRole("button", { name: /close terminal/i })
|
||||||
|
.nth(1)
|
||||||
|
.click({ force: true })
|
||||||
|
|
||||||
|
const first = tabs.filter({ hasText: /Terminal 1/ }).first()
|
||||||
|
await expect(tabs).toHaveCount(1)
|
||||||
|
await expect(first).toHaveAttribute("aria-selected", "true")
|
||||||
|
await expect
|
||||||
|
.poll(
|
||||||
|
async () => {
|
||||||
|
const state = await store(page, key)
|
||||||
|
return {
|
||||||
|
count: state?.all.length ?? 0,
|
||||||
|
first: state?.all.some((item) => item.titleNumber === 1) ?? false,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ timeout: 15_000 },
|
||||||
|
)
|
||||||
|
.toEqual({ count: 1, first: true })
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { createOpencodeClient } from "@opencode-ai/sdk/v2/client"
|
import { createOpencodeClient } from "@opencode-ai/sdk/v2/client"
|
||||||
import { base64Encode } from "@opencode-ai/util/encode"
|
import { base64Encode, checksum } from "@opencode-ai/util/encode"
|
||||||
|
|
||||||
export const serverHost = process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"
|
export const serverHost = process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"
|
||||||
export const serverPort = process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"
|
export const serverPort = process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"
|
||||||
@@ -14,6 +14,12 @@ export function createSdk(directory?: string) {
|
|||||||
return createOpencodeClient({ baseUrl: serverUrl, directory, throwOnError: true })
|
return createOpencodeClient({ baseUrl: serverUrl, directory, throwOnError: true })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function resolveDirectory(directory: string) {
|
||||||
|
return createSdk(directory)
|
||||||
|
.path.get()
|
||||||
|
.then((x) => x.data?.directory ?? directory)
|
||||||
|
}
|
||||||
|
|
||||||
export async function getWorktree() {
|
export async function getWorktree() {
|
||||||
const sdk = createSdk()
|
const sdk = createSdk()
|
||||||
const result = await sdk.path.get()
|
const result = await sdk.path.get()
|
||||||
@@ -33,3 +39,9 @@ export function dirPath(directory: string) {
|
|||||||
export function sessionPath(directory: string, sessionID?: string) {
|
export function sessionPath(directory: string, sessionID?: string) {
|
||||||
return `${dirPath(directory)}/session${sessionID ? `/${sessionID}` : ""}`
|
return `${dirPath(directory)}/session${sessionID ? `/${sessionID}` : ""}`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function workspacePersistKey(directory: string, key: string) {
|
||||||
|
const head = directory.slice(0, 12) || "workspace"
|
||||||
|
const sum = checksum(directory) ?? "0"
|
||||||
|
return `opencode.workspace.${head}.${sum}.dat:workspace:${key}`
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@opencode-ai/app",
|
"name": "@opencode-ai/app",
|
||||||
"version": "1.2.17",
|
"version": "1.2.20",
|
||||||
"description": "",
|
"description": "",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"exports": {
|
"exports": {
|
||||||
|
|||||||
@@ -244,7 +244,6 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
draggingType: "image" | "@mention" | null
|
draggingType: "image" | "@mention" | null
|
||||||
mode: "normal" | "shell"
|
mode: "normal" | "shell"
|
||||||
applyingHistory: boolean
|
applyingHistory: boolean
|
||||||
pendingAutoAccept: boolean
|
|
||||||
}>({
|
}>({
|
||||||
popover: null,
|
popover: null,
|
||||||
historyIndex: -1,
|
historyIndex: -1,
|
||||||
@@ -253,7 +252,6 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
draggingType: null,
|
draggingType: null,
|
||||||
mode: "normal",
|
mode: "normal",
|
||||||
applyingHistory: false,
|
applyingHistory: false,
|
||||||
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 })
|
||||||
@@ -306,12 +304,6 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
createEffect(
|
|
||||||
on(sessionKey, () => {
|
|
||||||
setStore("pendingAutoAccept", false)
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
const historyComments = () => {
|
const historyComments = () => {
|
||||||
const byID = new Map(comments.all().map((item) => [`${item.file}\n${item.id}`, item] as const))
|
const byID = new Map(comments.all().map((item) => [`${item.file}\n${item.id}`, item] as const))
|
||||||
return prompt.context.items().flatMap((item) => {
|
return prompt.context.items().flatMap((item) => {
|
||||||
@@ -961,7 +953,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
const variants = createMemo(() => ["default", ...local.model.variant.list()])
|
const variants = createMemo(() => ["default", ...local.model.variant.list()])
|
||||||
const accepting = createMemo(() => {
|
const accepting = createMemo(() => {
|
||||||
const id = params.id
|
const id = params.id
|
||||||
if (!id) return store.pendingAutoAccept
|
if (!id) return permission.isAutoAcceptingDirectory(sdk.directory)
|
||||||
return permission.isAutoAccepting(id, sdk.directory)
|
return permission.isAutoAccepting(id, sdk.directory)
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -1211,9 +1203,9 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
aria-multiline="true"
|
aria-multiline="true"
|
||||||
aria-label={placeholder()}
|
aria-label={placeholder()}
|
||||||
contenteditable="true"
|
contenteditable="true"
|
||||||
autocapitalize="off"
|
autocapitalize={store.mode === "normal" ? "sentences" : "off"}
|
||||||
autocorrect="off"
|
autocorrect={store.mode === "normal" ? "on" : "off"}
|
||||||
spellcheck={false}
|
spellcheck={store.mode === "normal"}
|
||||||
onInput={handleInput}
|
onInput={handleInput}
|
||||||
onPaste={handlePaste}
|
onPaste={handlePaste}
|
||||||
onCompositionStart={() => setComposing(true)}
|
onCompositionStart={() => setComposing(true)}
|
||||||
@@ -1336,7 +1328,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
variant="ghost"
|
variant="ghost"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (!params.id) {
|
if (!params.id) {
|
||||||
setStore("pendingAutoAccept", (value) => !value)
|
permission.toggleAutoAcceptDirectory(sdk.directory)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
permission.toggleAutoAccept(params.id, sdk.directory)
|
permission.toggleAutoAccept(params.id, sdk.directory)
|
||||||
|
|||||||
@@ -146,6 +146,7 @@ const DICT: Record<Locale, Dictionary> = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const localeMatchers: Array<{ locale: Locale; match: (language: string) => boolean }> = [
|
const localeMatchers: Array<{ locale: Locale; match: (language: string) => boolean }> = [
|
||||||
|
{ locale: "en", match: (language) => language.startsWith("en") },
|
||||||
{ locale: "zht", match: (language) => language.startsWith("zh") && language.includes("hant") },
|
{ locale: "zht", match: (language) => language.startsWith("zh") && language.includes("hant") },
|
||||||
{ locale: "zh", match: (language) => language.startsWith("zh") },
|
{ locale: "zh", match: (language) => language.startsWith("zh") },
|
||||||
{ locale: "ko", match: (language) => language.startsWith("ko") },
|
{ locale: "ko", match: (language) => language.startsWith("ko") },
|
||||||
@@ -217,6 +218,7 @@ export const { use: useLanguage, provider: LanguageProvider } = createSimpleCont
|
|||||||
)
|
)
|
||||||
|
|
||||||
const locale = createMemo<Locale>(() => normalizeLocale(store.locale))
|
const locale = createMemo<Locale>(() => normalizeLocale(store.locale))
|
||||||
|
console.log("locale", locale())
|
||||||
const intl = createMemo(() => INTL[locale()])
|
const intl = createMemo(() => INTL[locale()])
|
||||||
|
|
||||||
const dict = createMemo<Dictionary>(() => DICT[locale()])
|
const dict = createMemo<Dictionary>(() => DICT[locale()])
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { describe, expect, test } from "bun:test"
|
import { describe, expect, test } from "bun:test"
|
||||||
import type { PermissionRequest, Session } from "@opencode-ai/sdk/v2/client"
|
import type { PermissionRequest, Session } from "@opencode-ai/sdk/v2/client"
|
||||||
import { base64Encode } from "@opencode-ai/util/encode"
|
import { base64Encode } from "@opencode-ai/util/encode"
|
||||||
import { autoRespondsPermission } from "./permission-auto-respond"
|
import { autoRespondsPermission, isDirectoryAutoAccepting } from "./permission-auto-respond"
|
||||||
|
|
||||||
const session = (input: { id: string; parentID?: string }) =>
|
const session = (input: { id: string; parentID?: string }) =>
|
||||||
({
|
({
|
||||||
@@ -60,4 +60,43 @@ describe("autoRespondsPermission", () => {
|
|||||||
|
|
||||||
expect(autoRespondsPermission(autoAccept, sessions, permission("child"), directory)).toBe(true)
|
expect(autoRespondsPermission(autoAccept, sessions, permission("child"), directory)).toBe(true)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("falls back to directory-level auto-accept", () => {
|
||||||
|
const directory = "/tmp/project"
|
||||||
|
const sessions = [session({ id: "root" })]
|
||||||
|
const autoAccept = {
|
||||||
|
[`${base64Encode(directory)}/*`]: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(autoRespondsPermission(autoAccept, sessions, permission("root"), directory)).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("session-level override takes precedence over directory-level", () => {
|
||||||
|
const directory = "/tmp/project"
|
||||||
|
const sessions = [session({ id: "root" })]
|
||||||
|
const autoAccept = {
|
||||||
|
[`${base64Encode(directory)}/*`]: true,
|
||||||
|
[`${base64Encode(directory)}/root`]: false,
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(autoRespondsPermission(autoAccept, sessions, permission("root"), directory)).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("isDirectoryAutoAccepting", () => {
|
||||||
|
test("returns true when directory key is set", () => {
|
||||||
|
const directory = "/tmp/project"
|
||||||
|
const autoAccept = { [`${base64Encode(directory)}/*`]: true }
|
||||||
|
expect(isDirectoryAutoAccepting(autoAccept, directory)).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("returns false when directory key is not set", () => {
|
||||||
|
expect(isDirectoryAutoAccepting({}, "/tmp/project")).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("returns false when directory key is explicitly false", () => {
|
||||||
|
const directory = "/tmp/project"
|
||||||
|
const autoAccept = { [`${base64Encode(directory)}/*`]: false }
|
||||||
|
expect(isDirectoryAutoAccepting(autoAccept, directory)).toBe(false)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -5,9 +5,19 @@ export function acceptKey(sessionID: string, directory?: string) {
|
|||||||
return `${base64Encode(directory)}/${sessionID}`
|
return `${base64Encode(directory)}/${sessionID}`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function directoryAcceptKey(directory: string) {
|
||||||
|
return `${base64Encode(directory)}/*`
|
||||||
|
}
|
||||||
|
|
||||||
function accepted(autoAccept: Record<string, boolean>, sessionID: string, directory?: string) {
|
function accepted(autoAccept: Record<string, boolean>, sessionID: string, directory?: string) {
|
||||||
const key = acceptKey(sessionID, directory)
|
const key = acceptKey(sessionID, directory)
|
||||||
return autoAccept[key] ?? autoAccept[sessionID]
|
const directoryKey = directory ? directoryAcceptKey(directory) : undefined
|
||||||
|
return autoAccept[key] ?? autoAccept[sessionID] ?? (directoryKey ? autoAccept[directoryKey] : undefined)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isDirectoryAutoAccepting(autoAccept: Record<string, boolean>, directory: string) {
|
||||||
|
const key = directoryAcceptKey(directory)
|
||||||
|
return autoAccept[key] ?? false
|
||||||
}
|
}
|
||||||
|
|
||||||
function sessionLineage(session: { id: string; parentID?: string }[], sessionID: string) {
|
function sessionLineage(session: { id: string; parentID?: string }[], sessionID: string) {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { createMemo, onCleanup } from "solid-js"
|
import { createEffect, createMemo, onCleanup } from "solid-js"
|
||||||
import { createStore, produce } from "solid-js/store"
|
import { createStore, produce } from "solid-js/store"
|
||||||
import { createSimpleContext } from "@opencode-ai/ui/context"
|
import { createSimpleContext } from "@opencode-ai/ui/context"
|
||||||
import type { PermissionRequest } from "@opencode-ai/sdk/v2/client"
|
import type { PermissionRequest } from "@opencode-ai/sdk/v2/client"
|
||||||
@@ -7,7 +7,12 @@ import { useGlobalSDK } from "@/context/global-sdk"
|
|||||||
import { useGlobalSync } from "./global-sync"
|
import { useGlobalSync } from "./global-sync"
|
||||||
import { useParams } from "@solidjs/router"
|
import { useParams } from "@solidjs/router"
|
||||||
import { decode64 } from "@/utils/base64"
|
import { decode64 } from "@/utils/base64"
|
||||||
import { acceptKey, autoRespondsPermission } from "./permission-auto-respond"
|
import {
|
||||||
|
acceptKey,
|
||||||
|
directoryAcceptKey,
|
||||||
|
isDirectoryAutoAccepting,
|
||||||
|
autoRespondsPermission,
|
||||||
|
} from "./permission-auto-respond"
|
||||||
|
|
||||||
type PermissionRespondFn = (input: {
|
type PermissionRespondFn = (input: {
|
||||||
sessionID: string
|
sessionID: string
|
||||||
@@ -76,6 +81,25 @@ export const { use: usePermission, provider: PermissionProvider } = createSimple
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// When config has permission: "allow", auto-enable directory-level auto-accept
|
||||||
|
createEffect(() => {
|
||||||
|
if (!ready()) return
|
||||||
|
const directory = decode64(params.dir)
|
||||||
|
if (!directory) return
|
||||||
|
const [childStore] = globalSync.child(directory)
|
||||||
|
const perm = childStore.config.permission
|
||||||
|
if (typeof perm === "string" && perm === "allow") {
|
||||||
|
const key = directoryAcceptKey(directory)
|
||||||
|
if (store.autoAccept[key] === undefined) {
|
||||||
|
setStore(
|
||||||
|
produce((draft) => {
|
||||||
|
draft.autoAccept[key] = true
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
const MAX_RESPONDED = 1000
|
const MAX_RESPONDED = 1000
|
||||||
const RESPONDED_TTL_MS = 60 * 60 * 1000
|
const RESPONDED_TTL_MS = 60 * 60 * 1000
|
||||||
const responded = new Map<string, number>()
|
const responded = new Map<string, number>()
|
||||||
@@ -119,6 +143,10 @@ export const { use: usePermission, provider: PermissionProvider } = createSimple
|
|||||||
return autoRespondsPermission(store.autoAccept, session, { sessionID }, directory)
|
return autoRespondsPermission(store.autoAccept, session, { sessionID }, directory)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isAutoAcceptingDirectory(directory: string) {
|
||||||
|
return isDirectoryAutoAccepting(store.autoAccept, directory)
|
||||||
|
}
|
||||||
|
|
||||||
function shouldAutoRespond(permission: PermissionRequest, directory?: string) {
|
function shouldAutoRespond(permission: PermissionRequest, directory?: string) {
|
||||||
const session = directory ? globalSync.child(directory, { bootstrap: false })[0].session : []
|
const session = directory ? globalSync.child(directory, { bootstrap: false })[0].session : []
|
||||||
return autoRespondsPermission(store.autoAccept, session, permission, directory)
|
return autoRespondsPermission(store.autoAccept, session, permission, directory)
|
||||||
@@ -142,6 +170,36 @@ export const { use: usePermission, provider: PermissionProvider } = createSimple
|
|||||||
})
|
})
|
||||||
onCleanup(unsubscribe)
|
onCleanup(unsubscribe)
|
||||||
|
|
||||||
|
function enableDirectory(directory: string) {
|
||||||
|
const key = directoryAcceptKey(directory)
|
||||||
|
setStore(
|
||||||
|
produce((draft) => {
|
||||||
|
draft.autoAccept[key] = true
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
globalSDK.client.permission
|
||||||
|
.list({ directory })
|
||||||
|
.then((x) => {
|
||||||
|
if (!isAutoAcceptingDirectory(directory)) return
|
||||||
|
for (const perm of x.data ?? []) {
|
||||||
|
if (!perm?.id) continue
|
||||||
|
if (!shouldAutoRespond(perm, directory)) continue
|
||||||
|
respondOnce(perm, directory)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => undefined)
|
||||||
|
}
|
||||||
|
|
||||||
|
function disableDirectory(directory: string) {
|
||||||
|
const key = directoryAcceptKey(directory)
|
||||||
|
setStore(
|
||||||
|
produce((draft) => {
|
||||||
|
draft.autoAccept[key] = false
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
function enable(sessionID: string, directory: string) {
|
function enable(sessionID: string, directory: string) {
|
||||||
const key = acceptKey(sessionID, directory)
|
const key = acceptKey(sessionID, directory)
|
||||||
const version = bumpEnableVersion(sessionID, directory)
|
const version = bumpEnableVersion(sessionID, directory)
|
||||||
@@ -185,6 +243,7 @@ export const { use: usePermission, provider: PermissionProvider } = createSimple
|
|||||||
return shouldAutoRespond(permission, directory)
|
return shouldAutoRespond(permission, directory)
|
||||||
},
|
},
|
||||||
isAutoAccepting,
|
isAutoAccepting,
|
||||||
|
isAutoAcceptingDirectory,
|
||||||
toggleAutoAccept(sessionID: string, directory: string) {
|
toggleAutoAccept(sessionID: string, directory: string) {
|
||||||
if (isAutoAccepting(sessionID, directory)) {
|
if (isAutoAccepting(sessionID, directory)) {
|
||||||
disable(sessionID, directory)
|
disable(sessionID, directory)
|
||||||
@@ -193,6 +252,13 @@ export const { use: usePermission, provider: PermissionProvider } = createSimple
|
|||||||
|
|
||||||
enable(sessionID, directory)
|
enable(sessionID, directory)
|
||||||
},
|
},
|
||||||
|
toggleAutoAcceptDirectory(directory: string) {
|
||||||
|
if (isAutoAcceptingDirectory(directory)) {
|
||||||
|
disableDirectory(directory)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
enableDirectory(directory)
|
||||||
|
},
|
||||||
enableAutoAccept(sessionID: string, directory: string) {
|
enableAutoAccept(sessionID: string, directory: string) {
|
||||||
if (isAutoAccepting(sessionID, directory)) return
|
if (isAutoAccepting(sessionID, directory)) return
|
||||||
enable(sessionID, directory)
|
enable(sessionID, directory)
|
||||||
@@ -201,6 +267,11 @@ export const { use: usePermission, provider: PermissionProvider } = createSimple
|
|||||||
disable(sessionID, directory)
|
disable(sessionID, directory)
|
||||||
},
|
},
|
||||||
permissionsEnabled,
|
permissionsEnabled,
|
||||||
|
isPermissionAllowAll(directory: string) {
|
||||||
|
const [childStore] = globalSync.child(directory)
|
||||||
|
const perm = childStore.config.permission
|
||||||
|
return typeof perm === "string" && perm === "allow"
|
||||||
|
},
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -511,11 +511,13 @@ export const dict = {
|
|||||||
"session.review.change.other": "Changes",
|
"session.review.change.other": "Changes",
|
||||||
"session.review.loadingChanges": "Loading changes...",
|
"session.review.loadingChanges": "Loading changes...",
|
||||||
"session.review.empty": "No changes in this session yet",
|
"session.review.empty": "No changes in this session yet",
|
||||||
"session.review.noVcs": "No git VCS detected, so session changes will not be detected",
|
"session.review.noVcs": "No Git Version Control System detected, changes not displayed",
|
||||||
|
"session.review.noSnapshot": "Snapshot tracking is disabled in config, so session changes are unavailable",
|
||||||
"session.review.noChanges": "No changes",
|
"session.review.noChanges": "No changes",
|
||||||
|
|
||||||
"session.files.selectToOpen": "Select a file to open",
|
"session.files.selectToOpen": "Select a file to open",
|
||||||
"session.files.all": "All files",
|
"session.files.all": "All files",
|
||||||
|
"session.files.empty": "No files",
|
||||||
"session.files.binaryContent": "Binary file (content cannot be displayed)",
|
"session.files.binaryContent": "Binary file (content cannot be displayed)",
|
||||||
|
|
||||||
"session.messages.renderEarlier": "Render earlier messages",
|
"session.messages.renderEarlier": "Render earlier messages",
|
||||||
|
|||||||
@@ -1 +1,29 @@
|
|||||||
@import "@opencode-ai/ui/styles/tailwind";
|
@import "@opencode-ai/ui/styles/tailwind";
|
||||||
|
|
||||||
|
@layer components {
|
||||||
|
[data-component="getting-started"] {
|
||||||
|
container-type: inline-size;
|
||||||
|
container-name: getting-started;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-component="getting-started-actions"] {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.75rem; /* gap-3 */
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-component="getting-started-actions"] > [data-component="button"] {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
@container getting-started (min-width: 17rem) {
|
||||||
|
[data-component="getting-started-actions"] {
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-component="getting-started-actions"] > [data-component="button"] {
|
||||||
|
width: auto;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,26 +1,27 @@
|
|||||||
import { createEffect, createMemo, Show, type ParentProps } from "solid-js"
|
import { batch, createEffect, createMemo, Show, type ParentProps } from "solid-js"
|
||||||
import { createStore } from "solid-js/store"
|
import { createStore } from "solid-js/store"
|
||||||
import { useNavigate, useParams } from "@solidjs/router"
|
import { useLocation, useNavigate, useParams } from "@solidjs/router"
|
||||||
import { SDKProvider } from "@/context/sdk"
|
import { SDKProvider } from "@/context/sdk"
|
||||||
import { SyncProvider, useSync } from "@/context/sync"
|
import { SyncProvider, useSync } from "@/context/sync"
|
||||||
import { LocalProvider } from "@/context/local"
|
import { LocalProvider } from "@/context/local"
|
||||||
|
import { useGlobalSDK } from "@/context/global-sdk"
|
||||||
|
|
||||||
import { DataProvider } from "@opencode-ai/ui/context"
|
import { DataProvider } from "@opencode-ai/ui/context"
|
||||||
|
import { base64Encode } from "@opencode-ai/util/encode"
|
||||||
import { decode64 } from "@/utils/base64"
|
import { decode64 } from "@/utils/base64"
|
||||||
import { showToast } from "@opencode-ai/ui/toast"
|
import { showToast } from "@opencode-ai/ui/toast"
|
||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
|
|
||||||
function DirectoryDataProvider(props: ParentProps<{ directory: string }>) {
|
function DirectoryDataProvider(props: ParentProps<{ directory: string }>) {
|
||||||
const params = useParams()
|
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const sync = useSync()
|
const sync = useSync()
|
||||||
|
const slug = createMemo(() => base64Encode(props.directory))
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DataProvider
|
<DataProvider
|
||||||
data={sync.data}
|
data={sync.data}
|
||||||
directory={props.directory}
|
directory={props.directory}
|
||||||
onNavigateToSession={(sessionID: string) => navigate(`/${params.dir}/session/${sessionID}`)}
|
onNavigateToSession={(sessionID: string) => navigate(`/${slug()}/session/${sessionID}`)}
|
||||||
onSessionHref={(sessionID: string) => `/${params.dir}/session/${sessionID}`}
|
onSessionHref={(sessionID: string) => `/${slug()}/session/${sessionID}`}
|
||||||
>
|
>
|
||||||
<LocalProvider>{props.children}</LocalProvider>
|
<LocalProvider>{props.children}</LocalProvider>
|
||||||
</DataProvider>
|
</DataProvider>
|
||||||
@@ -30,31 +31,63 @@ function DirectoryDataProvider(props: ParentProps<{ directory: string }>) {
|
|||||||
export default function Layout(props: ParentProps) {
|
export default function Layout(props: ParentProps) {
|
||||||
const params = useParams()
|
const params = useParams()
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
|
const location = useLocation()
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
const [store, setStore] = createStore({ invalid: "" })
|
const globalSDK = useGlobalSDK()
|
||||||
const directory = createMemo(() => {
|
const directory = createMemo(() => decode64(params.dir) ?? "")
|
||||||
return decode64(params.dir) ?? ""
|
const [state, setState] = createStore({ invalid: "", resolved: "" })
|
||||||
})
|
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
if (!params.dir) return
|
if (!params.dir) return
|
||||||
if (directory()) return
|
const raw = directory()
|
||||||
if (store.invalid === params.dir) return
|
if (!raw) {
|
||||||
setStore("invalid", params.dir)
|
if (state.invalid === params.dir) return
|
||||||
showToast({
|
setState("invalid", params.dir)
|
||||||
variant: "error",
|
showToast({
|
||||||
title: language.t("common.requestFailed"),
|
variant: "error",
|
||||||
description: language.t("directory.error.invalidUrl"),
|
title: language.t("common.requestFailed"),
|
||||||
})
|
description: language.t("directory.error.invalidUrl"),
|
||||||
navigate("/", { replace: true })
|
})
|
||||||
|
navigate("/", { replace: true })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const current = params.dir
|
||||||
|
globalSDK
|
||||||
|
.createClient({
|
||||||
|
directory: raw,
|
||||||
|
throwOnError: true,
|
||||||
|
})
|
||||||
|
.path.get()
|
||||||
|
.then((x) => {
|
||||||
|
if (params.dir !== current) return
|
||||||
|
const next = x.data?.directory ?? raw
|
||||||
|
batch(() => {
|
||||||
|
setState("invalid", "")
|
||||||
|
setState("resolved", next)
|
||||||
|
})
|
||||||
|
if (next === raw) return
|
||||||
|
const path = location.pathname.slice(current.length + 1)
|
||||||
|
navigate(`/${base64Encode(next)}${path}${location.search}${location.hash}`, { replace: true })
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
if (params.dir !== current) return
|
||||||
|
batch(() => {
|
||||||
|
setState("invalid", "")
|
||||||
|
setState("resolved", raw)
|
||||||
|
})
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Show when={directory()}>
|
<Show when={state.resolved}>
|
||||||
<SDKProvider directory={directory}>
|
{(resolved) => (
|
||||||
<SyncProvider>
|
<SDKProvider directory={resolved}>
|
||||||
<DirectoryDataProvider directory={directory()}>{props.children}</DirectoryDataProvider>
|
<SyncProvider>
|
||||||
</SyncProvider>
|
<DirectoryDataProvider directory={resolved()}>{props.children}</DirectoryDataProvider>
|
||||||
</SDKProvider>
|
</SyncProvider>
|
||||||
|
</SDKProvider>
|
||||||
|
)}
|
||||||
</Show>
|
</Show>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,9 +10,8 @@ import {
|
|||||||
ParentProps,
|
ParentProps,
|
||||||
Show,
|
Show,
|
||||||
untrack,
|
untrack,
|
||||||
type JSX,
|
|
||||||
} from "solid-js"
|
} from "solid-js"
|
||||||
import { A, useNavigate, useParams } from "@solidjs/router"
|
import { useNavigate, useParams } from "@solidjs/router"
|
||||||
import { useLayout, LocalProject } from "@/context/layout"
|
import { useLayout, LocalProject } from "@/context/layout"
|
||||||
import { useGlobalSync } from "@/context/global-sync"
|
import { useGlobalSync } from "@/context/global-sync"
|
||||||
import { Persist, persisted } from "@/utils/persist"
|
import { Persist, persisted } from "@/utils/persist"
|
||||||
@@ -20,9 +19,8 @@ import { base64Encode } from "@opencode-ai/util/encode"
|
|||||||
import { decode64 } from "@/utils/base64"
|
import { decode64 } from "@/utils/base64"
|
||||||
import { ResizeHandle } from "@opencode-ai/ui/resize-handle"
|
import { ResizeHandle } from "@opencode-ai/ui/resize-handle"
|
||||||
import { Button } from "@opencode-ai/ui/button"
|
import { Button } from "@opencode-ai/ui/button"
|
||||||
import { Icon } from "@opencode-ai/ui/icon"
|
|
||||||
import { IconButton } from "@opencode-ai/ui/icon-button"
|
import { IconButton } from "@opencode-ai/ui/icon-button"
|
||||||
import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip"
|
import { Tooltip } from "@opencode-ai/ui/tooltip"
|
||||||
import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu"
|
import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu"
|
||||||
import { Dialog } from "@opencode-ai/ui/dialog"
|
import { Dialog } from "@opencode-ai/ui/dialog"
|
||||||
import { getFilename } from "@opencode-ai/util/path"
|
import { getFilename } from "@opencode-ai/util/path"
|
||||||
@@ -59,7 +57,6 @@ import { Titlebar } from "@/components/titlebar"
|
|||||||
import { useServer } from "@/context/server"
|
import { useServer } from "@/context/server"
|
||||||
import { useLanguage, type Locale } from "@/context/language"
|
import { useLanguage, type Locale } from "@/context/language"
|
||||||
import {
|
import {
|
||||||
childMapByParent,
|
|
||||||
displayName,
|
displayName,
|
||||||
effectiveWorkspaceOrder,
|
effectiveWorkspaceOrder,
|
||||||
errorMessage,
|
errorMessage,
|
||||||
@@ -96,6 +93,7 @@ export default function Layout(props: ParentProps) {
|
|||||||
workspaceName: {} as Record<string, string>,
|
workspaceName: {} as Record<string, string>,
|
||||||
workspaceBranchName: {} as Record<string, Record<string, string>>,
|
workspaceBranchName: {} as Record<string, Record<string, string>>,
|
||||||
workspaceExpanded: {} as Record<string, boolean>,
|
workspaceExpanded: {} as Record<string, boolean>,
|
||||||
|
gettingStartedDismissed: false,
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -157,6 +155,8 @@ export default function Layout(props: ParentProps) {
|
|||||||
const isBusy = (directory: string) => !!state.busyWorkspaces[workspaceKey(directory)]
|
const isBusy = (directory: string) => !!state.busyWorkspaces[workspaceKey(directory)]
|
||||||
const navLeave = { current: undefined as number | undefined }
|
const navLeave = { current: undefined as number | undefined }
|
||||||
const [sortNow, setSortNow] = createSignal(Date.now())
|
const [sortNow, setSortNow] = createSignal(Date.now())
|
||||||
|
const [sizing, setSizing] = createSignal(false)
|
||||||
|
let sizet: number | undefined
|
||||||
let sortNowInterval: ReturnType<typeof setInterval> | undefined
|
let sortNowInterval: ReturnType<typeof setInterval> | undefined
|
||||||
const sortNowTimeout = setTimeout(
|
const sortNowTimeout = setTimeout(
|
||||||
() => {
|
() => {
|
||||||
@@ -169,7 +169,7 @@ export default function Layout(props: ParentProps) {
|
|||||||
const aim = createAim({
|
const aim = createAim({
|
||||||
enabled: () => !layout.sidebar.opened(),
|
enabled: () => !layout.sidebar.opened(),
|
||||||
active: () => state.hoverProject,
|
active: () => state.hoverProject,
|
||||||
el: () => state.nav,
|
el: () => state.nav?.querySelector<HTMLElement>("[data-component='sidebar-rail']") ?? state.nav,
|
||||||
onActivate: (directory) => {
|
onActivate: (directory) => {
|
||||||
globalSync.child(directory)
|
globalSync.child(directory)
|
||||||
setState("hoverProject", directory)
|
setState("hoverProject", directory)
|
||||||
@@ -181,9 +181,23 @@ export default function Layout(props: ParentProps) {
|
|||||||
if (navLeave.current !== undefined) clearTimeout(navLeave.current)
|
if (navLeave.current !== undefined) clearTimeout(navLeave.current)
|
||||||
clearTimeout(sortNowTimeout)
|
clearTimeout(sortNowTimeout)
|
||||||
if (sortNowInterval) clearInterval(sortNowInterval)
|
if (sortNowInterval) clearInterval(sortNowInterval)
|
||||||
|
if (sizet !== undefined) clearTimeout(sizet)
|
||||||
|
if (peekt !== undefined) clearTimeout(peekt)
|
||||||
aim.reset()
|
aim.reset()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
const stop = () => setSizing(false)
|
||||||
|
window.addEventListener("pointerup", stop)
|
||||||
|
window.addEventListener("pointercancel", stop)
|
||||||
|
window.addEventListener("blur", stop)
|
||||||
|
onCleanup(() => {
|
||||||
|
window.removeEventListener("pointerup", stop)
|
||||||
|
window.removeEventListener("pointercancel", stop)
|
||||||
|
window.removeEventListener("blur", stop)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
const sidebarHovering = createMemo(() => !layout.sidebar.opened() && state.hoverProject !== undefined)
|
const sidebarHovering = createMemo(() => !layout.sidebar.opened() && state.hoverProject !== undefined)
|
||||||
const sidebarExpanded = createMemo(() => layout.sidebar.opened() || sidebarHovering())
|
const sidebarExpanded = createMemo(() => layout.sidebar.opened() || sidebarHovering())
|
||||||
const setHoverProject = (value: string | undefined) => {
|
const setHoverProject = (value: string | undefined) => {
|
||||||
@@ -194,12 +208,54 @@ export default function Layout(props: ParentProps) {
|
|||||||
const clearHoverProjectSoon = () => queueMicrotask(() => setHoverProject(undefined))
|
const clearHoverProjectSoon = () => queueMicrotask(() => setHoverProject(undefined))
|
||||||
const setHoverSession = (id: string | undefined) => setState("hoverSession", id)
|
const setHoverSession = (id: string | undefined) => setState("hoverSession", id)
|
||||||
|
|
||||||
|
const disarm = () => {
|
||||||
|
if (navLeave.current === undefined) return
|
||||||
|
clearTimeout(navLeave.current)
|
||||||
|
navLeave.current = undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
const arm = () => {
|
||||||
|
if (layout.sidebar.opened()) return
|
||||||
|
if (state.hoverProject === undefined) return
|
||||||
|
disarm()
|
||||||
|
navLeave.current = window.setTimeout(() => {
|
||||||
|
navLeave.current = undefined
|
||||||
|
setHoverProject(undefined)
|
||||||
|
setState("hoverSession", undefined)
|
||||||
|
}, 300)
|
||||||
|
}
|
||||||
|
|
||||||
|
const [peek, setPeek] = createSignal<LocalProject | undefined>(undefined)
|
||||||
|
const [peeked, setPeeked] = createSignal(false)
|
||||||
|
let peekt: number | undefined
|
||||||
|
|
||||||
const hoverProjectData = createMemo(() => {
|
const hoverProjectData = createMemo(() => {
|
||||||
const id = state.hoverProject
|
const id = state.hoverProject
|
||||||
if (!id) return
|
if (!id) return
|
||||||
return layout.projects.list().find((project) => project.worktree === id)
|
return layout.projects.list().find((project) => project.worktree === id)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
const p = hoverProjectData()
|
||||||
|
if (p) {
|
||||||
|
if (peekt !== undefined) {
|
||||||
|
clearTimeout(peekt)
|
||||||
|
peekt = undefined
|
||||||
|
}
|
||||||
|
setPeek(p)
|
||||||
|
setPeeked(true)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setPeeked(false)
|
||||||
|
if (peek() === undefined) return
|
||||||
|
if (peekt !== undefined) clearTimeout(peekt)
|
||||||
|
peekt = window.setTimeout(() => {
|
||||||
|
peekt = undefined
|
||||||
|
setPeek(undefined)
|
||||||
|
}, 180)
|
||||||
|
})
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
if (!layout.sidebar.opened()) return
|
if (!layout.sidebar.opened()) return
|
||||||
setHoverProject(undefined)
|
setHoverProject(undefined)
|
||||||
@@ -1125,6 +1181,12 @@ export default function Layout(props: ParentProps) {
|
|||||||
}
|
}
|
||||||
const openSession = async (target: { directory: string; id: string }) => {
|
const openSession = async (target: { directory: string; id: string }) => {
|
||||||
if (!canOpen(target.directory)) return false
|
if (!canOpen(target.directory)) return false
|
||||||
|
const [data] = globalSync.child(target.directory, { bootstrap: false })
|
||||||
|
if (data.session.some((item) => item.id === target.id)) {
|
||||||
|
setStore("lastProjectSession", root, { directory: target.directory, id: target.id, at: Date.now() })
|
||||||
|
navigateWithSidebarReset(`/${base64Encode(target.directory)}/session/${target.id}`)
|
||||||
|
return true
|
||||||
|
}
|
||||||
const resolved = await globalSDK.client.session
|
const resolved = await globalSDK.client.session
|
||||||
.get({ sessionID: target.id })
|
.get({ sessionID: target.id })
|
||||||
.then((x) => x.data)
|
.then((x) => x.data)
|
||||||
@@ -1815,7 +1877,8 @@ export default function Layout(props: ParentProps) {
|
|||||||
setHoverSession,
|
setHoverSession,
|
||||||
}
|
}
|
||||||
|
|
||||||
const SidebarPanel = (panelProps: { project: LocalProject | undefined; mobile?: boolean }) => {
|
const SidebarPanel = (panelProps: { project: LocalProject | undefined; mobile?: boolean; merged?: boolean }) => {
|
||||||
|
const merged = createMemo(() => panelProps.mobile || (panelProps.merged ?? layout.sidebar.opened()))
|
||||||
const projectName = createMemo(() => {
|
const projectName = createMemo(() => {
|
||||||
const project = panelProps.project
|
const project = panelProps.project
|
||||||
if (!project) return ""
|
if (!project) return ""
|
||||||
@@ -1841,12 +1904,19 @@ export default function Layout(props: ParentProps) {
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
classList={{
|
classList={{
|
||||||
"flex flex-col min-h-0 bg-background-stronger border border-b-0 border-border-weak-base rounded-tl-[12px]": true,
|
"flex flex-col min-h-0 min-w-0 rounded-tl-[12px] px-2": true,
|
||||||
|
"border border-b-0 border-border-weak-base": !merged(),
|
||||||
|
"border-l border-t border-border-weaker-base": merged(),
|
||||||
|
"bg-background-base": merged(),
|
||||||
|
"bg-background-stronger": !merged(),
|
||||||
"flex-1 min-w-0": panelProps.mobile,
|
"flex-1 min-w-0": panelProps.mobile,
|
||||||
|
"max-w-full overflow-hidden": panelProps.mobile,
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
width: panelProps.mobile ? undefined : `${Math.max(Math.max(layout.sidebar.width(), 244) - 64, 0)}px`,
|
||||||
}}
|
}}
|
||||||
style={{ width: panelProps.mobile ? undefined : `${Math.max(layout.sidebar.width() - 64, 0)}px` }}
|
|
||||||
>
|
>
|
||||||
<Show when={panelProps.project} keyed>
|
<Show when={panelProps.project}>
|
||||||
{(p) => (
|
{(p) => (
|
||||||
<>
|
<>
|
||||||
<div class="shrink-0 px-2 py-1">
|
<div class="shrink-0 px-2 py-1">
|
||||||
@@ -1855,7 +1925,7 @@ export default function Layout(props: ParentProps) {
|
|||||||
<InlineEditor
|
<InlineEditor
|
||||||
id={`project:${projectId()}`}
|
id={`project:${projectId()}`}
|
||||||
value={projectName}
|
value={projectName}
|
||||||
onSave={(next) => renameProject(p, next)}
|
onSave={(next) => renameProject(p(), next)}
|
||||||
class="text-14-medium text-text-strong truncate"
|
class="text-14-medium text-text-strong truncate"
|
||||||
displayClass="text-14-medium text-text-strong truncate"
|
displayClass="text-14-medium text-text-strong truncate"
|
||||||
stopPropagation
|
stopPropagation
|
||||||
@@ -1864,7 +1934,7 @@ export default function Layout(props: ParentProps) {
|
|||||||
<Tooltip
|
<Tooltip
|
||||||
placement="bottom"
|
placement="bottom"
|
||||||
gutter={2}
|
gutter={2}
|
||||||
value={p.worktree}
|
value={p().worktree}
|
||||||
class="shrink-0"
|
class="shrink-0"
|
||||||
contentStyle={{
|
contentStyle={{
|
||||||
"max-width": "640px",
|
"max-width": "640px",
|
||||||
@@ -1872,7 +1942,7 @@ export default function Layout(props: ParentProps) {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<span class="text-12-regular text-text-base truncate select-text">
|
<span class="text-12-regular text-text-base truncate select-text">
|
||||||
{p.worktree.replace(homedir(), "~")}
|
{p().worktree.replace(homedir(), "~")}
|
||||||
</span>
|
</span>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</div>
|
</div>
|
||||||
@@ -1883,33 +1953,33 @@ export default function Layout(props: ParentProps) {
|
|||||||
icon="dot-grid"
|
icon="dot-grid"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
data-action="project-menu"
|
data-action="project-menu"
|
||||||
data-project={base64Encode(p.worktree)}
|
data-project={base64Encode(p().worktree)}
|
||||||
class="shrink-0 size-6 rounded-md data-[expanded]:bg-surface-base-active"
|
class="shrink-0 size-6 rounded-md data-[expanded]:bg-surface-base-active"
|
||||||
classList={{
|
classList={{
|
||||||
"opacity-0 group-hover/project:opacity-100 data-[expanded]:opacity-100": !panelProps.mobile,
|
"opacity-0 group-hover/project:opacity-100 data-[expanded]:opacity-100": !panelProps.mobile,
|
||||||
}}
|
}}
|
||||||
aria-label={language.t("common.moreOptions")}
|
aria-label={language.t("common.moreOptions")}
|
||||||
/>
|
/>
|
||||||
<DropdownMenu.Portal mount={!panelProps.mobile ? state.nav : undefined}>
|
<DropdownMenu.Portal>
|
||||||
<DropdownMenu.Content class="mt-1">
|
<DropdownMenu.Content class="mt-1">
|
||||||
<DropdownMenu.Item onSelect={() => showEditProjectDialog(p)}>
|
<DropdownMenu.Item onSelect={() => showEditProjectDialog(p())}>
|
||||||
<DropdownMenu.ItemLabel>{language.t("common.edit")}</DropdownMenu.ItemLabel>
|
<DropdownMenu.ItemLabel>{language.t("common.edit")}</DropdownMenu.ItemLabel>
|
||||||
</DropdownMenu.Item>
|
</DropdownMenu.Item>
|
||||||
<DropdownMenu.Item
|
<DropdownMenu.Item
|
||||||
data-action="project-workspaces-toggle"
|
data-action="project-workspaces-toggle"
|
||||||
data-project={base64Encode(p.worktree)}
|
data-project={base64Encode(p().worktree)}
|
||||||
disabled={p.vcs !== "git" && !layout.sidebar.workspaces(p.worktree)()}
|
disabled={p().vcs !== "git" && !layout.sidebar.workspaces(p().worktree)()}
|
||||||
onSelect={() => toggleProjectWorkspaces(p)}
|
onSelect={() => toggleProjectWorkspaces(p())}
|
||||||
>
|
>
|
||||||
<DropdownMenu.ItemLabel>
|
<DropdownMenu.ItemLabel>
|
||||||
{layout.sidebar.workspaces(p.worktree)()
|
{layout.sidebar.workspaces(p().worktree)()
|
||||||
? language.t("sidebar.workspaces.disable")
|
? language.t("sidebar.workspaces.disable")
|
||||||
: language.t("sidebar.workspaces.enable")}
|
: language.t("sidebar.workspaces.enable")}
|
||||||
</DropdownMenu.ItemLabel>
|
</DropdownMenu.ItemLabel>
|
||||||
</DropdownMenu.Item>
|
</DropdownMenu.Item>
|
||||||
<DropdownMenu.Item
|
<DropdownMenu.Item
|
||||||
data-action="project-clear-notifications"
|
data-action="project-clear-notifications"
|
||||||
data-project={base64Encode(p.worktree)}
|
data-project={base64Encode(p().worktree)}
|
||||||
disabled={unseenCount() === 0}
|
disabled={unseenCount() === 0}
|
||||||
onSelect={clearNotifications}
|
onSelect={clearNotifications}
|
||||||
>
|
>
|
||||||
@@ -1920,8 +1990,8 @@ export default function Layout(props: ParentProps) {
|
|||||||
<DropdownMenu.Separator />
|
<DropdownMenu.Separator />
|
||||||
<DropdownMenu.Item
|
<DropdownMenu.Item
|
||||||
data-action="project-close-menu"
|
data-action="project-close-menu"
|
||||||
data-project={base64Encode(p.worktree)}
|
data-project={base64Encode(p().worktree)}
|
||||||
onSelect={() => closeProject(p.worktree)}
|
onSelect={() => closeProject(p().worktree)}
|
||||||
>
|
>
|
||||||
<DropdownMenu.ItemLabel>{language.t("common.close")}</DropdownMenu.ItemLabel>
|
<DropdownMenu.ItemLabel>{language.t("common.close")}</DropdownMenu.ItemLabel>
|
||||||
</DropdownMenu.Item>
|
</DropdownMenu.Item>
|
||||||
@@ -1937,25 +2007,19 @@ export default function Layout(props: ParentProps) {
|
|||||||
fallback={
|
fallback={
|
||||||
<>
|
<>
|
||||||
<div class="shrink-0 py-4 px-3">
|
<div class="shrink-0 py-4 px-3">
|
||||||
<TooltipKeybind
|
<Button
|
||||||
title={language.t("command.session.new")}
|
size="large"
|
||||||
keybind={command.keybind("session.new")}
|
icon="plus-small"
|
||||||
placement="top"
|
class="w-full"
|
||||||
|
onClick={() => navigateWithSidebarReset(`/${base64Encode(p().worktree)}/session`)}
|
||||||
>
|
>
|
||||||
<Button
|
{language.t("command.session.new")}
|
||||||
size="large"
|
</Button>
|
||||||
icon="plus-small"
|
|
||||||
class="w-full"
|
|
||||||
onClick={() => navigateWithSidebarReset(`/${base64Encode(p.worktree)}/session`)}
|
|
||||||
>
|
|
||||||
{language.t("command.session.new")}
|
|
||||||
</Button>
|
|
||||||
</TooltipKeybind>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="flex-1 min-h-0">
|
<div class="flex-1 min-h-0">
|
||||||
<LocalWorkspace
|
<LocalWorkspace
|
||||||
ctx={workspaceSidebarCtx}
|
ctx={workspaceSidebarCtx}
|
||||||
project={p}
|
project={p()}
|
||||||
sortNow={sortNow}
|
sortNow={sortNow}
|
||||||
mobile={panelProps.mobile}
|
mobile={panelProps.mobile}
|
||||||
/>
|
/>
|
||||||
@@ -1965,15 +2029,9 @@ export default function Layout(props: ParentProps) {
|
|||||||
>
|
>
|
||||||
<>
|
<>
|
||||||
<div class="shrink-0 py-4 px-3">
|
<div class="shrink-0 py-4 px-3">
|
||||||
<TooltipKeybind
|
<Button size="large" icon="plus-small" class="w-full" onClick={() => createWorkspace(p())}>
|
||||||
title={language.t("workspace.new")}
|
{language.t("workspace.new")}
|
||||||
keybind={command.keybind("workspace.new")}
|
</Button>
|
||||||
placement="top"
|
|
||||||
>
|
|
||||||
<Button size="large" icon="plus-small" class="w-full" onClick={() => createWorkspace(p)}>
|
|
||||||
{language.t("workspace.new")}
|
|
||||||
</Button>
|
|
||||||
</TooltipKeybind>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="relative flex-1 min-h-0">
|
<div class="relative flex-1 min-h-0">
|
||||||
<DragDropProvider
|
<DragDropProvider
|
||||||
@@ -1996,7 +2054,7 @@ export default function Layout(props: ParentProps) {
|
|||||||
<SortableWorkspace
|
<SortableWorkspace
|
||||||
ctx={workspaceSidebarCtx}
|
ctx={workspaceSidebarCtx}
|
||||||
directory={directory}
|
directory={directory}
|
||||||
project={p}
|
project={p()}
|
||||||
sortNow={sortNow}
|
sortNow={sortNow}
|
||||||
mobile={panelProps.mobile}
|
mobile={panelProps.mobile}
|
||||||
/>
|
/>
|
||||||
@@ -2021,25 +2079,31 @@ export default function Layout(props: ParentProps) {
|
|||||||
</Show>
|
</Show>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
class="shrink-0 px-2 py-3 border-t border-border-weak-base"
|
class="shrink-0 px-3 py-3"
|
||||||
classList={{
|
classList={{
|
||||||
hidden: !(providers.all().length > 0 && providers.paid().length === 0),
|
hidden: store.gettingStartedDismissed || !(providers.all().length > 0 && providers.paid().length === 0),
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div class="rounded-md bg-background-base shadow-xs-border-base">
|
<div class="rounded-xl bg-background-base shadow-xs-border-base" data-component="getting-started">
|
||||||
<div class="p-3 flex flex-col gap-2">
|
<div class="p-3 flex flex-col gap-6">
|
||||||
<div class="text-12-medium text-text-strong">{language.t("sidebar.gettingStarted.title")}</div>
|
<div class="flex flex-col gap-2">
|
||||||
<div class="text-text-base">{language.t("sidebar.gettingStarted.line1")}</div>
|
<div class="text-14-medium text-text-strong">{language.t("sidebar.gettingStarted.title")}</div>
|
||||||
<div class="text-text-base">{language.t("sidebar.gettingStarted.line2")}</div>
|
<div class="text-14-regular text-text-base" style={{ "line-height": "var(--line-height-normal)" }}>
|
||||||
|
{language.t("sidebar.gettingStarted.line1")}
|
||||||
|
</div>
|
||||||
|
<div class="text-14-regular text-text-base" style={{ "line-height": "var(--line-height-normal)" }}>
|
||||||
|
{language.t("sidebar.gettingStarted.line2")}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div data-component="getting-started-actions">
|
||||||
|
<Button size="large" icon="plus-small" onClick={connectProvider}>
|
||||||
|
{language.t("command.provider.connect")}
|
||||||
|
</Button>
|
||||||
|
<Button size="large" variant="ghost" onClick={() => setStore("gettingStartedDismissed", true)}>
|
||||||
|
Not yet
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Button
|
|
||||||
class="flex w-full text-left justify-start text-12-medium text-text-strong stroke-[1.5px] rounded-md rounded-t-none shadow-none border-t border-border-weak-base px-3"
|
|
||||||
size="large"
|
|
||||||
icon="plus"
|
|
||||||
onClick={connectProvider}
|
|
||||||
>
|
|
||||||
{language.t("command.provider.connect")}
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -2049,33 +2113,27 @@ export default function Layout(props: ParentProps) {
|
|||||||
return (
|
return (
|
||||||
<div class="relative bg-background-base flex-1 min-h-0 flex flex-col select-none [&_input]:select-text [&_textarea]:select-text [&_[contenteditable]]:select-text">
|
<div class="relative bg-background-base flex-1 min-h-0 flex flex-col select-none [&_input]:select-text [&_textarea]:select-text [&_[contenteditable]]:select-text">
|
||||||
<Titlebar />
|
<Titlebar />
|
||||||
<div class="flex-1 min-h-0 flex">
|
<div class="flex-1 min-h-0 relative overflow-x-hidden">
|
||||||
<nav
|
<nav
|
||||||
aria-label={language.t("sidebar.nav.projectsAndSessions")}
|
aria-label={language.t("sidebar.nav.projectsAndSessions")}
|
||||||
data-component="sidebar-nav-desktop"
|
data-component="sidebar-nav-desktop"
|
||||||
classList={{
|
classList={{
|
||||||
"hidden xl:block": true,
|
"hidden xl:block": true,
|
||||||
"relative shrink-0": true,
|
"absolute inset-y-0 left-0": true,
|
||||||
|
"z-10": true,
|
||||||
}}
|
}}
|
||||||
style={{ width: layout.sidebar.opened() ? `${Math.max(layout.sidebar.width(), 244)}px` : "64px" }}
|
style={{ width: `${Math.max(layout.sidebar.width(), 244)}px` }}
|
||||||
ref={(el) => {
|
ref={(el) => {
|
||||||
setState("nav", el)
|
setState("nav", el)
|
||||||
}}
|
}}
|
||||||
onMouseEnter={() => {
|
onMouseEnter={() => {
|
||||||
if (navLeave.current === undefined) return
|
disarm()
|
||||||
clearTimeout(navLeave.current)
|
|
||||||
navLeave.current = undefined
|
|
||||||
}}
|
}}
|
||||||
onMouseLeave={() => {
|
onMouseLeave={() => {
|
||||||
aim.reset()
|
aim.reset()
|
||||||
if (!sidebarHovering()) return
|
if (!sidebarHovering()) return
|
||||||
|
|
||||||
if (navLeave.current !== undefined) clearTimeout(navLeave.current)
|
arm()
|
||||||
navLeave.current = window.setTimeout(() => {
|
|
||||||
navLeave.current = undefined
|
|
||||||
setHoverProject(undefined)
|
|
||||||
setState("hoverSession", undefined)
|
|
||||||
}, 300)
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div class="@container w-full h-full contain-strict">
|
<div class="@container w-full h-full contain-strict">
|
||||||
@@ -2102,30 +2160,36 @@ export default function Layout(props: ParentProps) {
|
|||||||
onOpenHelp={() => platform.openLink("https://opencode.ai/desktop-feedback")}
|
onOpenHelp={() => platform.openLink("https://opencode.ai/desktop-feedback")}
|
||||||
renderPanel={() => (
|
renderPanel={() => (
|
||||||
<Show when={currentProject()} keyed>
|
<Show when={currentProject()} keyed>
|
||||||
{(project) => <SidebarPanel project={project} />}
|
{(project) => <SidebarPanel project={project} merged />}
|
||||||
</Show>
|
</Show>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<Show when={!layout.sidebar.opened() ? hoverProjectData()?.worktree : undefined} keyed>
|
|
||||||
{(worktree) => (
|
|
||||||
<div class="absolute inset-y-0 left-16 z-50 flex" onMouseEnter={aim.reset}>
|
|
||||||
<SidebarPanel project={hoverProjectData()} />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</Show>
|
|
||||||
<Show when={layout.sidebar.opened()}>
|
<Show when={layout.sidebar.opened()}>
|
||||||
<ResizeHandle
|
<div onPointerDown={() => setSizing(true)}>
|
||||||
direction="horizontal"
|
<ResizeHandle
|
||||||
size={layout.sidebar.width()}
|
direction="horizontal"
|
||||||
min={244}
|
size={layout.sidebar.width()}
|
||||||
max={typeof window === "undefined" ? 1000 : window.innerWidth * 0.3 + 64}
|
min={244}
|
||||||
collapseThreshold={244}
|
max={typeof window === "undefined" ? 1000 : window.innerWidth * 0.3 + 64}
|
||||||
onResize={layout.sidebar.resize}
|
collapseThreshold={244}
|
||||||
onCollapse={layout.sidebar.close}
|
onResize={(w) => {
|
||||||
/>
|
setSizing(true)
|
||||||
|
if (sizet !== undefined) clearTimeout(sizet)
|
||||||
|
sizet = window.setTimeout(() => setSizing(false), 120)
|
||||||
|
layout.sidebar.resize(w)
|
||||||
|
}}
|
||||||
|
onCollapse={layout.sidebar.close}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="hidden xl:block pointer-events-none absolute top-0 right-0 z-0 border-t border-border-weaker-base"
|
||||||
|
style={{ left: "calc(4rem + 12px)" }}
|
||||||
|
/>
|
||||||
|
|
||||||
<div class="xl:hidden">
|
<div class="xl:hidden">
|
||||||
<div
|
<div
|
||||||
classList={{
|
classList={{
|
||||||
@@ -2141,7 +2205,7 @@ export default function Layout(props: ParentProps) {
|
|||||||
aria-label={language.t("sidebar.nav.projectsAndSessions")}
|
aria-label={language.t("sidebar.nav.projectsAndSessions")}
|
||||||
data-component="sidebar-nav-mobile"
|
data-component="sidebar-nav-mobile"
|
||||||
classList={{
|
classList={{
|
||||||
"@container fixed top-10 bottom-0 left-0 z-50 w-72 bg-background-base transition-transform duration-200 ease-out": true,
|
"@container fixed top-10 bottom-0 left-0 z-50 w-full max-w-[400px] overflow-hidden border-r border-border-weaker-base bg-background-base transition-transform duration-200 ease-out": true,
|
||||||
"translate-x-0": layout.mobileSidebar.opened(),
|
"translate-x-0": layout.mobileSidebar.opened(),
|
||||||
"-translate-x-full": !layout.mobileSidebar.opened(),
|
"-translate-x-full": !layout.mobileSidebar.opened(),
|
||||||
}}
|
}}
|
||||||
@@ -2174,16 +2238,66 @@ export default function Layout(props: ParentProps) {
|
|||||||
</nav>
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<main
|
<div
|
||||||
classList={{
|
classList={{
|
||||||
"size-full overflow-x-hidden flex flex-col items-start contain-strict border-t border-border-weak-base": true,
|
"absolute inset-0": true,
|
||||||
"xl:border-l xl:rounded-tl-[12px]": !layout.sidebar.opened(),
|
"xl:inset-y-0 xl:right-0 xl:left-[var(--main-left)]": true,
|
||||||
|
"z-20": true,
|
||||||
|
"transition-[left] duration-200 ease-[cubic-bezier(0.22,1,0.36,1)] will-change-[left] motion-reduce:transition-none":
|
||||||
|
!sizing(),
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
"--main-left": layout.sidebar.opened() ? `${Math.max(layout.sidebar.width(), 244)}px` : "4rem",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Show when={!autoselecting()} fallback={<div class="size-full" />}>
|
<main
|
||||||
{props.children}
|
classList={{
|
||||||
|
"size-full overflow-x-hidden flex flex-col items-start contain-strict border-t border-border-weak-base xl:border-l xl:rounded-tl-[12px]": true,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Show when={!autoselecting()} fallback={<div class="size-full" />}>
|
||||||
|
{props.children}
|
||||||
|
</Show>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
classList={{
|
||||||
|
"hidden xl:flex absolute inset-y-0 left-16 z-30": true,
|
||||||
|
"opacity-100 translate-x-0 pointer-events-auto": peeked() && !layout.sidebar.opened(),
|
||||||
|
"opacity-0 -translate-x-2 pointer-events-none": !peeked() || layout.sidebar.opened(),
|
||||||
|
"transition-[opacity,transform] motion-reduce:transition-none": true,
|
||||||
|
"duration-180 ease-out": peeked() && !layout.sidebar.opened(),
|
||||||
|
"duration-120 ease-in": !peeked() || layout.sidebar.opened(),
|
||||||
|
}}
|
||||||
|
onMouseMove={disarm}
|
||||||
|
onMouseEnter={() => {
|
||||||
|
disarm()
|
||||||
|
aim.reset()
|
||||||
|
}}
|
||||||
|
onPointerDown={disarm}
|
||||||
|
onMouseLeave={() => {
|
||||||
|
arm()
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Show when={peek()} keyed>
|
||||||
|
{(project) => <SidebarPanel project={project} merged={false} />}
|
||||||
</Show>
|
</Show>
|
||||||
</main>
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
classList={{
|
||||||
|
"hidden xl:block pointer-events-none absolute inset-y-0 right-0 z-25 overflow-hidden": true,
|
||||||
|
"opacity-100 translate-x-0": peeked() && !layout.sidebar.opened(),
|
||||||
|
"opacity-0 -translate-x-2": !peeked() || layout.sidebar.opened(),
|
||||||
|
"transition-[opacity,transform] motion-reduce:transition-none": true,
|
||||||
|
"duration-180 ease-out": peeked() && !layout.sidebar.opened(),
|
||||||
|
"duration-120 ease-in": !peeked() || layout.sidebar.opened(),
|
||||||
|
}}
|
||||||
|
style={{ left: `calc(4rem + ${Math.max(Math.max(layout.sidebar.width(), 244) - 64, 0)}px)` }}
|
||||||
|
>
|
||||||
|
<div class="h-full w-px" style={{ "box-shadow": "var(--shadow-sidebar-overlay)" }} />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Toast.Region />
|
<Toast.Region />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -163,7 +163,6 @@ const SessionHoverPreview = (props: {
|
|||||||
gutter={16}
|
gutter={16}
|
||||||
shift={-2}
|
shift={-2}
|
||||||
trigger={props.trigger}
|
trigger={props.trigger}
|
||||||
mount={!props.mobile ? props.nav() : undefined}
|
|
||||||
open={props.hoverSession() === props.session.id}
|
open={props.hoverSession() === props.session.id}
|
||||||
onOpenChange={(open) => props.setHoverSession(open ? props.session.id : undefined)}
|
onOpenChange={(open) => props.setHoverSession(open ? props.session.id : undefined)}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -137,7 +137,7 @@ const ProjectTile = (props: {
|
|||||||
>
|
>
|
||||||
<ProjectIcon project={props.project} notify />
|
<ProjectIcon project={props.project} notify />
|
||||||
</ContextMenu.Trigger>
|
</ContextMenu.Trigger>
|
||||||
<ContextMenu.Portal mount={!props.mobile ? props.nav() : undefined}>
|
<ContextMenu.Portal>
|
||||||
<ContextMenu.Content>
|
<ContextMenu.Content>
|
||||||
<ContextMenu.Item onSelect={() => props.showEditProjectDialog(props.project)}>
|
<ContextMenu.Item onSelect={() => props.showEditProjectDialog(props.project)}>
|
||||||
<ContextMenu.ItemLabel>{props.language.t("common.edit")}</ContextMenu.ItemLabel>
|
<ContextMenu.ItemLabel>{props.language.t("common.edit")}</ContextMenu.ItemLabel>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { createMemo, For, Show, type Accessor, type JSX } from "solid-js"
|
import { createEffect, createMemo, For, Show, type Accessor, type JSX } from "solid-js"
|
||||||
import {
|
import {
|
||||||
DragDropProvider,
|
DragDropProvider,
|
||||||
DragDropSensors,
|
DragDropSensors,
|
||||||
@@ -35,10 +35,22 @@ export const SidebarContent = (props: {
|
|||||||
}): JSX.Element => {
|
}): JSX.Element => {
|
||||||
const expanded = createMemo(() => sidebarExpanded(props.mobile, props.opened()))
|
const expanded = createMemo(() => sidebarExpanded(props.mobile, props.opened()))
|
||||||
const placement = () => (props.mobile ? "bottom" : "right")
|
const placement = () => (props.mobile ? "bottom" : "right")
|
||||||
|
let panel: HTMLDivElement | undefined
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
const el = panel
|
||||||
|
if (!el) return
|
||||||
|
if (expanded()) {
|
||||||
|
el.removeAttribute("inert")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
el.setAttribute("inert", "")
|
||||||
|
})
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div class="flex h-full w-full overflow-hidden">
|
<div class="flex h-full w-full min-w-0 overflow-hidden">
|
||||||
<div
|
<div
|
||||||
|
data-component="sidebar-rail"
|
||||||
class="w-16 shrink-0 bg-background-base flex flex-col items-center overflow-hidden"
|
class="w-16 shrink-0 bg-background-base flex flex-col items-center overflow-hidden"
|
||||||
onMouseMove={props.aimMove}
|
onMouseMove={props.aimMove}
|
||||||
>
|
>
|
||||||
@@ -100,7 +112,15 @@ export const SidebarContent = (props: {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Show when={expanded()}>{props.renderPanel()}</Show>
|
<div
|
||||||
|
ref={(el) => {
|
||||||
|
panel = el
|
||||||
|
}}
|
||||||
|
classList={{ "flex h-full min-h-0 min-w-0 overflow-hidden": true, "pointer-events-none": !expanded() }}
|
||||||
|
aria-hidden={!expanded()}
|
||||||
|
>
|
||||||
|
{props.renderPanel()}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -182,7 +182,7 @@ const WorkspaceActions = (props: {
|
|||||||
aria-label={props.language.t("common.moreOptions")}
|
aria-label={props.language.t("common.moreOptions")}
|
||||||
/>
|
/>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
<DropdownMenu.Portal mount={!props.mobile ? props.nav() : undefined}>
|
<DropdownMenu.Portal>
|
||||||
<DropdownMenu.Content
|
<DropdownMenu.Content
|
||||||
onCloseAutoFocus={(event) => {
|
onCloseAutoFocus={(event) => {
|
||||||
if (!props.pendingRename()) return
|
if (!props.pendingRename()) return
|
||||||
@@ -249,7 +249,7 @@ const WorkspaceSessionList = (props: {
|
|||||||
loadMore: () => Promise<void>
|
loadMore: () => Promise<void>
|
||||||
language: ReturnType<typeof useLanguage>
|
language: ReturnType<typeof useLanguage>
|
||||||
}): JSX.Element => (
|
}): JSX.Element => (
|
||||||
<nav class="flex flex-col gap-1 px-2">
|
<nav class="flex flex-col gap-1 px-3">
|
||||||
<Show when={props.showNew()}>
|
<Show when={props.showNew()}>
|
||||||
<NewSessionItem
|
<NewSessionItem
|
||||||
slug={props.slug()}
|
slug={props.slug()}
|
||||||
@@ -490,7 +490,7 @@ export const LocalWorkspace = (props: {
|
|||||||
ref={(el) => props.ctx.setScrollContainerRef(el, props.mobile)}
|
ref={(el) => props.ctx.setScrollContainerRef(el, props.mobile)}
|
||||||
class="size-full flex flex-col py-2 overflow-y-auto no-scrollbar [overflow-anchor:none]"
|
class="size-full flex flex-col py-2 overflow-y-auto no-scrollbar [overflow-anchor:none]"
|
||||||
>
|
>
|
||||||
<nav class="flex flex-col gap-1 px-2">
|
<nav class="flex flex-col gap-1 px-3">
|
||||||
<Show when={loading()}>
|
<Show when={loading()}>
|
||||||
<SessionSkeleton />
|
<SessionSkeleton />
|
||||||
</Show>
|
</Show>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { UserMessage } from "@opencode-ai/sdk/v2"
|
import type { Project, UserMessage } from "@opencode-ai/sdk/v2"
|
||||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||||
import {
|
import {
|
||||||
onCleanup,
|
onCleanup,
|
||||||
@@ -20,11 +20,13 @@ import { createStore } from "solid-js/store"
|
|||||||
import { ResizeHandle } from "@opencode-ai/ui/resize-handle"
|
import { ResizeHandle } from "@opencode-ai/ui/resize-handle"
|
||||||
import { Select } from "@opencode-ai/ui/select"
|
import { Select } from "@opencode-ai/ui/select"
|
||||||
import { createAutoScroll } from "@opencode-ai/ui/hooks"
|
import { createAutoScroll } from "@opencode-ai/ui/hooks"
|
||||||
import { Mark } from "@opencode-ai/ui/logo"
|
import { Button } from "@opencode-ai/ui/button"
|
||||||
|
import { showToast } from "@opencode-ai/ui/toast"
|
||||||
import { base64Encode, checksum } from "@opencode-ai/util/encode"
|
import { base64Encode, checksum } from "@opencode-ai/util/encode"
|
||||||
import { useNavigate, useParams, useSearchParams } from "@solidjs/router"
|
import { useNavigate, useParams, useSearchParams } from "@solidjs/router"
|
||||||
import { NewSessionView, SessionHeader } from "@/components/session"
|
import { NewSessionView, SessionHeader } from "@/components/session"
|
||||||
import { useComments } from "@/context/comments"
|
import { useComments } from "@/context/comments"
|
||||||
|
import { useGlobalSync } from "@/context/global-sync"
|
||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
import { useLayout } from "@/context/layout"
|
import { useLayout } from "@/context/layout"
|
||||||
import { usePrompt } from "@/context/prompt"
|
import { usePrompt } from "@/context/prompt"
|
||||||
@@ -41,6 +43,7 @@ import { TerminalPanel } from "@/pages/session/terminal-panel"
|
|||||||
import { useSessionCommands } from "@/pages/session/use-session-commands"
|
import { useSessionCommands } from "@/pages/session/use-session-commands"
|
||||||
import { useSessionHashScroll } from "@/pages/session/use-session-hash-scroll"
|
import { useSessionHashScroll } from "@/pages/session/use-session-hash-scroll"
|
||||||
import { same } from "@/utils/same"
|
import { same } from "@/utils/same"
|
||||||
|
import { formatServerError } from "@/utils/server-errors"
|
||||||
|
|
||||||
const emptyUserMessages: UserMessage[] = []
|
const emptyUserMessages: UserMessage[] = []
|
||||||
|
|
||||||
@@ -252,6 +255,7 @@ function createSessionHistoryWindow(input: SessionHistoryWindowInput) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function Page() {
|
export default function Page() {
|
||||||
|
const globalSync = useGlobalSync()
|
||||||
const layout = useLayout()
|
const layout = useLayout()
|
||||||
const local = useLocal()
|
const local = useLocal()
|
||||||
const file = useFile()
|
const file = useFile()
|
||||||
@@ -278,6 +282,7 @@ export default function Page() {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const [ui, setUi] = createStore({
|
const [ui, setUi] = createStore({
|
||||||
|
git: false,
|
||||||
pendingMessage: undefined as string | undefined,
|
pendingMessage: undefined as string | undefined,
|
||||||
scrollGesture: 0,
|
scrollGesture: 0,
|
||||||
scroll: {
|
scroll: {
|
||||||
@@ -490,10 +495,51 @@ export default function Page() {
|
|||||||
})
|
})
|
||||||
const reviewEmptyKey = createMemo(() => {
|
const reviewEmptyKey = createMemo(() => {
|
||||||
const project = sync.project
|
const project = sync.project
|
||||||
if (!project || project.vcs) return "session.review.empty"
|
if (project && !project.vcs) return "session.review.noVcs"
|
||||||
return "session.review.noVcs"
|
if (sync.data.config.snapshot === false) return "session.review.noSnapshot"
|
||||||
|
return "session.review.empty"
|
||||||
})
|
})
|
||||||
|
|
||||||
|
function upsert(next: Project) {
|
||||||
|
const list = globalSync.data.project
|
||||||
|
sync.set("project", next.id)
|
||||||
|
const idx = list.findIndex((item) => item.id === next.id)
|
||||||
|
if (idx >= 0) {
|
||||||
|
globalSync.set(
|
||||||
|
"project",
|
||||||
|
list.map((item, i) => (i === idx ? { ...item, ...next } : item)),
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const at = list.findIndex((item) => item.id > next.id)
|
||||||
|
if (at >= 0) {
|
||||||
|
globalSync.set("project", [...list.slice(0, at), next, ...list.slice(at)])
|
||||||
|
return
|
||||||
|
}
|
||||||
|
globalSync.set("project", [...list, next])
|
||||||
|
}
|
||||||
|
|
||||||
|
function initGit() {
|
||||||
|
if (ui.git) return
|
||||||
|
setUi("git", true)
|
||||||
|
void sdk.client.project
|
||||||
|
.initGit()
|
||||||
|
.then((x) => {
|
||||||
|
if (!x.data) return
|
||||||
|
upsert(x.data)
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
showToast({
|
||||||
|
variant: "error",
|
||||||
|
title: language.t("common.requestFailed"),
|
||||||
|
description: formatServerError(err, language.t),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
setUi("git", false)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
let inputRef!: HTMLDivElement
|
let inputRef!: HTMLDivElement
|
||||||
let promptDock: HTMLDivElement | undefined
|
let promptDock: HTMLDivElement | undefined
|
||||||
let dockHeight = 0
|
let dockHeight = 0
|
||||||
@@ -720,29 +766,35 @@ export default function Page() {
|
|||||||
showAllFiles,
|
showAllFiles,
|
||||||
tabForPath: file.tab,
|
tabForPath: file.tab,
|
||||||
openTab: tabs().open,
|
openTab: tabs().open,
|
||||||
|
setActive: tabs().setActive,
|
||||||
loadFile: file.load,
|
loadFile: file.load,
|
||||||
})
|
})
|
||||||
|
|
||||||
const changesOptions = ["session", "turn"] as const
|
const changesOptions = ["session", "turn"] as const
|
||||||
const changesOptionsList = [...changesOptions]
|
const changesOptionsList = [...changesOptions]
|
||||||
|
|
||||||
const changesTitle = () => (
|
const changesTitle = () => {
|
||||||
<Select
|
if (!hasReview()) {
|
||||||
options={changesOptionsList}
|
return null
|
||||||
current={store.changes}
|
}
|
||||||
label={(option) =>
|
|
||||||
option === "session" ? language.t("ui.sessionReview.title") : language.t("ui.sessionReview.title.lastTurn")
|
return (
|
||||||
}
|
<Select
|
||||||
onSelect={(option) => option && setStore("changes", option)}
|
options={changesOptionsList}
|
||||||
variant="ghost"
|
current={store.changes}
|
||||||
size="small"
|
label={(option) =>
|
||||||
valueClass="text-14-medium"
|
option === "session" ? language.t("ui.sessionReview.title") : language.t("ui.sessionReview.title.lastTurn")
|
||||||
/>
|
}
|
||||||
)
|
onSelect={(option) => option && setStore("changes", option)}
|
||||||
|
variant="ghost"
|
||||||
|
size="small"
|
||||||
|
valueClass="text-14-medium"
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
const emptyTurn = () => (
|
const emptyTurn = () => (
|
||||||
<div class="h-full pb-30 flex flex-col items-center justify-center text-center gap-6">
|
<div class="h-full pb-30 flex flex-col items-center justify-center text-center gap-6">
|
||||||
<Mark class="w-14 opacity-10" />
|
|
||||||
<div class="text-14-regular text-text-weak max-w-56">{language.t("session.review.noChanges")}</div>
|
<div class="text-14-regular text-text-weak max-w-56">{language.t("session.review.noChanges")}</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
@@ -808,9 +860,23 @@ export default function Page() {
|
|||||||
empty={
|
empty={
|
||||||
store.changes === "turn" ? (
|
store.changes === "turn" ? (
|
||||||
emptyTurn()
|
emptyTurn()
|
||||||
|
) : reviewEmptyKey() === "session.review.noVcs" ? (
|
||||||
|
<div class={input.emptyClass}>
|
||||||
|
<div class="flex flex-col gap-3">
|
||||||
|
<div class="text-14-medium text-text-strong">Create a Git repository</div>
|
||||||
|
<div
|
||||||
|
class="text-14-regular text-text-base max-w-md"
|
||||||
|
style={{ "line-height": "var(--line-height-normal)" }}
|
||||||
|
>
|
||||||
|
Track, review, and undo changes in this project
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button size="large" disabled={ui.git} onClick={initGit}>
|
||||||
|
{ui.git ? "Creating Git repository..." : "Create Git repository"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div class={input.emptyClass}>
|
<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 class="text-14-regular text-text-weak max-w-56">{language.t(reviewEmptyKey())}</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -11,12 +11,13 @@ describe("createOpenReviewFile", () => {
|
|||||||
return `file://${path}`
|
return `file://${path}`
|
||||||
},
|
},
|
||||||
openTab: (tab) => calls.push(`open:${tab}`),
|
openTab: (tab) => calls.push(`open:${tab}`),
|
||||||
|
setActive: (tab) => calls.push(`active:${tab}`),
|
||||||
loadFile: (path) => calls.push(`load:${path}`),
|
loadFile: (path) => calls.push(`load:${path}`),
|
||||||
})
|
})
|
||||||
|
|
||||||
openReviewFile("src/a.ts")
|
openReviewFile("src/a.ts")
|
||||||
|
|
||||||
expect(calls).toEqual(["show", "load:src/a.ts", "tab:src/a.ts", "open:file://src/a.ts"])
|
expect(calls).toEqual(["show", "load:src/a.ts", "tab:src/a.ts", "open:file://src/a.ts", "active:file://src/a.ts"])
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -24,15 +24,20 @@ export const createOpenReviewFile = (input: {
|
|||||||
showAllFiles: () => void
|
showAllFiles: () => void
|
||||||
tabForPath: (path: string) => string
|
tabForPath: (path: string) => string
|
||||||
openTab: (tab: string) => void
|
openTab: (tab: string) => void
|
||||||
|
setActive: (tab: string) => void
|
||||||
loadFile: (path: string) => any | Promise<void>
|
loadFile: (path: string) => any | Promise<void>
|
||||||
}) => {
|
}) => {
|
||||||
return (path: string) => {
|
return (path: string) => {
|
||||||
batch(() => {
|
batch(() => {
|
||||||
input.showAllFiles()
|
input.showAllFiles()
|
||||||
const maybePromise = input.loadFile(path)
|
const maybePromise = input.loadFile(path)
|
||||||
const openTab = () => input.openTab(input.tabForPath(path))
|
const open = () => {
|
||||||
if (maybePromise instanceof Promise) maybePromise.then(openTab)
|
const tab = input.tabForPath(path)
|
||||||
else openTab()
|
input.openTab(tab)
|
||||||
|
input.setActive(tab)
|
||||||
|
}
|
||||||
|
if (maybePromise instanceof Promise) maybePromise.then(open)
|
||||||
|
else open()
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { For, createEffect, createMemo, on, onCleanup, Show, startTransition, Index, type JSX } from "solid-js"
|
import { For, createEffect, createMemo, on, onCleanup, Show, Index, type JSX } from "solid-js"
|
||||||
import { createStore, produce } from "solid-js/store"
|
import { createStore, produce } from "solid-js/store"
|
||||||
import { useNavigate, useParams } from "@solidjs/router"
|
import { useNavigate, useParams } from "@solidjs/router"
|
||||||
import { Button } from "@opencode-ai/ui/button"
|
import { Button } from "@opencode-ai/ui/button"
|
||||||
@@ -160,7 +160,7 @@ function createTimelineStaging(input: TimelineStageInput) {
|
|||||||
}
|
}
|
||||||
const currentTotal = input.messages().length
|
const currentTotal = input.messages().length
|
||||||
count = Math.min(currentTotal, count + input.config.batch)
|
count = Math.min(currentTotal, count + input.config.batch)
|
||||||
startTransition(() => setState("count", count))
|
setState("count", count)
|
||||||
if (count >= currentTotal) {
|
if (count >= currentTotal) {
|
||||||
setState({ completedSession: sessionKey, activeSession: "" })
|
setState({ completedSession: sessionKey, activeSession: "" })
|
||||||
frame = undefined
|
frame = undefined
|
||||||
@@ -611,7 +611,7 @@ export function MessageTimeline(props: {
|
|||||||
</Show>
|
</Show>
|
||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
<Show when={sessionID()} keyed>
|
<Show when={sessionID()}>
|
||||||
{(id) => (
|
{(id) => (
|
||||||
<div class="shrink-0 flex items-center gap-3">
|
<div class="shrink-0 flex items-center gap-3">
|
||||||
<SessionContextUsage placement="bottom" />
|
<SessionContextUsage placement="bottom" />
|
||||||
@@ -646,12 +646,12 @@ export function MessageTimeline(props: {
|
|||||||
>
|
>
|
||||||
<DropdownMenu.ItemLabel>{language.t("common.rename")}</DropdownMenu.ItemLabel>
|
<DropdownMenu.ItemLabel>{language.t("common.rename")}</DropdownMenu.ItemLabel>
|
||||||
</DropdownMenu.Item>
|
</DropdownMenu.Item>
|
||||||
<DropdownMenu.Item onSelect={() => void archiveSession(id)}>
|
<DropdownMenu.Item onSelect={() => void archiveSession(id())}>
|
||||||
<DropdownMenu.ItemLabel>{language.t("common.archive")}</DropdownMenu.ItemLabel>
|
<DropdownMenu.ItemLabel>{language.t("common.archive")}</DropdownMenu.ItemLabel>
|
||||||
</DropdownMenu.Item>
|
</DropdownMenu.Item>
|
||||||
<DropdownMenu.Separator />
|
<DropdownMenu.Separator />
|
||||||
<DropdownMenu.Item
|
<DropdownMenu.Item
|
||||||
onSelect={() => dialog.show(() => <DialogDeleteSession sessionID={id} />)}
|
onSelect={() => dialog.show(() => <DialogDeleteSession sessionID={id()} />)}
|
||||||
>
|
>
|
||||||
<DropdownMenu.ItemLabel>{language.t("common.delete")}</DropdownMenu.ItemLabel>
|
<DropdownMenu.ItemLabel>{language.t("common.delete")}</DropdownMenu.ItemLabel>
|
||||||
</DropdownMenu.Item>
|
</DropdownMenu.Item>
|
||||||
|
|||||||
@@ -60,6 +60,12 @@ export function SessionSidePanel(props: {
|
|||||||
return sync.data.session_diff[id] !== undefined
|
return sync.data.session_diff[id] !== undefined
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const reviewEmptyKey = createMemo(() => {
|
||||||
|
if (sync.project && !sync.project.vcs) return "session.review.noVcs"
|
||||||
|
if (sync.data.config.snapshot === false) return "session.review.noSnapshot"
|
||||||
|
return "session.review.noChanges"
|
||||||
|
})
|
||||||
|
|
||||||
const diffFiles = createMemo(() => diffs().map((d) => d.file))
|
const diffFiles = createMemo(() => diffs().map((d) => d.file))
|
||||||
const kinds = createMemo(() => {
|
const kinds = createMemo(() => {
|
||||||
const merge = (a: "add" | "del" | "mix" | undefined, b: "add" | "del" | "mix") => {
|
const merge = (a: "add" | "del" | "mix" | undefined, b: "add" | "del" | "mix") => {
|
||||||
@@ -87,6 +93,21 @@ export function SessionSidePanel(props: {
|
|||||||
return out
|
return out
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const empty = (msg: string) => (
|
||||||
|
<div class="h-full flex flex-col">
|
||||||
|
<div class="h-12 shrink-0" aria-hidden />
|
||||||
|
<div class="flex-1 pb-30 flex items-center justify-center text-center">
|
||||||
|
<div class="text-12-regular text-text-weak">{msg}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
|
const nofiles = createMemo(() => {
|
||||||
|
const state = file.tree.state("")
|
||||||
|
if (!state?.loaded) return false
|
||||||
|
return file.tree.children("").length === 0
|
||||||
|
})
|
||||||
|
|
||||||
const normalizeTab = (tab: string) => {
|
const normalizeTab = (tab: string) => {
|
||||||
if (!tab.startsWith("file://")) return tab
|
if (!tab.startsWith("file://")) return tab
|
||||||
return file.tab(tab)
|
return file.tab(tab)
|
||||||
@@ -145,17 +166,8 @@ export function SessionSidePanel(props: {
|
|||||||
|
|
||||||
const [store, setStore] = createStore({
|
const [store, setStore] = createStore({
|
||||||
activeDraggable: undefined as string | undefined,
|
activeDraggable: undefined as string | undefined,
|
||||||
fileTreeScrolled: false,
|
|
||||||
})
|
})
|
||||||
|
|
||||||
let changesEl: HTMLDivElement | undefined
|
|
||||||
let allEl: HTMLDivElement | undefined
|
|
||||||
|
|
||||||
const syncFileTreeScrolled = (el?: HTMLDivElement) => {
|
|
||||||
const next = (el?.scrollTop ?? 0) > 0
|
|
||||||
setStore("fileTreeScrolled", (current) => (current === next ? current : next))
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleDragStart = (event: unknown) => {
|
const handleDragStart = (event: unknown) => {
|
||||||
const id = getDraggableId(event)
|
const id = getDraggableId(event)
|
||||||
if (!id) return
|
if (!id) return
|
||||||
@@ -176,11 +188,6 @@ export function SessionSidePanel(props: {
|
|||||||
setStore("activeDraggable", undefined)
|
setStore("activeDraggable", undefined)
|
||||||
}
|
}
|
||||||
|
|
||||||
createEffect(() => {
|
|
||||||
if (!layout.fileTree.opened()) return
|
|
||||||
syncFileTreeScrolled(fileTreeTab() === "changes" ? changesEl : allEl)
|
|
||||||
})
|
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
if (!file.ready()) return
|
if (!file.ready()) return
|
||||||
|
|
||||||
@@ -207,7 +214,7 @@ export function SessionSidePanel(props: {
|
|||||||
<aside
|
<aside
|
||||||
id="review-panel"
|
id="review-panel"
|
||||||
aria-label={language.t("session.panel.reviewAndFiles")}
|
aria-label={language.t("session.panel.reviewAndFiles")}
|
||||||
class="relative min-w-0 h-full border-l border-border-weak-base flex"
|
class="relative min-w-0 h-full border-l border-border-weaker-base flex"
|
||||||
classList={{
|
classList={{
|
||||||
"flex-1": reviewOpen(),
|
"flex-1": reviewOpen(),
|
||||||
"shrink-0": !reviewOpen(),
|
"shrink-0": !reviewOpen(),
|
||||||
@@ -331,9 +338,7 @@ export function SessionSidePanel(props: {
|
|||||||
const path = createMemo(() => file.pathFromTab(tab))
|
const path = createMemo(() => file.pathFromTab(tab))
|
||||||
return (
|
return (
|
||||||
<div data-component="tabs-drag-preview">
|
<div data-component="tabs-drag-preview">
|
||||||
<Show when={path()} keyed>
|
<Show when={path()}>{(p) => <FileVisual active path={p()} />}</Show>
|
||||||
{(p) => <FileVisual active path={p} />}
|
|
||||||
</Show>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}}
|
}}
|
||||||
@@ -347,7 +352,7 @@ export function SessionSidePanel(props: {
|
|||||||
<div id="file-tree-panel" class="relative shrink-0 h-full" style={{ width: `${layout.fileTree.width()}px` }}>
|
<div id="file-tree-panel" class="relative shrink-0 h-full" style={{ width: `${layout.fileTree.width()}px` }}>
|
||||||
<div
|
<div
|
||||||
class="h-full flex flex-col overflow-hidden group/filetree"
|
class="h-full flex flex-col overflow-hidden group/filetree"
|
||||||
classList={{ "border-l border-border-weak-base": reviewOpen() }}
|
classList={{ "border-l border-border-weaker-base": reviewOpen() }}
|
||||||
>
|
>
|
||||||
<Tabs
|
<Tabs
|
||||||
variant="pill"
|
variant="pill"
|
||||||
@@ -356,7 +361,7 @@ export function SessionSidePanel(props: {
|
|||||||
class="h-full"
|
class="h-full"
|
||||||
data-scope="filetree"
|
data-scope="filetree"
|
||||||
>
|
>
|
||||||
<Tabs.List data-scrolled={store.fileTreeScrolled ? "" : undefined}>
|
<Tabs.List>
|
||||||
<Tabs.Trigger value="changes" class="flex-1" classes={{ button: "w-full" }}>
|
<Tabs.Trigger value="changes" class="flex-1" classes={{ button: "w-full" }}>
|
||||||
{reviewCount()}{" "}
|
{reviewCount()}{" "}
|
||||||
{language.t(reviewCount() === 1 ? "session.review.change.one" : "session.review.change.other")}
|
{language.t(reviewCount() === 1 ? "session.review.change.one" : "session.review.change.other")}
|
||||||
@@ -365,12 +370,7 @@ export function SessionSidePanel(props: {
|
|||||||
{language.t("session.files.all")}
|
{language.t("session.files.all")}
|
||||||
</Tabs.Trigger>
|
</Tabs.Trigger>
|
||||||
</Tabs.List>
|
</Tabs.List>
|
||||||
<Tabs.Content
|
<Tabs.Content value="changes" class="bg-background-stronger px-3 py-0">
|
||||||
value="changes"
|
|
||||||
ref={(el: HTMLDivElement) => (changesEl = el)}
|
|
||||||
onScroll={(e: UIEvent & { currentTarget: HTMLDivElement }) => syncFileTreeScrolled(e.currentTarget)}
|
|
||||||
class="bg-background-stronger px-3 py-0"
|
|
||||||
>
|
|
||||||
<Switch>
|
<Switch>
|
||||||
<Match when={hasReview()}>
|
<Match when={hasReview()}>
|
||||||
<Show
|
<Show
|
||||||
@@ -384,6 +384,7 @@ export function SessionSidePanel(props: {
|
|||||||
>
|
>
|
||||||
<FileTree
|
<FileTree
|
||||||
path=""
|
path=""
|
||||||
|
class="pt-3"
|
||||||
allowed={diffFiles()}
|
allowed={diffFiles()}
|
||||||
kinds={kinds()}
|
kinds={kinds()}
|
||||||
draggable={false}
|
draggable={false}
|
||||||
@@ -392,25 +393,22 @@ export function SessionSidePanel(props: {
|
|||||||
/>
|
/>
|
||||||
</Show>
|
</Show>
|
||||||
</Match>
|
</Match>
|
||||||
<Match when={true}>
|
<Match when={true}>{empty(language.t(reviewEmptyKey()))}</Match>
|
||||||
<div class="mt-8 text-center text-12-regular text-text-weak">
|
|
||||||
{language.t("session.review.noChanges")}
|
|
||||||
</div>
|
|
||||||
</Match>
|
|
||||||
</Switch>
|
</Switch>
|
||||||
</Tabs.Content>
|
</Tabs.Content>
|
||||||
<Tabs.Content
|
<Tabs.Content value="all" class="bg-background-stronger px-3 py-0">
|
||||||
value="all"
|
<Switch>
|
||||||
ref={(el: HTMLDivElement) => (allEl = el)}
|
<Match when={nofiles()}>{empty(language.t("session.files.empty"))}</Match>
|
||||||
onScroll={(e: UIEvent & { currentTarget: HTMLDivElement }) => syncFileTreeScrolled(e.currentTarget)}
|
<Match when={true}>
|
||||||
class="bg-background-stronger px-3 py-0"
|
<FileTree
|
||||||
>
|
path=""
|
||||||
<FileTree
|
class="pt-3"
|
||||||
path=""
|
modified={diffFiles()}
|
||||||
modified={diffFiles()}
|
kinds={kinds()}
|
||||||
kinds={kinds()}
|
onFileClick={(node) => openTab(file.tab(node.path))}
|
||||||
onFileClick={(node) => openTab(file.tab(node.path))}
|
/>
|
||||||
/>
|
</Match>
|
||||||
|
</Switch>
|
||||||
</Tabs.Content>
|
</Tabs.Content>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -154,7 +154,7 @@ export function TerminalPanel() {
|
|||||||
when={terminal.ready()}
|
when={terminal.ready()}
|
||||||
fallback={
|
fallback={
|
||||||
<div class="flex flex-col h-full pointer-events-none">
|
<div class="flex flex-col h-full pointer-events-none">
|
||||||
<div class="h-10 flex items-center gap-2 px-2 border-b border-border-weak-base bg-background-stronger overflow-hidden">
|
<div class="h-10 flex items-center gap-2 px-2 border-b border-border-weaker-base bg-background-stronger overflow-hidden">
|
||||||
<For each={handoff()}>
|
<For each={handoff()}>
|
||||||
{(title) => (
|
{(title) => (
|
||||||
<div class="px-2 py-1 rounded-md bg-surface-base text-14-regular text-text-weak truncate max-w-40">
|
<div class="px-2 py-1 rounded-md bg-surface-base text-14-regular text-text-weak truncate max-w-40">
|
||||||
@@ -187,12 +187,12 @@ export function TerminalPanel() {
|
|||||||
onChange={(id) => terminal.open(id)}
|
onChange={(id) => terminal.open(id)}
|
||||||
class="!h-auto !flex-none"
|
class="!h-auto !flex-none"
|
||||||
>
|
>
|
||||||
<Tabs.List class="h-10">
|
<Tabs.List class="h-10 border-b border-border-weaker-base">
|
||||||
<SortableProvider ids={ids()}>
|
<SortableProvider ids={ids()}>
|
||||||
<For each={ids()}>
|
<For each={ids()}>
|
||||||
{(id) => (
|
{(id) => (
|
||||||
<Show when={byId().get(id)} keyed>
|
<Show when={byId().get(id)}>
|
||||||
{(pty) => <SortableTerminalTab terminal={pty} onClose={close} />}
|
{(pty) => <SortableTerminalTab terminal={pty()} onClose={close} />}
|
||||||
</Show>
|
</Show>
|
||||||
)}
|
)}
|
||||||
</For>
|
</For>
|
||||||
@@ -217,10 +217,10 @@ export function TerminalPanel() {
|
|||||||
<div class="flex-1 min-h-0 relative">
|
<div class="flex-1 min-h-0 relative">
|
||||||
<Show when={terminal.active()} keyed>
|
<Show when={terminal.active()} keyed>
|
||||||
{(id) => (
|
{(id) => (
|
||||||
<Show when={byId().get(id)} keyed>
|
<Show when={byId().get(id)}>
|
||||||
{(pty) => (
|
{(pty) => (
|
||||||
<div id={`terminal-wrapper-${id}`} class="absolute inset-0">
|
<div id={`terminal-wrapper-${id}`} class="absolute inset-0">
|
||||||
<Terminal pty={pty} onCleanup={terminal.update} onConnectError={() => terminal.clone(id)} />
|
<Terminal pty={pty()} onCleanup={terminal.update} onConnectError={() => terminal.clone(id)} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</Show>
|
</Show>
|
||||||
@@ -229,14 +229,14 @@ export function TerminalPanel() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<DragOverlay>
|
<DragOverlay>
|
||||||
<Show when={store.activeDraggable} keyed>
|
<Show when={store.activeDraggable}>
|
||||||
{(draggedId) => (
|
{(draggedId) => (
|
||||||
<Show when={byId().get(draggedId)} keyed>
|
<Show when={byId().get(draggedId())}>
|
||||||
{(t) => (
|
{(t) => (
|
||||||
<div class="relative p-1 h-10 flex items-center bg-background-stronger text-14-regular">
|
<div class="relative p-1 h-10 flex items-center bg-background-stronger text-14-regular">
|
||||||
{terminalTabLabel({
|
{terminalTabLabel({
|
||||||
title: t.title,
|
title: t().title,
|
||||||
titleNumber: t.titleNumber,
|
titleNumber: t().titleNumber,
|
||||||
t: language.t as (key: string, vars?: Record<string, string | number | boolean>) => string,
|
t: language.t as (key: string, vars?: Record<string, string | number | boolean>) => string,
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -261,24 +261,35 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
|
|||||||
}),
|
}),
|
||||||
])
|
])
|
||||||
|
|
||||||
|
const isAutoAcceptActive = () => {
|
||||||
|
const sessionID = params.id
|
||||||
|
if (sessionID) return permission.isAutoAccepting(sessionID, sdk.directory)
|
||||||
|
return permission.isAutoAcceptingDirectory(sdk.directory)
|
||||||
|
}
|
||||||
|
|
||||||
const permissionCommands = createMemo(() => [
|
const permissionCommands = createMemo(() => [
|
||||||
permissionsCommand({
|
permissionsCommand({
|
||||||
id: "permissions.autoaccept",
|
id: "permissions.autoaccept",
|
||||||
title:
|
title: isAutoAcceptActive()
|
||||||
params.id && permission.isAutoAccepting(params.id, sdk.directory)
|
? language.t("command.permissions.autoaccept.disable")
|
||||||
? language.t("command.permissions.autoaccept.disable")
|
: language.t("command.permissions.autoaccept.enable"),
|
||||||
: language.t("command.permissions.autoaccept.enable"),
|
|
||||||
keybind: "mod+shift+a",
|
keybind: "mod+shift+a",
|
||||||
disabled: !params.id || !permission.permissionsEnabled(),
|
disabled: false,
|
||||||
onSelect: () => {
|
onSelect: () => {
|
||||||
const sessionID = params.id
|
const sessionID = params.id
|
||||||
if (!sessionID) return
|
if (sessionID) {
|
||||||
permission.toggleAutoAccept(sessionID, sdk.directory)
|
permission.toggleAutoAccept(sessionID, sdk.directory)
|
||||||
|
} else {
|
||||||
|
permission.toggleAutoAcceptDirectory(sdk.directory)
|
||||||
|
}
|
||||||
|
const active = sessionID
|
||||||
|
? permission.isAutoAccepting(sessionID, sdk.directory)
|
||||||
|
: permission.isAutoAcceptingDirectory(sdk.directory)
|
||||||
showToast({
|
showToast({
|
||||||
title: permission.isAutoAccepting(sessionID, sdk.directory)
|
title: active
|
||||||
? language.t("toast.permissions.autoaccept.on.title")
|
? language.t("toast.permissions.autoaccept.on.title")
|
||||||
: language.t("toast.permissions.autoaccept.off.title"),
|
: language.t("toast.permissions.autoaccept.off.title"),
|
||||||
description: permission.isAutoAccepting(sessionID, sdk.directory)
|
description: active
|
||||||
? language.t("toast.permissions.autoaccept.on.description")
|
? language.t("toast.permissions.autoaccept.on.description")
|
||||||
: language.t("toast.permissions.autoaccept.off.description"),
|
: language.t("toast.permissions.autoaccept.off.description"),
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@opencode-ai/console-app",
|
"name": "@opencode-ai/console-app",
|
||||||
"version": "1.2.17",
|
"version": "1.2.20",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -108,6 +108,26 @@ const DOCS_SEGMENT = new Set([
|
|||||||
"zh-tw",
|
"zh-tw",
|
||||||
])
|
])
|
||||||
|
|
||||||
|
const DOCS_LOCALE = {
|
||||||
|
ar: "ar",
|
||||||
|
da: "da",
|
||||||
|
de: "de",
|
||||||
|
en: "en",
|
||||||
|
es: "es",
|
||||||
|
fr: "fr",
|
||||||
|
it: "it",
|
||||||
|
ja: "ja",
|
||||||
|
ko: "ko",
|
||||||
|
nb: "no",
|
||||||
|
"pt-br": "br",
|
||||||
|
root: "en",
|
||||||
|
ru: "ru",
|
||||||
|
th: "th",
|
||||||
|
tr: "tr",
|
||||||
|
"zh-cn": "zh",
|
||||||
|
"zh-tw": "zht",
|
||||||
|
} as const satisfies Record<string, Locale>
|
||||||
|
|
||||||
function suffix(pathname: string) {
|
function suffix(pathname: string) {
|
||||||
const index = pathname.search(/[?#]/)
|
const index = pathname.search(/[?#]/)
|
||||||
if (index === -1) {
|
if (index === -1) {
|
||||||
@@ -130,7 +150,12 @@ export function docs(locale: Locale, pathname: string) {
|
|||||||
return `${next.path}${next.suffix}`
|
return `${next.path}${next.suffix}`
|
||||||
}
|
}
|
||||||
|
|
||||||
if (value === "root") return `${next.path}${next.suffix}`
|
if (value === "root") {
|
||||||
|
if (next.path === "/docs/en") return `/docs${next.suffix}`
|
||||||
|
if (next.path === "/docs/en/") return `/docs/${next.suffix}`
|
||||||
|
if (next.path.startsWith("/docs/en/")) return `/docs/${next.path.slice("/docs/en/".length)}${next.suffix}`
|
||||||
|
return `${next.path}${next.suffix}`
|
||||||
|
}
|
||||||
|
|
||||||
if (next.path === "/docs") return `/docs/${value}${next.suffix}`
|
if (next.path === "/docs") return `/docs/${value}${next.suffix}`
|
||||||
if (next.path === "/docs/") return `/docs/${value}/${next.suffix}`
|
if (next.path === "/docs/") return `/docs/${value}/${next.suffix}`
|
||||||
@@ -154,6 +179,15 @@ export function fromPathname(pathname: string) {
|
|||||||
return parseLocale(fix(pathname).split("/")[1])
|
return parseLocale(fix(pathname).split("/")[1])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function fromDocsPathname(pathname: string) {
|
||||||
|
const next = fix(pathname)
|
||||||
|
const value = next.split("/")[2]?.toLowerCase()
|
||||||
|
if (!value) return null
|
||||||
|
if (!next.startsWith("/docs/")) return null
|
||||||
|
if (!(value in DOCS_LOCALE)) return null
|
||||||
|
return DOCS_LOCALE[value as keyof typeof DOCS_LOCALE]
|
||||||
|
}
|
||||||
|
|
||||||
export function strip(pathname: string) {
|
export function strip(pathname: string) {
|
||||||
const locale = fromPathname(pathname)
|
const locale = fromPathname(pathname)
|
||||||
if (!locale) return fix(pathname)
|
if (!locale) return fix(pathname)
|
||||||
@@ -272,6 +306,9 @@ export function localeFromRequest(request: Request) {
|
|||||||
const fromPath = fromPathname(new URL(request.url).pathname)
|
const fromPath = fromPathname(new URL(request.url).pathname)
|
||||||
if (fromPath) return fromPath
|
if (fromPath) return fromPath
|
||||||
|
|
||||||
|
const fromDocsPath = fromDocsPathname(new URL(request.url).pathname)
|
||||||
|
if (fromDocsPath) return fromDocsPath
|
||||||
|
|
||||||
return (
|
return (
|
||||||
localeFromCookieHeader(request.headers.get("cookie")) ??
|
localeFromCookieHeader(request.headers.get("cookie")) ??
|
||||||
detectFromAcceptLanguage(request.headers.get("accept-language"))
|
detectFromAcceptLanguage(request.headers.get("accept-language"))
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import type { APIEvent } from "@solidjs/start/server"
|
import type { APIEvent } from "@solidjs/start/server"
|
||||||
import { Resource } from "@opencode-ai/console-resource"
|
import { Resource } from "@opencode-ai/console-resource"
|
||||||
import { docs, localeFromRequest, tag } from "~/lib/language"
|
import { cookie, docs, localeFromRequest, tag } from "~/lib/language"
|
||||||
|
|
||||||
async function handler(evt: APIEvent) {
|
async function handler(evt: APIEvent) {
|
||||||
const req = evt.request.clone()
|
const req = evt.request.clone()
|
||||||
@@ -17,7 +17,9 @@ async function handler(evt: APIEvent) {
|
|||||||
headers,
|
headers,
|
||||||
body: req.body,
|
body: req.body,
|
||||||
})
|
})
|
||||||
return response
|
const next = new Response(response.body, response)
|
||||||
|
next.headers.append("set-cookie", cookie(locale))
|
||||||
|
return next
|
||||||
}
|
}
|
||||||
|
|
||||||
export const GET = handler
|
export const GET = handler
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import type { APIEvent } from "@solidjs/start/server"
|
import type { APIEvent } from "@solidjs/start/server"
|
||||||
import { Resource } from "@opencode-ai/console-resource"
|
import { Resource } from "@opencode-ai/console-resource"
|
||||||
import { docs, localeFromRequest, tag } from "~/lib/language"
|
import { cookie, docs, localeFromRequest, tag } from "~/lib/language"
|
||||||
|
|
||||||
async function handler(evt: APIEvent) {
|
async function handler(evt: APIEvent) {
|
||||||
const req = evt.request.clone()
|
const req = evt.request.clone()
|
||||||
@@ -17,7 +17,9 @@ async function handler(evt: APIEvent) {
|
|||||||
headers,
|
headers,
|
||||||
body: req.body,
|
body: req.body,
|
||||||
})
|
})
|
||||||
return response
|
const next = new Response(response.body, response)
|
||||||
|
next.headers.append("set-cookie", cookie(locale))
|
||||||
|
return next
|
||||||
}
|
}
|
||||||
|
|
||||||
export const GET = handler
|
export const GET = handler
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ export default function PrivacyPolicy() {
|
|||||||
<section data-component="brand-content">
|
<section data-component="brand-content">
|
||||||
<article data-component="privacy-policy">
|
<article data-component="privacy-policy">
|
||||||
<h1>Privacy Policy</h1>
|
<h1>Privacy Policy</h1>
|
||||||
<p class="effective-date">Effective date: Dec 16, 2025</p>
|
<p class="effective-date">Effective date: Mar 6, 2026</p>
|
||||||
|
|
||||||
<p>
|
<p>
|
||||||
At OpenCode, we take your privacy seriously. Please read this Privacy Policy to learn how we treat your
|
At OpenCode, we take your privacy seriously. Please read this Privacy Policy to learn how we treat your
|
||||||
@@ -30,7 +30,10 @@ export default function PrivacyPolicy() {
|
|||||||
By using or accessing our Services in any manner, you acknowledge that you accept the practices and
|
By using or accessing our Services in any manner, you acknowledge that you accept the practices and
|
||||||
policies outlined below, and you hereby consent that we will collect, use and disclose your
|
policies outlined below, and you hereby consent that we will collect, use and disclose your
|
||||||
information as described in this Privacy Policy.
|
information as described in this Privacy Policy.
|
||||||
</strong>
|
</strong>{" "}
|
||||||
|
For clarity, our open source software that is not provided to you on a hosted basis is subject to the
|
||||||
|
open source license and terms set forth on the applicable repository where you access such open source
|
||||||
|
software, and such license and terms will exclusively govern your use of such open source software.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p>
|
<p>
|
||||||
@@ -382,9 +385,7 @@ export default function PrivacyPolicy() {
|
|||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
<h3>Parties You Authorize, Access or Authenticate</h3>
|
<h3>Parties You Authorize, Access or Authenticate</h3>
|
||||||
<ul>
|
<p>Parties You Authorize, Access or Authenticate.</p>
|
||||||
<li>Home buyers</li>
|
|
||||||
</ul>
|
|
||||||
|
|
||||||
<h3>Legal Obligations</h3>
|
<h3>Legal Obligations</h3>
|
||||||
<p>
|
<p>
|
||||||
@@ -1502,6 +1503,7 @@ export default function PrivacyPolicy() {
|
|||||||
Email: <a href="mailto:contact@anoma.ly">contact@anoma.ly</a>
|
Email: <a href="mailto:contact@anoma.ly">contact@anoma.ly</a>
|
||||||
</li>
|
</li>
|
||||||
<li>Phone: +1 415 794-0209</li>
|
<li>Phone: +1 415 794-0209</li>
|
||||||
|
<li>Address: 2443 Fillmore St #380-6343, San Francisco, CA 94115, United States</li>
|
||||||
</ul>
|
</ul>
|
||||||
</article>
|
</article>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -21,12 +21,12 @@ export default function TermsOfService() {
|
|||||||
<section data-component="brand-content">
|
<section data-component="brand-content">
|
||||||
<article data-component="terms-of-service">
|
<article data-component="terms-of-service">
|
||||||
<h1>Terms of Use</h1>
|
<h1>Terms of Use</h1>
|
||||||
<p class="effective-date">Effective date: Dec 16, 2025</p>
|
<p class="effective-date">Effective date: Mar 6, 2026</p>
|
||||||
|
|
||||||
<p>
|
<p>
|
||||||
Welcome to OpenCode. Please read on to learn the rules and restrictions that govern your use of OpenCode
|
Welcome to OpenCode. Please read on to learn the rules and restrictions that govern your use of
|
||||||
(the "Services"). If you have any questions, comments, or concerns regarding these terms or the
|
OpenCode's website, inference product and hosted software offering (the "Services"). If you have
|
||||||
Services, please contact us at:
|
any questions, comments, or concerns regarding these terms or the Services, please contact us at:
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p>
|
<p>
|
||||||
@@ -44,7 +44,10 @@ export default function TermsOfService() {
|
|||||||
and/or conditions ("Additional Terms"), which are incorporated herein by reference, and you understand
|
and/or conditions ("Additional Terms"), which are incorporated herein by reference, and you understand
|
||||||
and agree that by using or participating in any such Services, you agree to also comply with these
|
and agree that by using or participating in any such Services, you agree to also comply with these
|
||||||
Additional Terms.
|
Additional Terms.
|
||||||
</strong>
|
</strong>{" "}
|
||||||
|
For clarity, our open source software that is not provided to you on a hosted basis is subject to the
|
||||||
|
open source license and terms set forth on the applicable repository where you access such open source
|
||||||
|
software, and such license and terms will exclusively govern your use of such open source software.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p>
|
<p>
|
||||||
@@ -460,10 +463,10 @@ export default function TermsOfService() {
|
|||||||
<h4>Opt-out</h4>
|
<h4>Opt-out</h4>
|
||||||
<p>
|
<p>
|
||||||
You have the right to opt out of the provisions of this Section by sending written notice of your
|
You have the right to opt out of the provisions of this Section by sending written notice of your
|
||||||
decision to opt out to the following address: [ADDRESS], [CITY], Canada [ZIP CODE] postmarked within
|
decision to opt out to the following address: 2443 Fillmore St #380-6343, San Francisco, CA 94115,
|
||||||
thirty (30) days of first accepting these Terms. You must include (i) your name and residence address,
|
United States postmarked within thirty (30) days of first accepting these Terms. You must include (i)
|
||||||
(ii) the email address and/or telephone number associated with your account, and (iii) a clear statement
|
your name and residence address, (ii) the email address and/or telephone number associated with your
|
||||||
that you want to opt out of these Terms' arbitration agreement.
|
account, and (iii) a clear statement that you want to opt out of these Terms' arbitration agreement.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<h4>Exclusive Venue</h4>
|
<h4>Exclusive Venue</h4>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import type { APIEvent } from "@solidjs/start/server"
|
import type { APIEvent } from "@solidjs/start/server"
|
||||||
import { Resource } from "@opencode-ai/console-resource"
|
import { Resource } from "@opencode-ai/console-resource"
|
||||||
import { docs, localeFromRequest, tag } from "~/lib/language"
|
import { cookie, docs, localeFromRequest, tag } from "~/lib/language"
|
||||||
|
|
||||||
async function handler(evt: APIEvent) {
|
async function handler(evt: APIEvent) {
|
||||||
const req = evt.request.clone()
|
const req = evt.request.clone()
|
||||||
@@ -17,7 +17,9 @@ async function handler(evt: APIEvent) {
|
|||||||
headers,
|
headers,
|
||||||
body: req.body,
|
body: req.body,
|
||||||
})
|
})
|
||||||
return response
|
const next = new Response(response.body, response)
|
||||||
|
next.headers.append("set-cookie", cookie(locale))
|
||||||
|
return next
|
||||||
}
|
}
|
||||||
|
|
||||||
export const GET = handler
|
export const GET = handler
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://json.schemastore.org/package.json",
|
"$schema": "https://json.schemastore.org/package.json",
|
||||||
"name": "@opencode-ai/console-core",
|
"name": "@opencode-ai/console-core",
|
||||||
"version": "1.2.17",
|
"version": "1.2.20",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@opencode-ai/console-function",
|
"name": "@opencode-ai/console-function",
|
||||||
"version": "1.2.17",
|
"version": "1.2.20",
|
||||||
"$schema": "https://json.schemastore.org/package.json",
|
"$schema": "https://json.schemastore.org/package.json",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@opencode-ai/console-mail",
|
"name": "@opencode-ai/console-mail",
|
||||||
"version": "1.2.17",
|
"version": "1.2.20",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@jsx-email/all": "2.2.3",
|
"@jsx-email/all": "2.2.3",
|
||||||
"@jsx-email/cli": "1.4.3",
|
"@jsx-email/cli": "1.4.3",
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "@opencode-ai/desktop-electron",
|
"name": "@opencode-ai/desktop-electron",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "1.2.17",
|
"version": "1.2.20",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"homepage": "https://opencode.ai",
|
"homepage": "https://opencode.ai",
|
||||||
|
|||||||
@@ -76,6 +76,7 @@ function detectLocale(): Locale {
|
|||||||
const languages = navigator.languages?.length ? navigator.languages : [navigator.language]
|
const languages = navigator.languages?.length ? navigator.languages : [navigator.language]
|
||||||
for (const language of languages) {
|
for (const language of languages) {
|
||||||
if (!language) continue
|
if (!language) continue
|
||||||
|
if (language.toLowerCase().startsWith("en")) return "en"
|
||||||
if (language.toLowerCase().startsWith("zh")) {
|
if (language.toLowerCase().startsWith("zh")) {
|
||||||
if (language.toLowerCase().includes("hant")) return "zht"
|
if (language.toLowerCase().includes("hant")) return "zht"
|
||||||
return "zh"
|
return "zh"
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "@opencode-ai/desktop",
|
"name": "@opencode-ai/desktop",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "1.2.17",
|
"version": "1.2.20",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -77,6 +77,7 @@ function detectLocale(): Locale {
|
|||||||
const languages = navigator.languages?.length ? navigator.languages : [navigator.language]
|
const languages = navigator.languages?.length ? navigator.languages : [navigator.language]
|
||||||
for (const language of languages) {
|
for (const language of languages) {
|
||||||
if (!language) continue
|
if (!language) continue
|
||||||
|
if (language.toLowerCase().startsWith("en")) return "en"
|
||||||
if (language.toLowerCase().startsWith("zh")) {
|
if (language.toLowerCase().startsWith("zh")) {
|
||||||
if (language.toLowerCase().includes("hant")) return "zht"
|
if (language.toLowerCase().includes("hant")) return "zht"
|
||||||
return "zh"
|
return "zh"
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@opencode-ai/enterprise",
|
"name": "@opencode-ai/enterprise",
|
||||||
"version": "1.2.17",
|
"version": "1.2.20",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
|||||||
@@ -1,10 +1,8 @@
|
|||||||
import { FileDiff, Message, Model, Part, Session } from "@opencode-ai/sdk/v2"
|
import { FileDiff, Message, Model, Part, Session } from "@opencode-ai/sdk/v2"
|
||||||
import { fn } from "@opencode-ai/util/fn"
|
import { fn } from "@opencode-ai/util/fn"
|
||||||
import { iife } from "@opencode-ai/util/iife"
|
import { iife } from "@opencode-ai/util/iife"
|
||||||
import { Identifier } from "@opencode-ai/util/identifier"
|
|
||||||
import z from "zod"
|
import z from "zod"
|
||||||
import { Storage } from "./storage"
|
import { Storage } from "./storage"
|
||||||
import { Binary } from "@opencode-ai/util/binary"
|
|
||||||
|
|
||||||
export namespace Share {
|
export namespace Share {
|
||||||
export const Info = z.object({
|
export const Info = z.object({
|
||||||
@@ -38,6 +36,81 @@ export namespace Share {
|
|||||||
])
|
])
|
||||||
export type Data = z.infer<typeof Data>
|
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) => {
|
export const create = fn(z.object({ sessionID: z.string() }), async (body) => {
|
||||||
const isTest = process.env.NODE_ENV === "test" || body.sessionID.startsWith("test_")
|
const isTest = process.env.NODE_ENV === "test" || body.sessionID.startsWith("test_")
|
||||||
const info: Info = {
|
const info: Info = {
|
||||||
@@ -47,7 +120,7 @@ export namespace Share {
|
|||||||
}
|
}
|
||||||
const exists = await get(info.id)
|
const exists = await get(info.id)
|
||||||
if (exists) throw new Errors.AlreadyExists(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
|
return info
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -60,8 +133,13 @@ export namespace Share {
|
|||||||
if (!share) throw new Errors.NotFound(body.id)
|
if (!share) throw new Errors.NotFound(body.id)
|
||||||
if (share.secret !== body.secret) throw new Errors.InvalidSecret(body.id)
|
if (share.secret !== body.secret) throw new Errors.InvalidSecret(body.id)
|
||||||
await Storage.remove(["share", body.id])
|
await Storage.remove(["share", body.id])
|
||||||
const list = await Storage.list({ prefix: ["share_data", body.id] })
|
const groups = await Promise.all([
|
||||||
for (const item of list) {
|
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)
|
await Storage.remove(item)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -75,59 +153,13 @@ export namespace Share {
|
|||||||
const share = await get(input.share.id)
|
const share = await get(input.share.id)
|
||||||
if (!share) throw new Errors.NotFound(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)
|
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) {
|
export async function data(shareID: string) {
|
||||||
console.log("reading compaction")
|
return (await readSnapshot(shareID)) ?? legacy(shareID)
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const syncOld = fn(
|
export const syncOld = fn(
|
||||||
|
|||||||
@@ -108,6 +108,7 @@ app
|
|||||||
validator("param", z.object({ shareID: z.string() })),
|
validator("param", z.object({ shareID: z.string() })),
|
||||||
async (c) => {
|
async (c) => {
|
||||||
const { shareID } = c.req.valid("param")
|
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))
|
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 { FileComponentProvider } from "@opencode-ai/ui/context/file"
|
||||||
import { WorkerPoolProvider } from "@opencode-ai/ui/context/worker-pool"
|
import { WorkerPoolProvider } from "@opencode-ai/ui/context/worker-pool"
|
||||||
import { createAsync, query, useParams } from "@solidjs/router"
|
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 { Share } from "~/core/share"
|
||||||
import { Logo, Mark } from "@opencode-ai/ui/logo"
|
import { Logo, Mark } from "@opencode-ai/ui/logo"
|
||||||
import { IconButton } from "@opencode-ai/ui/icon-button"
|
import { IconButton } from "@opencode-ai/ui/icon-button"
|
||||||
import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
|
import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
|
||||||
import { createDefaultOptions } from "@opencode-ai/ui/pierre"
|
|
||||||
import { iife } from "@opencode-ai/util/iife"
|
import { iife } from "@opencode-ai/util/iife"
|
||||||
import { Binary } from "@opencode-ai/util/binary"
|
import { Binary } from "@opencode-ai/util/binary"
|
||||||
import { NamedError } from "@opencode-ai/util/error"
|
import { NamedError } from "@opencode-ai/util/error"
|
||||||
@@ -20,11 +19,11 @@ import z from "zod"
|
|||||||
import NotFound from "../[...404]"
|
import NotFound from "../[...404]"
|
||||||
import { Tabs } from "@opencode-ai/ui/tabs"
|
import { Tabs } from "@opencode-ai/ui/tabs"
|
||||||
import { MessageNav } from "@opencode-ai/ui/message-nav"
|
import { MessageNav } from "@opencode-ai/ui/message-nav"
|
||||||
import { preloadMultiFileDiff, PreloadMultiFileDiffResult } from "@pierre/diffs/ssr"
|
|
||||||
import { FileSSR } from "@opencode-ai/ui/file-ssr"
|
import { FileSSR } from "@opencode-ai/ui/file-ssr"
|
||||||
import { clientOnly } from "@solidjs/start"
|
import { clientOnly } from "@solidjs/start"
|
||||||
import { Meta, Title } from "@solidjs/meta"
|
import { Meta, Title } from "@solidjs/meta"
|
||||||
import { Base64 } from "js-base64"
|
import { Base64 } from "js-base64"
|
||||||
|
import { getRequestEvent } from "solid-js/web"
|
||||||
|
|
||||||
const ClientOnlyWorkerPoolProvider = clientOnly(() =>
|
const ClientOnlyWorkerPoolProvider = clientOnly(() =>
|
||||||
import("@opencode-ai/ui/pierre/worker").then((m) => ({
|
import("@opencode-ai/ui/pierre/worker").then((m) => ({
|
||||||
@@ -54,12 +53,6 @@ const getData = query(async (shareID) => {
|
|||||||
session_diff: {
|
session_diff: {
|
||||||
[sessionID: string]: FileDiff[]
|
[sessionID: string]: FileDiff[]
|
||||||
}
|
}
|
||||||
session_diff_preload: {
|
|
||||||
[sessionID: string]: PreloadMultiFileDiffResult<any>[]
|
|
||||||
}
|
|
||||||
session_diff_preload_split: {
|
|
||||||
[sessionID: string]: PreloadMultiFileDiffResult<any>[]
|
|
||||||
}
|
|
||||||
session_status: {
|
session_status: {
|
||||||
[sessionID: string]: SessionStatus
|
[sessionID: string]: SessionStatus
|
||||||
}
|
}
|
||||||
@@ -79,12 +72,6 @@ const getData = query(async (shareID) => {
|
|||||||
session_diff: {
|
session_diff: {
|
||||||
[share.sessionID]: [],
|
[share.sessionID]: [],
|
||||||
},
|
},
|
||||||
session_diff_preload: {
|
|
||||||
[share.sessionID]: [],
|
|
||||||
},
|
|
||||||
session_diff_preload_split: {
|
|
||||||
[share.sessionID]: [],
|
|
||||||
},
|
|
||||||
session_status: {
|
session_status: {
|
||||||
[share.sessionID]: {
|
[share.sessionID]: {
|
||||||
type: "idle",
|
type: "idle",
|
||||||
@@ -101,28 +88,6 @@ const getData = query(async (shareID) => {
|
|||||||
break
|
break
|
||||||
case "session_diff":
|
case "session_diff":
|
||||||
result.session_diff[share.sessionID] = item.data
|
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
|
break
|
||||||
case "message":
|
case "message":
|
||||||
result.message[item.data.sessionID] = result.message[item.data.sessionID] ?? []
|
result.message[item.data.sessionID] = result.message[item.data.sessionID] ?? []
|
||||||
@@ -143,17 +108,15 @@ const getData = query(async (shareID) => {
|
|||||||
}, "getShareData")
|
}, "getShareData")
|
||||||
|
|
||||||
export default function () {
|
export default function () {
|
||||||
|
getRequestEvent()?.response.headers.set(
|
||||||
|
"Cache-Control",
|
||||||
|
"public, max-age=30, s-maxage=300, stale-while-revalidate=86400",
|
||||||
|
)
|
||||||
|
|
||||||
const params = useParams()
|
const params = useParams()
|
||||||
const data = createAsync(async () => {
|
const data = createAsync(async () => {
|
||||||
if (!params.shareID) throw new Error("Missing shareID")
|
if (!params.shareID) throw new Error("Missing shareID")
|
||||||
const now = Date.now()
|
return getData(params.shareID)
|
||||||
const data = getData(params.shareID)
|
|
||||||
console.log("getData", Date.now() - now)
|
|
||||||
return data
|
|
||||||
})
|
|
||||||
|
|
||||||
createEffect(() => {
|
|
||||||
console.log(data())
|
|
||||||
})
|
})
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -241,22 +204,8 @@ export default function () {
|
|||||||
const provider = createMemo(() => activeMessage()?.model?.providerID)
|
const provider = createMemo(() => activeMessage()?.model?.providerID)
|
||||||
const modelID = createMemo(() => activeMessage()?.model?.modelID)
|
const modelID = createMemo(() => activeMessage()?.model?.modelID)
|
||||||
const model = createMemo(() => data().model[data().sessionID]?.find((m) => m.id === modelID()))
|
const model = createMemo(() => data().model[data().sessionID]?.find((m) => m.id === modelID()))
|
||||||
const diffs = createMemo(() => {
|
const diffs = createMemo(() => data().session_diff[data().sessionID] ?? [])
|
||||||
const diffs = data().session_diff[data().sessionID] ?? []
|
const [diffStyle, setDiffStyle] = createSignal<"unified" | "split">("unified")
|
||||||
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 title = () => (
|
const title = () => (
|
||||||
<div class="flex flex-col gap-4">
|
<div class="flex flex-col gap-4">
|
||||||
@@ -380,18 +329,9 @@ export default function () {
|
|||||||
<Show when={diffs().length > 0}>
|
<Show when={diffs().length > 0}>
|
||||||
<div class="@container relative grow pt-14 flex-1 min-h-0 border-l border-border-weak-base">
|
<div class="@container relative grow pt-14 flex-1 min-h-0 border-l border-border-weak-base">
|
||||||
<SessionReview
|
<SessionReview
|
||||||
class="@4xl:hidden"
|
|
||||||
diffs={diffs()}
|
diffs={diffs()}
|
||||||
classes={{
|
diffStyle={diffStyle()}
|
||||||
root: "pb-20",
|
onDiffStyleChange={setDiffStyle}
|
||||||
header: "px-6",
|
|
||||||
container: "px-6",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<SessionReview
|
|
||||||
split
|
|
||||||
class="hidden @4xl:flex"
|
|
||||||
diffs={splitDiffs()}
|
|
||||||
classes={{
|
classes={{
|
||||||
root: "pb-20",
|
root: "pb-20",
|
||||||
header: "px-6",
|
header: "px-6",
|
||||||
@@ -419,11 +359,7 @@ export default function () {
|
|||||||
<Tabs.Content value="session" class="!overflow-hidden">
|
<Tabs.Content value="session" class="!overflow-hidden">
|
||||||
{turns()}
|
{turns()}
|
||||||
</Tabs.Content>
|
</Tabs.Content>
|
||||||
<Tabs.Content
|
<Tabs.Content value="review" class="!overflow-hidden hidden data-[selected]:block">
|
||||||
forceMount
|
|
||||||
value="review"
|
|
||||||
class="!overflow-hidden hidden data-[selected]:block"
|
|
||||||
>
|
|
||||||
<div class="relative h-full pt-8 overflow-y-auto no-scrollbar">
|
<div class="relative h-full pt-8 overflow-y-auto no-scrollbar">
|
||||||
<SessionReview
|
<SessionReview
|
||||||
diffs={diffs()}
|
diffs={diffs()}
|
||||||
|
|||||||
@@ -30,8 +30,8 @@ describe.concurrent("core.share", () => {
|
|||||||
data,
|
data,
|
||||||
})
|
})
|
||||||
|
|
||||||
const events = await Storage.list({ prefix: ["share_event", share.id] })
|
const snapshot = await Storage.read<{ data: Share.Data[] }>(["share_snapshot", share.id])
|
||||||
expect(events.length).toBe(1)
|
expect(snapshot?.data).toHaveLength(1)
|
||||||
|
|
||||||
await Share.remove({ id: share.id, secret: share.secret })
|
await Share.remove({ id: share.id, secret: share.secret })
|
||||||
})
|
})
|
||||||
@@ -64,8 +64,8 @@ describe.concurrent("core.share", () => {
|
|||||||
data: data2,
|
data: data2,
|
||||||
})
|
})
|
||||||
|
|
||||||
const events = await Storage.list({ prefix: ["share_event", share.id] })
|
const snapshot = await Storage.read<{ data: Share.Data[] }>(["share_snapshot", share.id])
|
||||||
expect(events.length).toBe(2)
|
expect(snapshot?.data).toHaveLength(2)
|
||||||
|
|
||||||
await Share.remove({ id: share.id, secret: share.secret })
|
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 })
|
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 () => {
|
test("should throw error for invalid secret", async () => {
|
||||||
const sessionID = Identifier.descending()
|
const sessionID = Identifier.descending()
|
||||||
const share = await Share.create({ sessionID })
|
const share = await Share.create({ sessionID })
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
id = "opencode"
|
id = "opencode"
|
||||||
name = "OpenCode"
|
name = "OpenCode"
|
||||||
description = "The open source coding agent."
|
description = "The open source coding agent."
|
||||||
version = "1.2.17"
|
version = "1.2.20"
|
||||||
schema_version = 1
|
schema_version = 1
|
||||||
authors = ["Anomaly"]
|
authors = ["Anomaly"]
|
||||||
repository = "https://github.com/anomalyco/opencode"
|
repository = "https://github.com/anomalyco/opencode"
|
||||||
@@ -11,26 +11,26 @@ name = "OpenCode"
|
|||||||
icon = "./icons/opencode.svg"
|
icon = "./icons/opencode.svg"
|
||||||
|
|
||||||
[agent_servers.opencode.targets.darwin-aarch64]
|
[agent_servers.opencode.targets.darwin-aarch64]
|
||||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.2.17/opencode-darwin-arm64.zip"
|
archive = "https://github.com/anomalyco/opencode/releases/download/v1.2.20/opencode-darwin-arm64.zip"
|
||||||
cmd = "./opencode"
|
cmd = "./opencode"
|
||||||
args = ["acp"]
|
args = ["acp"]
|
||||||
|
|
||||||
[agent_servers.opencode.targets.darwin-x86_64]
|
[agent_servers.opencode.targets.darwin-x86_64]
|
||||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.2.17/opencode-darwin-x64.zip"
|
archive = "https://github.com/anomalyco/opencode/releases/download/v1.2.20/opencode-darwin-x64.zip"
|
||||||
cmd = "./opencode"
|
cmd = "./opencode"
|
||||||
args = ["acp"]
|
args = ["acp"]
|
||||||
|
|
||||||
[agent_servers.opencode.targets.linux-aarch64]
|
[agent_servers.opencode.targets.linux-aarch64]
|
||||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.2.17/opencode-linux-arm64.tar.gz"
|
archive = "https://github.com/anomalyco/opencode/releases/download/v1.2.20/opencode-linux-arm64.tar.gz"
|
||||||
cmd = "./opencode"
|
cmd = "./opencode"
|
||||||
args = ["acp"]
|
args = ["acp"]
|
||||||
|
|
||||||
[agent_servers.opencode.targets.linux-x86_64]
|
[agent_servers.opencode.targets.linux-x86_64]
|
||||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.2.17/opencode-linux-x64.tar.gz"
|
archive = "https://github.com/anomalyco/opencode/releases/download/v1.2.20/opencode-linux-x64.tar.gz"
|
||||||
cmd = "./opencode"
|
cmd = "./opencode"
|
||||||
args = ["acp"]
|
args = ["acp"]
|
||||||
|
|
||||||
[agent_servers.opencode.targets.windows-x86_64]
|
[agent_servers.opencode.targets.windows-x86_64]
|
||||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.2.17/opencode-windows-x64.zip"
|
archive = "https://github.com/anomalyco/opencode/releases/download/v1.2.20/opencode-windows-x64.zip"
|
||||||
cmd = "./opencode.exe"
|
cmd = "./opencode.exe"
|
||||||
args = ["acp"]
|
args = ["acp"]
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@opencode-ai/function",
|
"name": "@opencode-ai/function",
|
||||||
"version": "1.2.17",
|
"version": "1.2.20",
|
||||||
"$schema": "https://json.schemastore.org/package.json",
|
"$schema": "https://json.schemastore.org/package.json",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://json.schemastore.org/package.json",
|
"$schema": "https://json.schemastore.org/package.json",
|
||||||
"version": "1.2.17",
|
"version": "1.2.20",
|
||||||
"name": "opencode",
|
"name": "opencode",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
@@ -43,9 +43,10 @@
|
|||||||
"@types/mime-types": "3.0.1",
|
"@types/mime-types": "3.0.1",
|
||||||
"@types/turndown": "5.0.5",
|
"@types/turndown": "5.0.5",
|
||||||
"@types/yargs": "17.0.33",
|
"@types/yargs": "17.0.33",
|
||||||
|
"@types/which": "3.0.4",
|
||||||
"@typescript/native-preview": "catalog:",
|
"@typescript/native-preview": "catalog:",
|
||||||
"drizzle-kit": "1.0.0-beta.12-a5629fb",
|
"drizzle-kit": "1.0.0-beta.16-ea816b6",
|
||||||
"drizzle-orm": "1.0.0-beta.12-a5629fb",
|
"drizzle-orm": "1.0.0-beta.16-ea816b6",
|
||||||
"typescript": "catalog:",
|
"typescript": "catalog:",
|
||||||
"vscode-languageserver-types": "3.17.5",
|
"vscode-languageserver-types": "3.17.5",
|
||||||
"why-is-node-running": "3.2.2",
|
"why-is-node-running": "3.2.2",
|
||||||
@@ -105,7 +106,7 @@
|
|||||||
"clipboardy": "4.0.0",
|
"clipboardy": "4.0.0",
|
||||||
"decimal.js": "10.5.0",
|
"decimal.js": "10.5.0",
|
||||||
"diff": "catalog:",
|
"diff": "catalog:",
|
||||||
"drizzle-orm": "1.0.0-beta.12-a5629fb",
|
"drizzle-orm": "1.0.0-beta.16-ea816b6",
|
||||||
"fuzzysort": "3.1.0",
|
"fuzzysort": "3.1.0",
|
||||||
"glob": "13.0.5",
|
"glob": "13.0.5",
|
||||||
"google-auth-library": "10.5.0",
|
"google-auth-library": "10.5.0",
|
||||||
@@ -127,12 +128,13 @@
|
|||||||
"ulid": "catalog:",
|
"ulid": "catalog:",
|
||||||
"vscode-jsonrpc": "8.2.1",
|
"vscode-jsonrpc": "8.2.1",
|
||||||
"web-tree-sitter": "0.25.10",
|
"web-tree-sitter": "0.25.10",
|
||||||
|
"which": "6.0.1",
|
||||||
"xdg-basedir": "5.1.0",
|
"xdg-basedir": "5.1.0",
|
||||||
"yargs": "18.0.0",
|
"yargs": "18.0.0",
|
||||||
"zod": "catalog:",
|
"zod": "catalog:",
|
||||||
"zod-to-json-schema": "3.24.5"
|
"zod-to-json-schema": "3.24.5"
|
||||||
},
|
},
|
||||||
"overrides": {
|
"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 fs from "fs"
|
||||||
import path from "path"
|
import path from "path"
|
||||||
import { fileURLToPath } from "url"
|
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 __filename = fileURLToPath(import.meta.url)
|
||||||
const __dirname = path.dirname(__filename)
|
const __dirname = path.dirname(__filename)
|
||||||
@@ -51,7 +51,7 @@ const migrations = await Promise.all(
|
|||||||
Number(match[6]),
|
Number(match[6]),
|
||||||
)
|
)
|
||||||
: 0
|
: 0
|
||||||
return { sql, timestamp }
|
return { sql, timestamp, name }
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
console.log(`Loaded ${migrations.length} migrations`)
|
console.log(`Loaded ${migrations.length} migrations`)
|
||||||
@@ -161,7 +161,9 @@ for (const item of targets) {
|
|||||||
console.log(`building ${name}`)
|
console.log(`building ${name}`)
|
||||||
await $`mkdir -p dist/${name}/bin`
|
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"
|
const workerPath = "./src/cli/cmd/tui/worker.ts"
|
||||||
|
|
||||||
// Use platform-specific bunfs root path based on target OS
|
// Use platform-specific bunfs root path based on target OS
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ import {
|
|||||||
import { Log } from "../util/log"
|
import { Log } from "../util/log"
|
||||||
import { pathToFileURL } from "bun"
|
import { pathToFileURL } from "bun"
|
||||||
import { Filesystem } from "../util/filesystem"
|
import { Filesystem } from "../util/filesystem"
|
||||||
|
import { Hash } from "../util/hash"
|
||||||
import { ACPSessionManager } from "./session"
|
import { ACPSessionManager } from "./session"
|
||||||
import type { ACPConfig } from "./types"
|
import type { ACPConfig } from "./types"
|
||||||
import { Provider } from "../provider/provider"
|
import { Provider } from "../provider/provider"
|
||||||
@@ -281,7 +282,7 @@ export namespace ACP {
|
|||||||
const output = this.bashOutput(part)
|
const output = this.bashOutput(part)
|
||||||
const content: ToolCallContent[] = []
|
const content: ToolCallContent[] = []
|
||||||
if (output) {
|
if (output) {
|
||||||
const hash = String(Bun.hash(output))
|
const hash = Hash.fast(output)
|
||||||
if (part.tool === "bash") {
|
if (part.tool === "bash") {
|
||||||
if (this.bashSnapshots.get(part.callID) === hash) {
|
if (this.bashSnapshots.get(part.callID) === hash) {
|
||||||
await this.connection
|
await this.connection
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import { Instance } from "../../project/instance"
|
|||||||
import type { Hooks } from "@opencode-ai/plugin"
|
import type { Hooks } from "@opencode-ai/plugin"
|
||||||
import { Process } from "../../util/process"
|
import { Process } from "../../util/process"
|
||||||
import { text } from "node:stream/consumers"
|
import { text } from "node:stream/consumers"
|
||||||
|
import { setTimeout as sleep } from "node:timers/promises"
|
||||||
|
|
||||||
type PluginAuth = NonNullable<Hooks["auth"]>
|
type PluginAuth = NonNullable<Hooks["auth"]>
|
||||||
|
|
||||||
@@ -47,7 +48,7 @@ async function handlePluginAuth(plugin: { auth: PluginAuth }, provider: string,
|
|||||||
const method = plugin.auth.methods[index]
|
const method = plugin.auth.methods[index]
|
||||||
|
|
||||||
// Handle prompts for all auth types
|
// Handle prompts for all auth types
|
||||||
await Bun.sleep(10)
|
await sleep(10)
|
||||||
const inputs: Record<string, string> = {}
|
const inputs: Record<string, string> = {}
|
||||||
if (method.prompts) {
|
if (method.prompts) {
|
||||||
for (const prompt of method.prompts) {
|
for (const prompt of method.prompts) {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { bootstrap } from "../../bootstrap"
|
|||||||
import { cmd } from "../cmd"
|
import { cmd } from "../cmd"
|
||||||
import { Log } from "../../../util/log"
|
import { Log } from "../../../util/log"
|
||||||
import { EOL } from "os"
|
import { EOL } from "os"
|
||||||
|
import { setTimeout as sleep } from "node:timers/promises"
|
||||||
|
|
||||||
export const LSPCommand = cmd({
|
export const LSPCommand = cmd({
|
||||||
command: "lsp",
|
command: "lsp",
|
||||||
@@ -19,7 +20,7 @@ const DiagnosticsCommand = cmd({
|
|||||||
async handler(args) {
|
async handler(args) {
|
||||||
await bootstrap(process.cwd(), async () => {
|
await bootstrap(process.cwd(), async () => {
|
||||||
await LSP.touchFile(args.file, true)
|
await LSP.touchFile(args.file, true)
|
||||||
await Bun.sleep(1000)
|
await sleep(1000)
|
||||||
process.stdout.write(JSON.stringify(await LSP.diagnostics(), null, 2) + EOL)
|
process.stdout.write(JSON.stringify(await LSP.diagnostics(), null, 2) + EOL)
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ import { Bus } from "../../bus"
|
|||||||
import { MessageV2 } from "../../session/message-v2"
|
import { MessageV2 } from "../../session/message-v2"
|
||||||
import { SessionPrompt } from "@/session/prompt"
|
import { SessionPrompt } from "@/session/prompt"
|
||||||
import { $ } from "bun"
|
import { $ } from "bun"
|
||||||
|
import { setTimeout as sleep } from "node:timers/promises"
|
||||||
|
|
||||||
type GitHubAuthor = {
|
type GitHubAuthor = {
|
||||||
login: string
|
login: string
|
||||||
@@ -353,7 +354,7 @@ export const GithubInstallCommand = cmd({
|
|||||||
}
|
}
|
||||||
|
|
||||||
retries++
|
retries++
|
||||||
await Bun.sleep(1000)
|
await sleep(1000)
|
||||||
} while (true)
|
} while (true)
|
||||||
|
|
||||||
s.stop("Installed GitHub app")
|
s.stop("Installed GitHub app")
|
||||||
@@ -1372,7 +1373,7 @@ Co-authored-by: ${actor} <${actor}@users.noreply.github.com>"`
|
|||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (retries > 0) {
|
if (retries > 0) {
|
||||||
console.log(`Retrying after ${delayMs}ms...`)
|
console.log(`Retrying after ${delayMs}ms...`)
|
||||||
await Bun.sleep(delayMs)
|
await sleep(delayMs)
|
||||||
return withRetry(fn, retries - 1, delayMs)
|
return withRetry(fn, retries - 1, delayMs)
|
||||||
}
|
}
|
||||||
throw e
|
throw e
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { Filesystem } from "../../util/filesystem"
|
|||||||
import { Process } from "../../util/process"
|
import { Process } from "../../util/process"
|
||||||
import { EOL } from "os"
|
import { EOL } from "os"
|
||||||
import path from "path"
|
import path from "path"
|
||||||
|
import { which } from "../../util/which"
|
||||||
|
|
||||||
function pagerCmd(): string[] {
|
function pagerCmd(): string[] {
|
||||||
const lessOptions = ["-R", "-S"]
|
const lessOptions = ["-R", "-S"]
|
||||||
@@ -17,7 +18,7 @@ function pagerCmd(): string[] {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// user could have less installed via other options
|
// user could have less installed via other options
|
||||||
const lessOnPath = Bun.which("less")
|
const lessOnPath = which("less")
|
||||||
if (lessOnPath) {
|
if (lessOnPath) {
|
||||||
if (Filesystem.stat(lessOnPath)?.size) return [lessOnPath, ...lessOptions]
|
if (Filesystem.stat(lessOnPath)?.size) return [lessOnPath, ...lessOptions]
|
||||||
}
|
}
|
||||||
@@ -27,7 +28,7 @@ function pagerCmd(): string[] {
|
|||||||
if (Filesystem.stat(less)?.size) return [less, ...lessOptions]
|
if (Filesystem.stat(less)?.size) return [less, ...lessOptions]
|
||||||
}
|
}
|
||||||
|
|
||||||
const git = Bun.which("git")
|
const git = which("git")
|
||||||
if (git) {
|
if (git) {
|
||||||
const less = path.join(git, "..", "..", "usr", "bin", "less.exe")
|
const less = path.join(git, "..", "..", "usr", "bin", "less.exe")
|
||||||
if (Filesystem.stat(less)?.size) return [less, ...lessOptions]
|
if (Filesystem.stat(less)?.size) return [less, ...lessOptions]
|
||||||
|
|||||||
@@ -111,8 +111,10 @@ export const TuiThreadCommand = cmd({
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Resolve relative paths against PWD to preserve behavior when using --cwd flag
|
// Resolve relative paths against PWD to preserve behavior when using --cwd flag
|
||||||
const root = process.env.PWD ?? process.cwd()
|
const root = Filesystem.resolve(process.env.PWD ?? process.cwd())
|
||||||
const cwd = args.project ? path.resolve(root, args.project) : process.cwd()
|
const cwd = args.project
|
||||||
|
? Filesystem.resolve(path.isAbsolute(args.project) ? args.project : path.join(root, args.project))
|
||||||
|
: root
|
||||||
const file = await target()
|
const file = await target()
|
||||||
try {
|
try {
|
||||||
process.chdir(cwd)
|
process.chdir(cwd)
|
||||||
|
|||||||
@@ -56,7 +56,7 @@ export function DialogExportOptions(props: DialogExportOptionsProps) {
|
|||||||
setStore("active", order[nextIndex])
|
setStore("active", order[nextIndex])
|
||||||
evt.preventDefault()
|
evt.preventDefault()
|
||||||
}
|
}
|
||||||
if (evt.name === "space") {
|
if (evt.name === "space" || evt.name === " ") {
|
||||||
if (store.active === "thinking") setStore("thinking", !store.thinking)
|
if (store.active === "thinking") setStore("thinking", !store.thinking)
|
||||||
if (store.active === "toolDetails") setStore("toolDetails", !store.toolDetails)
|
if (store.active === "toolDetails") setStore("toolDetails", !store.toolDetails)
|
||||||
if (store.active === "assistantMetadata") setStore("assistantMetadata", !store.assistantMetadata)
|
if (store.active === "assistantMetadata") setStore("assistantMetadata", !store.assistantMetadata)
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { tmpdir } from "os"
|
|||||||
import path from "path"
|
import path from "path"
|
||||||
import { Filesystem } from "../../../../util/filesystem"
|
import { Filesystem } from "../../../../util/filesystem"
|
||||||
import { Process } from "../../../../util/process"
|
import { Process } from "../../../../util/process"
|
||||||
|
import { which } from "../../../../util/which"
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Writes text to clipboard via OSC 52 escape sequence.
|
* Writes text to clipboard via OSC 52 escape sequence.
|
||||||
@@ -76,7 +77,7 @@ export namespace Clipboard {
|
|||||||
const getCopyMethod = lazy(() => {
|
const getCopyMethod = lazy(() => {
|
||||||
const os = platform()
|
const os = platform()
|
||||||
|
|
||||||
if (os === "darwin" && Bun.which("osascript")) {
|
if (os === "darwin" && which("osascript")) {
|
||||||
console.log("clipboard: using osascript")
|
console.log("clipboard: using osascript")
|
||||||
return async (text: string) => {
|
return async (text: string) => {
|
||||||
const escaped = text.replace(/\\/g, "\\\\").replace(/"/g, '\\"')
|
const escaped = text.replace(/\\/g, "\\\\").replace(/"/g, '\\"')
|
||||||
@@ -85,7 +86,7 @@ export namespace Clipboard {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (os === "linux") {
|
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")
|
console.log("clipboard: using wl-copy")
|
||||||
return async (text: string) => {
|
return async (text: string) => {
|
||||||
const proc = Process.spawn(["wl-copy"], { stdin: "pipe", stdout: "ignore", stderr: "ignore" })
|
const proc = Process.spawn(["wl-copy"], { stdin: "pipe", stdout: "ignore", stderr: "ignore" })
|
||||||
@@ -95,7 +96,7 @@ export namespace Clipboard {
|
|||||||
await proc.exited.catch(() => {})
|
await proc.exited.catch(() => {})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (Bun.which("xclip")) {
|
if (which("xclip")) {
|
||||||
console.log("clipboard: using xclip")
|
console.log("clipboard: using xclip")
|
||||||
return async (text: string) => {
|
return async (text: string) => {
|
||||||
const proc = Process.spawn(["xclip", "-selection", "clipboard"], {
|
const proc = Process.spawn(["xclip", "-selection", "clipboard"], {
|
||||||
@@ -109,7 +110,7 @@ export namespace Clipboard {
|
|||||||
await proc.exited.catch(() => {})
|
await proc.exited.catch(() => {})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (Bun.which("xsel")) {
|
if (which("xsel")) {
|
||||||
console.log("clipboard: using xsel")
|
console.log("clipboard: using xsel")
|
||||||
return async (text: string) => {
|
return async (text: string) => {
|
||||||
const proc = Process.spawn(["xsel", "--clipboard", "--input"], {
|
const proc = Process.spawn(["xsel", "--clipboard", "--input"], {
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user