Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
70eaf3545e | ||
|
|
664f4c0a68 |
@@ -1,38 +0,0 @@
|
|||||||
name: storybook
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches: [dev]
|
|
||||||
paths:
|
|
||||||
- ".github/workflows/storybook.yml"
|
|
||||||
- "package.json"
|
|
||||||
- "bun.lock"
|
|
||||||
- "packages/storybook/**"
|
|
||||||
- "packages/ui/**"
|
|
||||||
pull_request:
|
|
||||||
branches: [dev]
|
|
||||||
paths:
|
|
||||||
- ".github/workflows/storybook.yml"
|
|
||||||
- "package.json"
|
|
||||||
- "bun.lock"
|
|
||||||
- "packages/storybook/**"
|
|
||||||
- "packages/ui/**"
|
|
||||||
workflow_dispatch:
|
|
||||||
|
|
||||||
concurrency:
|
|
||||||
group: ${{ github.workflow }}-${{ github.ref }}
|
|
||||||
cancel-in-progress: true
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
build:
|
|
||||||
name: storybook build
|
|
||||||
runs-on: blacksmith-4vcpu-ubuntu-2404
|
|
||||||
steps:
|
|
||||||
- name: Checkout repository
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Setup Bun
|
|
||||||
uses: ./.github/actions/setup-bun
|
|
||||||
|
|
||||||
- name: Build Storybook
|
|
||||||
run: bun --cwd packages/storybook build
|
|
||||||
@@ -5,11 +5,6 @@
|
|||||||
"options": {},
|
"options": {},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
"permission": {
|
|
||||||
"edit": {
|
|
||||||
"packages/opencode/migration/*": "deny",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
"mcp": {},
|
"mcp": {},
|
||||||
"tools": {
|
"tools": {
|
||||||
"github-triage": false,
|
"github-triage": false,
|
||||||
|
|||||||
@@ -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 anomalyco/opencode repository and returns LLM-friendly results including:
|
This tool searches PRs in the sst/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)
|
||||||
|
|||||||
+1
-2
@@ -35,8 +35,7 @@
|
|||||||
<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)
|
||||||
|
|||||||
+1
-2
@@ -35,8 +35,7 @@
|
|||||||
<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)
|
||||||
|
|||||||
+1
-3
@@ -27,7 +27,6 @@
|
|||||||
<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> |
|
||||||
@@ -35,8 +34,7 @@
|
|||||||
<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)
|
||||||
|
|||||||
+1
-2
@@ -35,8 +35,7 @@
|
|||||||
<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)
|
||||||
|
|||||||
+1
-3
@@ -27,7 +27,6 @@
|
|||||||
<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> |
|
||||||
@@ -35,8 +34,7 @@
|
|||||||
<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)
|
||||||
|
|||||||
+1
-3
@@ -27,7 +27,6 @@
|
|||||||
<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> |
|
||||||
@@ -35,8 +34,7 @@
|
|||||||
<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)
|
||||||
|
|||||||
+1
-3
@@ -27,7 +27,6 @@
|
|||||||
<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> |
|
||||||
@@ -35,8 +34,7 @@
|
|||||||
<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)
|
||||||
|
|||||||
+1
-3
@@ -27,7 +27,6 @@
|
|||||||
<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> |
|
||||||
@@ -35,8 +34,7 @@
|
|||||||
<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)
|
||||||
|
|||||||
+1
-2
@@ -35,8 +35,7 @@
|
|||||||
<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)
|
||||||
|
|||||||
+1
-3
@@ -27,7 +27,6 @@
|
|||||||
<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> |
|
||||||
@@ -35,8 +34,7 @@
|
|||||||
<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)
|
||||||
|
|||||||
+1
-3
@@ -27,7 +27,6 @@
|
|||||||
<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> |
|
||||||
@@ -35,8 +34,7 @@
|
|||||||
<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)
|
||||||
|
|||||||
+1
-3
@@ -27,7 +27,6 @@
|
|||||||
<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> |
|
||||||
@@ -35,8 +34,7 @@
|
|||||||
<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,8 +35,7 @@
|
|||||||
<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)
|
||||||
|
|||||||
+1
-3
@@ -27,7 +27,6 @@
|
|||||||
<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> |
|
||||||
@@ -35,8 +34,7 @@
|
|||||||
<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)
|
||||||
|
|||||||
+1
-3
@@ -27,7 +27,6 @@
|
|||||||
<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> |
|
||||||
@@ -35,8 +34,7 @@
|
|||||||
<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)
|
||||||
|
|||||||
+1
-3
@@ -27,7 +27,6 @@
|
|||||||
<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> |
|
||||||
@@ -35,8 +34,7 @@
|
|||||||
<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)
|
||||||
|
|||||||
+1
-3
@@ -27,7 +27,6 @@
|
|||||||
<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> |
|
||||||
@@ -35,8 +34,7 @@
|
|||||||
<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)
|
||||||
|
|||||||
+1
-3
@@ -27,7 +27,6 @@
|
|||||||
<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> |
|
||||||
@@ -35,8 +34,7 @@
|
|||||||
<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)
|
||||||
|
|||||||
+1
-2
@@ -35,8 +35,7 @@
|
|||||||
<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
@@ -1,141 +0,0 @@
|
|||||||
<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)
|
|
||||||
+1
-3
@@ -27,7 +27,6 @@
|
|||||||
<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> |
|
||||||
@@ -35,8 +34,7 @@
|
|||||||
<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)
|
||||||
|
|||||||
+1
-3
@@ -27,7 +27,6 @@
|
|||||||
<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> |
|
||||||
@@ -35,8 +34,7 @@
|
|||||||
<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)
|
||||||
|
|||||||
+1
-2
@@ -8,7 +8,6 @@ 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
|
||||||
@@ -282,7 +281,7 @@ async function assertOpencodeConnected() {
|
|||||||
connected = true
|
connected = true
|
||||||
break
|
break
|
||||||
} catch (e) {}
|
} catch (e) {}
|
||||||
await sleep(300)
|
await Bun.sleep(300)
|
||||||
} while (retry++ < 30)
|
} while (retry++ < 30)
|
||||||
|
|
||||||
if (!connected) {
|
if (!connected) {
|
||||||
|
|||||||
+4
-4
@@ -1,8 +1,8 @@
|
|||||||
{
|
{
|
||||||
"nodeModules": {
|
"nodeModules": {
|
||||||
"x86_64-linux": "sha256-4kjoJ06VNvHltPHfzQRBG0bC6R39jao10ffGzrNZ230=",
|
"x86_64-linux": "sha256-ZmxeRNy2chc9py4m1iW6B+c/NSccMnVZ0lfni/EMdHw=",
|
||||||
"aarch64-linux": "sha256-6Uio+S2rcyBWbBEeOZb9N1CCKgkbKi68lOIKi3Ws/pQ=",
|
"aarch64-linux": "sha256-R+1mxsmAQicerN8ixVy0ff6V8bZ4GH18MHpihvWnaTg=",
|
||||||
"aarch64-darwin": "sha256-8ngN5KVN4vhdsk0QJ11BGgSVBrcaEbwSj23c77HBpgs=",
|
"aarch64-darwin": "sha256-m+QT20ohlqo9e86qXu67eKthZm6VDRLwlqJ9CNlEV+0=",
|
||||||
"x86_64-darwin": "sha256-v/ueYGb9a0Nymzy+mkO4uQr78DAuJnES1qOT0onFgnQ="
|
"x86_64-darwin": "sha256-4GeNPyTT2Hq4rxHGSON23ul5Ud3yFGE0QUVsB03Gidc="
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+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.16-ea816b6",
|
"drizzle-kit": "1.0.0-beta.12-a5629fb",
|
||||||
"drizzle-orm": "1.0.0-beta.16-ea816b6",
|
"drizzle-orm": "1.0.0-beta.12-a5629fb",
|
||||||
"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,12 +71,6 @@ 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
|
|
||||||
- `sessionIDFromUrl(url)` - Read session ID from URL
|
|
||||||
- `slugFromUrl(url)` - Read workspace slug from URL
|
|
||||||
- `waitSlug(page, skip?)` - Wait for resolved workspace slug
|
|
||||||
- `trackSession(sessionID, directory?)` - Register session for fixture cleanup
|
|
||||||
- `trackDirectory(directory)` - Register directory for fixture cleanup
|
|
||||||
- `clickListItem(container, filter)` - Click list item by key/text
|
- `clickListItem(container, filter)` - Click list item by key/text
|
||||||
|
|
||||||
**Selectors** (`selectors.ts`):
|
**Selectors** (`selectors.ts`):
|
||||||
@@ -115,7 +109,7 @@ import { test, expect } from "@playwright/test"
|
|||||||
|
|
||||||
### Error Handling
|
### Error Handling
|
||||||
|
|
||||||
Tests should clean up after themselves. Prefer fixture-managed cleanup:
|
Tests should clean up after themselves:
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
test("test with cleanup", async ({ page, sdk, gotoSession }) => {
|
test("test with cleanup", async ({ page, sdk, gotoSession }) => {
|
||||||
@@ -126,11 +120,6 @@ 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:
|
||||||
@@ -172,10 +161,9 @@ await page.keyboard.press(`${modKey}+Comma`) // Open settings
|
|||||||
1. Choose appropriate folder or create new one
|
1. Choose appropriate folder or create new one
|
||||||
2. Import from `../fixtures`
|
2. Import from `../fixtures`
|
||||||
3. Use helper functions from `../actions` and `../selectors`
|
3. Use helper functions from `../actions` and `../selectors`
|
||||||
4. When validating routing, use shared helpers from `../actions`. Workspace URL slugs can be canonicalized on Windows, so assert against canonical or resolved workspace slugs.
|
4. Clean up any created resources
|
||||||
5. Clean up any created resources
|
5. Use specific selectors (avoid CSS classes)
|
||||||
6. Use specific selectors (avoid CSS classes)
|
6. Test one feature per test file
|
||||||
7. Test one feature per test file
|
|
||||||
|
|
||||||
## Local Development
|
## Local Development
|
||||||
|
|
||||||
|
|||||||
+50
-185
@@ -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 { createSdk, modKey, resolveDirectory, serverUrl } from "./utils"
|
import { modKey, serverUrl } from "./utils"
|
||||||
import {
|
import {
|
||||||
|
sessionItemSelector,
|
||||||
dropdownMenuTriggerSelector,
|
dropdownMenuTriggerSelector,
|
||||||
dropdownMenuContentSelector,
|
dropdownMenuContentSelector,
|
||||||
projectMenuTriggerSelector,
|
projectMenuTriggerSelector,
|
||||||
projectCloseMenuSelector,
|
|
||||||
projectWorkspacesToggleSelector,
|
projectWorkspacesToggleSelector,
|
||||||
titlebarRightSelector,
|
titlebarRightSelector,
|
||||||
popoverBodySelector,
|
popoverBodySelector,
|
||||||
@@ -18,6 +18,7 @@ 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
|
||||||
@@ -60,9 +61,9 @@ export async function closeDialog(page: Page, dialog: Locator) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function isSidebarClosed(page: Page) {
|
export async function isSidebarClosed(page: Page) {
|
||||||
const button = page.getByRole("button", { name: /toggle sidebar/i }).first()
|
const main = page.locator("main")
|
||||||
await expect(button).toBeVisible()
|
const classes = (await main.getAttribute("class")) ?? ""
|
||||||
return (await button.getAttribute("aria-expanded")) !== "true"
|
return classes.includes("xl:border-l")
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function toggleSidebar(page: Page) {
|
export async function toggleSidebar(page: Page) {
|
||||||
@@ -74,34 +75,48 @@ 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()
|
||||||
await button.click()
|
const visible = await button
|
||||||
|
.isVisible()
|
||||||
|
.then((x) => x)
|
||||||
|
.catch(() => false)
|
||||||
|
|
||||||
const opened = await expect(button)
|
if (visible) await button.click()
|
||||||
.toHaveAttribute("aria-expanded", "true", { timeout: 1500 })
|
if (!visible) await toggleSidebar(page)
|
||||||
|
|
||||||
|
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(button).toHaveAttribute("aria-expanded", "true")
|
await expect(main).not.toHaveClass(/xl:border-l/)
|
||||||
}
|
}
|
||||||
|
|
||||||
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()
|
||||||
await button.click()
|
const visible = await button
|
||||||
|
.isVisible()
|
||||||
|
.then((x) => x)
|
||||||
|
.catch(() => false)
|
||||||
|
|
||||||
const closed = await expect(button)
|
if (visible) await button.click()
|
||||||
.toHaveAttribute("aria-expanded", "false", { timeout: 1500 })
|
if (!visible) await toggleSidebar(page)
|
||||||
|
|
||||||
|
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(button).toHaveAttribute("aria-expanded", "false")
|
await expect(main).toHaveClass(/xl:border-l/)
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function openSettings(page: Page) {
|
export async function openSettings(page: Page) {
|
||||||
@@ -182,48 +197,17 @@ 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 resolveDirectory(root)
|
return root
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function cleanupTestProject(directory: string) {
|
export async function cleanupTestProject(directory: string) {
|
||||||
try {
|
await fs.rm(directory, { recursive: true, force: true }).catch(() => undefined)
|
||||||
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 slugFromUrl(url: string) {
|
|
||||||
return /\/([^/]+)\/session(?:[/?#]|$)/.exec(url)?.[1] ?? ""
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function waitSlug(page: Page, skip: string[] = []) {
|
|
||||||
let prev = ""
|
|
||||||
let next = ""
|
|
||||||
await expect
|
|
||||||
.poll(
|
|
||||||
() => {
|
|
||||||
const slug = slugFromUrl(page.url())
|
|
||||||
if (!slug) return ""
|
|
||||||
if (skip.includes(slug)) return ""
|
|
||||||
if (slug !== prev) {
|
|
||||||
prev = slug
|
|
||||||
next = ""
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
next = slug
|
|
||||||
return slug
|
|
||||||
},
|
|
||||||
{ timeout: 45_000 },
|
|
||||||
)
|
|
||||||
.not.toBe("")
|
|
||||||
return next
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function sessionIDFromUrl(url: string) {
|
export function sessionIDFromUrl(url: string) {
|
||||||
@@ -232,7 +216,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(`[data-session-id="${sessionID}"]`).last()
|
const sessionEl = page.locator(sessionItemSelector(sessionID)).first()
|
||||||
await expect(sessionEl).toBeVisible()
|
await expect(sessionEl).toBeVisible()
|
||||||
await sessionEl.hover()
|
await sessionEl.hover()
|
||||||
return sessionEl
|
return sessionEl
|
||||||
@@ -333,57 +317,6 @@ 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,
|
||||||
@@ -395,7 +328,7 @@ export async function withSession<T>(
|
|||||||
try {
|
try {
|
||||||
return await callback(session)
|
return await callback(session)
|
||||||
} finally {
|
} finally {
|
||||||
await cleanupSession({ sdk, sessionID: session.id })
|
await sdk.session.delete({ sessionID: session.id }).catch(() => undefined)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -508,57 +441,6 @@ 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: {
|
||||||
@@ -633,42 +515,32 @@ 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) {
|
||||||
await expect(close).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
|
return menu
|
||||||
}
|
}
|
||||||
|
|
||||||
throw new Error(`Failed to open project menu: ${projectSlug}`)
|
await trigger.click({ force: true })
|
||||||
|
|
||||||
|
await expect(menu).toBeVisible()
|
||||||
|
|
||||||
|
const viewport = page.viewportSize()
|
||||||
|
const x = viewport ? Math.max(viewport.width - 5, 0) : 1200
|
||||||
|
const y = viewport ? Math.max(viewport.height - 5, 0) : 800
|
||||||
|
await page.mouse.move(x, y)
|
||||||
|
return menu
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function setWorkspacesEnabled(page: Page, projectSlug: string, enabled: boolean) {
|
export async function setWorkspacesEnabled(page: Page, projectSlug: string, enabled: boolean) {
|
||||||
@@ -681,18 +553,11 @@ export async function setWorkspacesEnabled(page: Page, projectSlug: string, enab
|
|||||||
|
|
||||||
if (current === enabled) return
|
if (current === enabled) return
|
||||||
|
|
||||||
const flip = async (timeout?: number) => {
|
await openProjectMenu(page, projectSlug)
|
||||||
const menu = await openProjectMenu(page, projectSlug)
|
|
||||||
const toggle = menu.locator(projectWorkspacesToggleSelector(projectSlug)).first()
|
const toggle = page.locator(projectWorkspacesToggleSelector(projectSlug)).first()
|
||||||
await expect(toggle).toBeVisible()
|
await expect(toggle).toBeVisible()
|
||||||
return toggle.click({ force: true, timeout })
|
await toggle.click({ force: true })
|
||||||
}
|
|
||||||
|
|
||||||
const flipped = await flip(1500)
|
|
||||||
.then(() => true)
|
|
||||||
.catch(() => false)
|
|
||||||
|
|
||||||
if (!flipped) await flip()
|
|
||||||
|
|
||||||
const expected = enabled ? "New workspace" : "New session"
|
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()
|
||||||
|
|||||||
@@ -1,17 +1,17 @@
|
|||||||
import { test, expect } from "../fixtures"
|
import { test, expect } from "../fixtures"
|
||||||
import { serverNamePattern } from "../utils"
|
import { serverName } from "../utils"
|
||||||
|
|
||||||
test("home renders and shows core entrypoints", async ({ page }) => {
|
test("home renders and shows core entrypoints", async ({ page }) => {
|
||||||
await page.goto("/")
|
await page.goto("/")
|
||||||
|
|
||||||
await expect(page.getByRole("button", { name: "Open project" }).first()).toBeVisible()
|
await expect(page.getByRole("button", { name: "Open project" }).first()).toBeVisible()
|
||||||
await expect(page.getByRole("button", { name: serverNamePattern })).toBeVisible()
|
await expect(page.getByRole("button", { name: serverName })).toBeVisible()
|
||||||
})
|
})
|
||||||
|
|
||||||
test("server picker dialog opens from home", async ({ page }) => {
|
test("server picker dialog opens from home", async ({ page }) => {
|
||||||
await page.goto("/")
|
await page.goto("/")
|
||||||
|
|
||||||
const trigger = page.getByRole("button", { name: serverNamePattern })
|
const trigger = page.getByRole("button", { name: serverName })
|
||||||
await expect(trigger).toBeVisible()
|
await expect(trigger).toBeVisible()
|
||||||
await trigger.click()
|
await trigger.click()
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { test, expect } from "../fixtures"
|
import { test, expect } from "../fixtures"
|
||||||
import { serverNamePattern, serverUrls } from "../utils"
|
import { serverName, serverUrl } from "../utils"
|
||||||
import { closeDialog, clickMenuItem } from "../actions"
|
import { clickListItem, closeDialog, clickMenuItem } from "../actions"
|
||||||
|
|
||||||
const DEFAULT_SERVER_URL_KEY = "opencode.settings.dat:defaultServerUrl"
|
const DEFAULT_SERVER_URL_KEY = "opencode.settings.dat:defaultServerUrl"
|
||||||
|
|
||||||
@@ -31,9 +31,10 @@ test("can set a default server on web", async ({ page, gotoSession }) => {
|
|||||||
const dialog = page.getByRole("dialog")
|
const dialog = page.getByRole("dialog")
|
||||||
await expect(dialog).toBeVisible()
|
await expect(dialog).toBeVisible()
|
||||||
|
|
||||||
await expect(dialog.getByText(serverNamePattern).first()).toBeVisible()
|
const row = dialog.locator('[data-slot="list-item"]').filter({ hasText: serverName }).first()
|
||||||
|
await expect(row).toBeVisible()
|
||||||
|
|
||||||
const menuTrigger = dialog.locator('[data-slot="dropdown-menu-trigger"]').first()
|
const menuTrigger = row.locator('[data-slot="dropdown-menu-trigger"]').first()
|
||||||
await expect(menuTrigger).toBeVisible()
|
await expect(menuTrigger).toBeVisible()
|
||||||
await menuTrigger.click({ force: true })
|
await menuTrigger.click({ force: true })
|
||||||
|
|
||||||
@@ -41,18 +42,14 @@ test("can set a default server on web", async ({ page, gotoSession }) => {
|
|||||||
await expect(menu).toBeVisible()
|
await expect(menu).toBeVisible()
|
||||||
await clickMenuItem(menu, /set as default/i)
|
await clickMenuItem(menu, /set as default/i)
|
||||||
|
|
||||||
await expect
|
await expect.poll(() => page.evaluate((key) => localStorage.getItem(key), DEFAULT_SERVER_URL_KEY)).toBe(serverUrl)
|
||||||
.poll(async () =>
|
await expect(row.getByText("Default", { exact: true })).toBeVisible()
|
||||||
serverUrls.includes((await page.evaluate((key) => localStorage.getItem(key), DEFAULT_SERVER_URL_KEY)) ?? ""),
|
|
||||||
)
|
|
||||||
.toBe(true)
|
|
||||||
await expect(dialog.getByText("Default", { exact: true })).toBeVisible()
|
|
||||||
|
|
||||||
await closeDialog(page, dialog)
|
await closeDialog(page, dialog)
|
||||||
|
|
||||||
await ensurePopoverOpen()
|
await ensurePopoverOpen()
|
||||||
|
|
||||||
const serverRow = popover.locator("button").filter({ hasText: serverNamePattern }).first()
|
const serverRow = popover.locator("button").filter({ hasText: serverName }).first()
|
||||||
await expect(serverRow).toBeVisible()
|
await expect(serverRow).toBeVisible()
|
||||||
await expect(serverRow.getByText("Default", { exact: true })).toBeVisible()
|
await expect(serverRow.getByText("Default", { exact: true })).toBeVisible()
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ 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}(?:\\?|#|$)`))
|
||||||
@@ -55,6 +56,7 @@ 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}(?:\\?|#|$)`))
|
||||||
@@ -74,6 +76,7 @@ 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}(?:\\?|#|$)`))
|
||||||
@@ -99,6 +102,7 @@ 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}(?:\\?|#|$)`))
|
||||||
|
|||||||
@@ -10,8 +10,6 @@ const expanded = async (el: { getAttribute: (name: string) => Promise<string | n
|
|||||||
test("review panel can be toggled via keybind", async ({ page, gotoSession }) => {
|
test("review panel can be toggled via keybind", async ({ page, gotoSession }) => {
|
||||||
await gotoSession()
|
await gotoSession()
|
||||||
|
|
||||||
const reviewPanel = page.locator("#review-panel")
|
|
||||||
|
|
||||||
const treeToggle = page.getByRole("button", { name: "Toggle file tree" }).first()
|
const treeToggle = page.getByRole("button", { name: "Toggle file tree" }).first()
|
||||||
await expect(treeToggle).toBeVisible()
|
await expect(treeToggle).toBeVisible()
|
||||||
if (await expanded(treeToggle)) await treeToggle.click()
|
if (await expanded(treeToggle)) await treeToggle.click()
|
||||||
@@ -21,13 +19,13 @@ test("review panel can be toggled via keybind", async ({ page, gotoSession }) =>
|
|||||||
await expect(reviewToggle).toBeVisible()
|
await expect(reviewToggle).toBeVisible()
|
||||||
if (await expanded(reviewToggle)) await reviewToggle.click()
|
if (await expanded(reviewToggle)) await reviewToggle.click()
|
||||||
await expect(reviewToggle).toHaveAttribute("aria-expanded", "false")
|
await expect(reviewToggle).toHaveAttribute("aria-expanded", "false")
|
||||||
await expect(reviewPanel).toHaveAttribute("aria-hidden", "true")
|
await expect(page.locator("#review-panel")).toHaveCount(0)
|
||||||
|
|
||||||
await page.keyboard.press(`${modKey}+Shift+R`)
|
await page.keyboard.press(`${modKey}+Shift+R`)
|
||||||
await expect(reviewToggle).toHaveAttribute("aria-expanded", "true")
|
await expect(reviewToggle).toHaveAttribute("aria-expanded", "true")
|
||||||
await expect(reviewPanel).toHaveAttribute("aria-hidden", "false")
|
await expect(page.locator("#review-panel")).toBeVisible()
|
||||||
|
|
||||||
await page.keyboard.press(`${modKey}+Shift+R`)
|
await page.keyboard.press(`${modKey}+Shift+R`)
|
||||||
await expect(reviewToggle).toHaveAttribute("aria-expanded", "false")
|
await expect(reviewToggle).toHaveAttribute("aria-expanded", "false")
|
||||||
await expect(reviewPanel).toHaveAttribute("aria-hidden", "true")
|
await expect(page.locator("#review-panel")).toHaveCount(0)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -43,13 +43,6 @@ test("file tree can expand folders and open a file", async ({ page, gotoSession
|
|||||||
await tab.click()
|
await tab.click()
|
||||||
await expect(tab).toHaveAttribute("aria-selected", "true")
|
await expect(tab).toHaveAttribute("aria-selected", "true")
|
||||||
|
|
||||||
await toggle.click()
|
|
||||||
await expect(toggle).toHaveAttribute("aria-expanded", "false")
|
|
||||||
|
|
||||||
await toggle.click()
|
|
||||||
await expect(toggle).toHaveAttribute("aria-expanded", "true")
|
|
||||||
await expect(allTab).toHaveAttribute("aria-selected", "true")
|
|
||||||
|
|
||||||
const viewer = page.locator('[data-component="file"][data-mode="text"]').first()
|
const viewer = page.locator('[data-component="file"][data-mode="text"]').first()
|
||||||
await expect(viewer).toBeVisible()
|
await expect(viewer).toBeVisible()
|
||||||
await expect(viewer).toContainText("export default function FileTree")
|
await expect(viewer).toContainText("export default function FileTree")
|
||||||
|
|||||||
@@ -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 { cleanupSession, cleanupTestProject, createTestProject, seedProjects, sessionIDFromUrl } from "./actions"
|
import { cleanupTestProject, createTestProject, seedProjects } 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,8 +13,6 @@ 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>
|
||||||
@@ -53,36 +51,20 @@ 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 root = await createTestProject()
|
const directory = await createTestProject()
|
||||||
const slug = dirSlug(root)
|
const slug = dirSlug(directory)
|
||||||
const sessions = new Map<string, string>()
|
await seedStorage(page, { directory, extra: options?.extra })
|
||||||
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(root, sessionID))
|
await page.goto(sessionPath(directory, 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: root, slug, gotoSession, trackSession, trackDirectory })
|
return await callback({ directory, slug, gotoSession })
|
||||||
} finally {
|
} finally {
|
||||||
await Promise.allSettled(
|
await cleanupTestProject(directory)
|
||||||
Array.from(sessions, ([sessionID, directory]) => cleanupSession({ sessionID, directory })),
|
|
||||||
)
|
|
||||||
await Promise.allSettled(Array.from(dirs, (directory) => cleanupTestProject(directory)))
|
|
||||||
await cleanupTestProject(root)
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,15 +1,25 @@
|
|||||||
import { test, expect } from "../fixtures"
|
import { test, expect } from "../fixtures"
|
||||||
import { clickMenuItem, openProjectMenu, openSidebar } from "../actions"
|
import { 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 ({ slug }) => {
|
await withProject(async () => {
|
||||||
await openSidebar(page)
|
await openSidebar(page)
|
||||||
|
|
||||||
const open = async () => {
|
const open = async () => {
|
||||||
const menu = await openProjectMenu(page, slug)
|
const header = page.locator(".group\\/project").first()
|
||||||
await clickMenuItem(menu, /^Edit$/i, { force: true })
|
await header.hover()
|
||||||
|
const trigger = header.getByRole("button", { name: "More options" }).first()
|
||||||
|
await expect(trigger).toBeVisible()
|
||||||
|
await trigger.click({ force: true })
|
||||||
|
|
||||||
|
const menu = page.locator('[data-component="dropdown-menu-content"]').first()
|
||||||
|
await expect(menu).toBeVisible()
|
||||||
|
|
||||||
|
const editItem = menu.getByRole("menuitem", { name: "Edit" }).first()
|
||||||
|
await expect(editItem).toBeVisible()
|
||||||
|
await editItem.click({ force: true })
|
||||||
|
|
||||||
const dialog = page.getByRole("dialog")
|
const dialog = page.getByRole("dialog")
|
||||||
await expect(dialog).toBeVisible()
|
await expect(dialog).toBeVisible()
|
||||||
|
|||||||
@@ -1,8 +1,36 @@
|
|||||||
import { test, expect } from "../fixtures"
|
import { test, expect } from "../fixtures"
|
||||||
import { createTestProject, cleanupTestProject, openSidebar, clickMenuItem, openProjectMenu } from "../actions"
|
import { createTestProject, cleanupTestProject, openSidebar, clickMenuItem, openProjectMenu } from "../actions"
|
||||||
import { projectSwitchSelector } from "../selectors"
|
import { projectCloseHoverSelector, projectSwitchSelector } from "../selectors"
|
||||||
import { dirSlug } from "../utils"
|
import { dirSlug } from "../utils"
|
||||||
|
|
||||||
|
test("can close a project via hover card close button", async ({ page, withProject }) => {
|
||||||
|
await page.setViewportSize({ width: 1400, height: 800 })
|
||||||
|
|
||||||
|
const other = await createTestProject()
|
||||||
|
const otherSlug = dirSlug(other)
|
||||||
|
|
||||||
|
try {
|
||||||
|
await withProject(
|
||||||
|
async () => {
|
||||||
|
await openSidebar(page)
|
||||||
|
|
||||||
|
const otherButton = page.locator(projectSwitchSelector(otherSlug)).first()
|
||||||
|
await expect(otherButton).toBeVisible()
|
||||||
|
await otherButton.hover()
|
||||||
|
|
||||||
|
const close = page.locator(projectCloseHoverSelector(otherSlug)).first()
|
||||||
|
await expect(close).toBeVisible()
|
||||||
|
await close.click()
|
||||||
|
|
||||||
|
await expect(otherButton).toHaveCount(0)
|
||||||
|
},
|
||||||
|
{ extra: [other] },
|
||||||
|
)
|
||||||
|
} finally {
|
||||||
|
await cleanupTestProject(other)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
test("closing active project navigates to another open project", async ({ page, withProject }) => {
|
test("closing active project navigates to another open project", async ({ page, withProject }) => {
|
||||||
await page.setViewportSize({ width: 1400, height: 800 })
|
await page.setViewportSize({ width: 1400, height: 800 })
|
||||||
|
|
||||||
@@ -25,26 +53,16 @@ test("closing active project navigates to another open project", async ({ page,
|
|||||||
await clickMenuItem(menu, /^Close$/i, { force: true })
|
await clickMenuItem(menu, /^Close$/i, { force: true })
|
||||||
|
|
||||||
await expect
|
await expect
|
||||||
.poll(
|
.poll(() => {
|
||||||
() => {
|
|
||||||
const pathname = new URL(page.url()).pathname
|
const pathname = new URL(page.url()).pathname
|
||||||
if (new RegExp(`^/${slug}/session(?:/[^/]+)?/?$`).test(pathname)) return "project"
|
if (new RegExp(`^/${slug}/session(?:/[^/]+)?/?$`).test(pathname)) return "project"
|
||||||
if (pathname === "/") return "home"
|
if (pathname === "/") return "home"
|
||||||
return ""
|
return ""
|
||||||
},
|
})
|
||||||
{ timeout: 15_000 },
|
|
||||||
)
|
|
||||||
.toMatch(/^(project|home)$/)
|
.toMatch(/^(project|home)$/)
|
||||||
|
|
||||||
await expect(page).not.toHaveURL(new RegExp(`/${otherSlug}/session(?:[/?#]|$)`))
|
await expect(page).not.toHaveURL(new RegExp(`/${otherSlug}/session(?:[/?#]|$)`))
|
||||||
await expect
|
await expect(otherButton).toHaveCount(0)
|
||||||
.poll(
|
|
||||||
async () => {
|
|
||||||
return await page.locator(projectSwitchSelector(otherSlug)).count()
|
|
||||||
},
|
|
||||||
{ timeout: 15_000 },
|
|
||||||
)
|
|
||||||
.toBe(0)
|
|
||||||
},
|
},
|
||||||
{ extra: [other] },
|
{ extra: [other] },
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,39 +1,18 @@
|
|||||||
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 { defocus, createTestProject, cleanupTestProject, openSidebar, sessionIDFromUrl, waitSlug } from "../actions"
|
import {
|
||||||
|
defocus,
|
||||||
|
createTestProject,
|
||||||
|
cleanupTestProject,
|
||||||
|
openSidebar,
|
||||||
|
setWorkspacesEnabled,
|
||||||
|
sessionIDFromUrl,
|
||||||
|
} from "../actions"
|
||||||
import { projectSwitchSelector, promptSelector, workspaceItemSelector, workspaceNewSessionSelector } from "../selectors"
|
import { projectSwitchSelector, promptSelector, workspaceItemSelector, workspaceNewSessionSelector } from "../selectors"
|
||||||
import { dirSlug, resolveDirectory } from "../utils"
|
import { createSdk, dirSlug, sessionPath } from "../utils"
|
||||||
|
|
||||||
async function workspaces(page: Page, directory: string, enabled: boolean) {
|
function slugFromUrl(url: string) {
|
||||||
await page.evaluate(
|
return /\/([^/]+)\/session(?:\/|$)/.exec(url)?.[1] ?? ""
|
||||||
({ 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 }) => {
|
||||||
@@ -72,39 +51,46 @@ 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 sessionID: string | undefined
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await withProject(
|
await withProject(
|
||||||
async ({ directory, slug, trackSession, trackDirectory }) => {
|
async ({ directory, slug }) => {
|
||||||
|
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 expect(page.getByRole("button", { name: "New workspace" }).first()).toBeVisible()
|
await setWorkspacesEnabled(page, slug, true)
|
||||||
|
|
||||||
await page.getByRole("button", { name: "New workspace" }).first().click()
|
await page.getByRole("button", { name: "New workspace" }).first().click()
|
||||||
|
|
||||||
const raw = await waitSlug(page, [slug])
|
await expect
|
||||||
const dir = base64Decode(raw)
|
.poll(
|
||||||
if (!dir) throw new Error(`Failed to decode workspace slug: ${raw}`)
|
() => {
|
||||||
const space = await resolveDirectory(dir)
|
const next = slugFromUrl(page.url())
|
||||||
const next = dirSlug(space)
|
if (!next) return ""
|
||||||
trackDirectory(space)
|
if (next === slug) return ""
|
||||||
|
return next
|
||||||
|
},
|
||||||
|
{ timeout: 45_000 },
|
||||||
|
)
|
||||||
|
.not.toBe("")
|
||||||
|
|
||||||
|
const workspaceSlug = slugFromUrl(page.url())
|
||||||
|
workspaceDir = base64Decode(workspaceSlug)
|
||||||
|
if (!workspaceDir) throw new Error(`Failed to decode workspace slug: ${workspaceSlug}`)
|
||||||
await openSidebar(page)
|
await openSidebar(page)
|
||||||
|
|
||||||
const item = page.locator(`${workspaceItemSelector(next)}, ${workspaceItemSelector(raw)}`).first()
|
const workspace = page.locator(workspaceItemSelector(workspaceSlug)).first()
|
||||||
await expect(item).toBeVisible()
|
await expect(workspace).toBeVisible()
|
||||||
await item.hover()
|
await workspace.hover()
|
||||||
|
|
||||||
const btn = page.locator(`${workspaceNewSessionSelector(next)}, ${workspaceNewSessionSelector(raw)}`).first()
|
const newSession = page.locator(workspaceNewSessionSelector(workspaceSlug)).first()
|
||||||
await expect(btn).toBeVisible()
|
await expect(newSession).toBeVisible()
|
||||||
await btn.click({ force: true })
|
await newSession.click({ force: true })
|
||||||
|
|
||||||
// A new workspace can be discovered via a transient slug before the route and sidebar
|
await expect(page).toHaveURL(new RegExp(`/${workspaceSlug}/session(?:[/?#]|$)`))
|
||||||
// settle to the canonical workspace path on Windows, so interact with either and assert
|
|
||||||
// against the resolved workspace slug.
|
|
||||||
await waitSlug(page)
|
|
||||||
await expect(page).toHaveURL(new RegExp(`/${next}/session(?:[/?#]|$)`))
|
|
||||||
|
|
||||||
// Create a session by sending a prompt
|
// Create a session by sending a prompt
|
||||||
const prompt = page.locator(promptSelector)
|
const prompt = page.locator(promptSelector)
|
||||||
@@ -117,9 +103,9 @@ 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()}`)
|
||||||
trackSession(created, space)
|
sessionID = created
|
||||||
|
|
||||||
await expect(page).toHaveURL(new RegExp(`/${next}/session/${created}(?:[/?#]|$)`))
|
await expect(page).toHaveURL(new RegExp(`/${workspaceSlug}/session/${created}(?:[/?#]|$)`))
|
||||||
|
|
||||||
await openSidebar(page)
|
await openSidebar(page)
|
||||||
|
|
||||||
@@ -138,6 +124,20 @@ 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,10 +1,14 @@
|
|||||||
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 { openSidebar, sessionIDFromUrl, setWorkspacesEnabled, slugFromUrl, waitSlug } from "../actions"
|
import { cleanupTestProject, 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"
|
||||||
|
|
||||||
|
function slugFromUrl(url: string) {
|
||||||
|
return /\/([^/]+)\/session(?:\/|$)/.exec(url)?.[1] ?? ""
|
||||||
|
}
|
||||||
|
|
||||||
async function waitWorkspaceReady(page: Page, slug: string) {
|
async function waitWorkspaceReady(page: Page, slug: string) {
|
||||||
await openSidebar(page)
|
await openSidebar(page)
|
||||||
await expect
|
await expect
|
||||||
@@ -27,7 +31,20 @@ 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()
|
||||||
|
|
||||||
const slug = await waitSlug(page, [root, ...seen])
|
await expect
|
||||||
|
.poll(
|
||||||
|
() => {
|
||||||
|
const slug = slugFromUrl(page.url())
|
||||||
|
if (!slug) return ""
|
||||||
|
if (slug === root) return ""
|
||||||
|
if (seen.includes(slug)) return ""
|
||||||
|
return slug
|
||||||
|
},
|
||||||
|
{ timeout: 45_000 },
|
||||||
|
)
|
||||||
|
.not.toBe("")
|
||||||
|
|
||||||
|
const slug = slugFromUrl(page.url())
|
||||||
const 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 }
|
||||||
@@ -43,13 +60,12 @@ 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 })
|
||||||
|
|
||||||
const next = await waitSlug(page)
|
await expect.poll(() => slugFromUrl(page.url())).toBe(slug)
|
||||||
await expect(page).toHaveURL(new RegExp(`/${next}/session(?:[/?#]|$)`))
|
await expect(page).toHaveURL(new RegExp(`/${slug}/session(?:[/?#]|$)`))
|
||||||
return next
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function createSessionFromWorkspace(page: Page, slug: string, text: string) {
|
async function createSessionFromWorkspace(page: Page, slug: string, text: string) {
|
||||||
const next = await openWorkspaceNewSession(page, slug)
|
await openWorkspaceNewSession(page, slug)
|
||||||
|
|
||||||
const prompt = page.locator(promptSelector)
|
const prompt = page.locator(promptSelector)
|
||||||
await expect(prompt).toBeVisible()
|
await expect(prompt).toBeVisible()
|
||||||
@@ -60,13 +76,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(next)
|
await expect.poll(() => slugFromUrl(page.url())).toBe(slug)
|
||||||
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(`/${next}/session/${sessionID}(?:[/?#]|$)`))
|
await expect(page).toHaveURL(new RegExp(`/${slug}/session/${sessionID}(?:[/?#]|$)`))
|
||||||
return { sessionID, slug: next }
|
return sessionID
|
||||||
}
|
}
|
||||||
|
|
||||||
async function sessionDirectory(directory: string, sessionID: string) {
|
async function sessionDirectory(directory: string, sessionID: string) {
|
||||||
@@ -81,29 +97,48 @@ 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, trackSession, trackDirectory }) => {
|
await withProject(async ({ directory, slug: root }) => {
|
||||||
|
const workspaces = [] as { slug: string; directory: string }[]
|
||||||
|
const sessions = [] as string[]
|
||||||
|
|
||||||
|
try {
|
||||||
await openSidebar(page)
|
await openSidebar(page)
|
||||||
await setWorkspacesEnabled(page, root, true)
|
await setWorkspacesEnabled(page, root, true)
|
||||||
|
|
||||||
const first = await createWorkspace(page, root, [])
|
const first = await createWorkspace(page, root, [])
|
||||||
trackDirectory(first.directory)
|
workspaces.push(first)
|
||||||
await waitWorkspaceReady(page, first.slug)
|
await waitWorkspaceReady(page, first.slug)
|
||||||
|
|
||||||
const second = await createWorkspace(page, root, [first.slug])
|
const second = await createWorkspace(page, root, [first.slug])
|
||||||
trackDirectory(second.directory)
|
workspaces.push(second)
|
||||||
await waitWorkspaceReady(page, second.slug)
|
await waitWorkspaceReady(page, second.slug)
|
||||||
|
|
||||||
const firstSession = await createSessionFromWorkspace(page, first.slug, `workspace one ${Date.now()}`)
|
const firstSession = await createSessionFromWorkspace(page, first.slug, `workspace one ${Date.now()}`)
|
||||||
trackSession(firstSession.sessionID, first.directory)
|
sessions.push(firstSession)
|
||||||
|
|
||||||
const secondSession = await createSessionFromWorkspace(page, second.slug, `workspace two ${Date.now()}`)
|
const secondSession = await createSessionFromWorkspace(page, second.slug, `workspace two ${Date.now()}`)
|
||||||
trackSession(secondSession.sessionID, second.directory)
|
sessions.push(secondSession)
|
||||||
|
|
||||||
const thirdSession = await createSessionFromWorkspace(page, first.slug, `workspace one again ${Date.now()}`)
|
const thirdSession = await createSessionFromWorkspace(page, first.slug, `workspace one again ${Date.now()}`)
|
||||||
trackSession(thirdSession.sessionID, first.directory)
|
sessions.push(thirdSession)
|
||||||
|
|
||||||
await expect.poll(() => sessionDirectory(first.directory, firstSession.sessionID)).toBe(first.directory)
|
await expect.poll(() => sessionDirectory(first.directory, firstSession)).toBe(first.directory)
|
||||||
await expect.poll(() => sessionDirectory(second.directory, secondSession.sessionID)).toBe(second.directory)
|
await expect.poll(() => sessionDirectory(second.directory, secondSession)).toBe(second.directory)
|
||||||
await expect.poll(() => sessionDirectory(first.directory, thirdSession.sessionID)).toBe(first.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)))
|
||||||
|
}
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -14,12 +14,14 @@ import {
|
|||||||
openSidebar,
|
openSidebar,
|
||||||
openWorkspaceMenu,
|
openWorkspaceMenu,
|
||||||
setWorkspacesEnabled,
|
setWorkspacesEnabled,
|
||||||
slugFromUrl,
|
|
||||||
waitSlug,
|
|
||||||
} from "../actions"
|
} from "../actions"
|
||||||
import { dropdownMenuContentSelector, inlineInputSelector, workspaceItemSelector } from "../selectors"
|
import { dropdownMenuContentSelector, inlineInputSelector, workspaceItemSelector } from "../selectors"
|
||||||
import { createSdk, dirSlug } from "../utils"
|
import { createSdk, dirSlug } from "../utils"
|
||||||
|
|
||||||
|
function slugFromUrl(url: string) {
|
||||||
|
return /\/([^/]+)\/session(?:\/|$)/.exec(url)?.[1] ?? ""
|
||||||
|
}
|
||||||
|
|
||||||
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)
|
||||||
@@ -27,7 +29,17 @@ 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()
|
||||||
const slug = await waitSlug(page, [rootSlug])
|
await expect
|
||||||
|
.poll(
|
||||||
|
() => {
|
||||||
|
const slug = slugFromUrl(page.url())
|
||||||
|
return slug.length > 0 && slug !== rootSlug
|
||||||
|
},
|
||||||
|
{ timeout: 45_000 },
|
||||||
|
)
|
||||||
|
.toBe(true)
|
||||||
|
|
||||||
|
const slug = slugFromUrl(page.url())
|
||||||
const dir = base64Decode(slug)
|
const dir = base64Decode(slug)
|
||||||
|
|
||||||
await openSidebar(page)
|
await openSidebar(page)
|
||||||
@@ -79,7 +91,18 @@ 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)
|
||||||
@@ -256,7 +279,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.poll(() => base64Decode(slugFromUrl(page.url()))).toBe(project.directory)
|
await expect(page).toHaveURL(new RegExp(`/${rootSlug}/session`))
|
||||||
|
|
||||||
await expect
|
await expect
|
||||||
.poll(
|
.poll(
|
||||||
@@ -313,6 +336,9 @@ 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")
|
||||||
@@ -331,7 +357,17 @@ test("can reorder workspaces by drag and drop", async ({ page, withProject }) =>
|
|||||||
for (const _ of [0, 1]) {
|
for (const _ of [0, 1]) {
|
||||||
const prev = slugFromUrl(page.url())
|
const prev = slugFromUrl(page.url())
|
||||||
await page.getByRole("button", { name: "New workspace" }).first().click()
|
await page.getByRole("button", { name: "New workspace" }).first().click()
|
||||||
const slug = await waitSlug(page, [rootSlug, prev])
|
await expect
|
||||||
|
.poll(
|
||||||
|
() => {
|
||||||
|
const slug = slugFromUrl(page.url())
|
||||||
|
return slug.length > 0 && slug !== rootSlug && slug !== prev
|
||||||
|
},
|
||||||
|
{ timeout: 45_000 },
|
||||||
|
)
|
||||||
|
.toBe(true)
|
||||||
|
|
||||||
|
const slug = slugFromUrl(page.url())
|
||||||
const dir = base64Decode(slug)
|
const dir = base64Decode(slug)
|
||||||
workspaces.push({ slug, directory: dir })
|
workspaces.push({ slug, directory: dir })
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
import { test, expect } from "../fixtures"
|
import { test, expect } from "../fixtures"
|
||||||
import { promptSelector } from "../selectors"
|
import { promptSelector } from "../selectors"
|
||||||
import { cleanupSession, sessionIDFromUrl, withSession } from "../actions"
|
import { sessionIDFromUrl } 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
|
||||||
@@ -40,37 +38,6 @@ test("prompt succeeds when sync message endpoint is unreachable", async ({ page,
|
|||||||
)
|
)
|
||||||
.toContain(token)
|
.toContain(token)
|
||||||
} finally {
|
} finally {
|
||||||
await cleanupSession({ sdk, sessionID })
|
await sdk.session.delete({ sessionID }).catch(() => undefined)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
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)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|||||||
@@ -1,181 +0,0 @@
|
|||||||
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)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -1,62 +0,0 @@
|
|||||||
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("")
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -1,64 +0,0 @@
|
|||||||
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 { cleanupSession, sessionIDFromUrl, withSession } from "../actions"
|
import { 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 cleanupSession({ sdk, sessionID })
|
await sdk.session.delete({ sessionID }).catch(() => undefined)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (pageErrors.length > 0) {
|
if (pageErrors.length > 0) {
|
||||||
|
|||||||
@@ -30,6 +30,8 @@ export const sidebarNavSelector = '[data-component="sidebar-nav-desktop"]'
|
|||||||
export const projectSwitchSelector = (slug: string) =>
|
export const projectSwitchSelector = (slug: string) =>
|
||||||
`${sidebarNavSelector} [data-action="project-switch"][data-project="${slug}"]`
|
`${sidebarNavSelector} [data-action="project-switch"][data-project="${slug}"]`
|
||||||
|
|
||||||
|
export const projectCloseHoverSelector = (slug: string) => `[data-action="project-close-hover"][data-project="${slug}"]`
|
||||||
|
|
||||||
export const projectMenuTriggerSelector = (slug: string) =>
|
export const projectMenuTriggerSelector = (slug: string) =>
|
||||||
`${sidebarNavSelector} [data-action="project-menu"][data-project="${slug}"]`
|
`${sidebarNavSelector} [data-action="project-menu"][data-project="${slug}"]`
|
||||||
|
|
||||||
|
|||||||
@@ -1,37 +0,0 @@
|
|||||||
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 { cleanupSession, clearSessionDockSeed, seedSessionQuestion, seedSessionTodos } from "../actions"
|
import { 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 cleanupSession({ sdk, sessionID: session.id })
|
await sdk.session.delete({ sessionID: session.id }).catch(() => undefined)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -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 cleanupSession({ sdk, sessionID: child.id })
|
await sdk.session.delete({ sessionID: child.id }).catch(() => undefined)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
@@ -358,7 +358,7 @@ test("child session permission request blocks parent dock and supports allow onc
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
} finally {
|
} finally {
|
||||||
await cleanupSession({ sdk, sessionID: child.id })
|
await sdk.session.delete({ sessionID: child.id }).catch(() => undefined)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ async function seedConversation(input: {
|
|||||||
.toBe(true)
|
.toBe(true)
|
||||||
|
|
||||||
if (!userMessageID) throw new Error("Expected a user message id")
|
if (!userMessageID) throw new Error("Expected a user message id")
|
||||||
await expect(input.page.locator(`[data-message-id="${userMessageID}"]`)).toHaveCount(1, { timeout: 30_000 })
|
await expect(input.page.locator(`[data-message-id="${userMessageID}"]`).first()).toBeVisible({ timeout: 30_000 })
|
||||||
return { prompt, userMessageID }
|
return { prompt, userMessageID }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -123,7 +123,7 @@ test("slash redo clears revert and restores latest state", async ({ page, withPr
|
|||||||
.toBeUndefined()
|
.toBeUndefined()
|
||||||
|
|
||||||
await expect(seeded.prompt).not.toContainText(token)
|
await expect(seeded.prompt).not.toContainText(token)
|
||||||
await expect(page.locator(`[data-message-id="${seeded.userMessageID}"]`)).toHaveCount(1)
|
await expect(page.locator(`[data-message-id="${seeded.userMessageID}"]`).first()).toBeVisible()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
@@ -158,8 +158,8 @@ test("slash undo/redo traverses multi-step revert stack", async ({ page, withPro
|
|||||||
const firstMessage = page.locator(`[data-message-id="${first.userMessageID}"]`)
|
const firstMessage = page.locator(`[data-message-id="${first.userMessageID}"]`)
|
||||||
const secondMessage = page.locator(`[data-message-id="${second.userMessageID}"]`)
|
const secondMessage = page.locator(`[data-message-id="${second.userMessageID}"]`)
|
||||||
|
|
||||||
await expect(firstMessage).toHaveCount(1)
|
await expect(firstMessage.first()).toBeVisible()
|
||||||
await expect(secondMessage).toHaveCount(1)
|
await expect(secondMessage.first()).toBeVisible()
|
||||||
|
|
||||||
await second.prompt.click()
|
await second.prompt.click()
|
||||||
await page.keyboard.press(`${modKey}+A`)
|
await page.keyboard.press(`${modKey}+A`)
|
||||||
@@ -176,7 +176,7 @@ test("slash undo/redo traverses multi-step revert stack", async ({ page, withPro
|
|||||||
})
|
})
|
||||||
.toBe(second.userMessageID)
|
.toBe(second.userMessageID)
|
||||||
|
|
||||||
await expect(firstMessage).toHaveCount(1)
|
await expect(firstMessage.first()).toBeVisible()
|
||||||
await expect(secondMessage).toHaveCount(0)
|
await expect(secondMessage).toHaveCount(0)
|
||||||
|
|
||||||
await second.prompt.click()
|
await second.prompt.click()
|
||||||
@@ -210,7 +210,7 @@ test("slash undo/redo traverses multi-step revert stack", async ({ page, withPro
|
|||||||
})
|
})
|
||||||
.toBe(second.userMessageID)
|
.toBe(second.userMessageID)
|
||||||
|
|
||||||
await expect(firstMessage).toHaveCount(1)
|
await expect(firstMessage.first()).toBeVisible()
|
||||||
await expect(secondMessage).toHaveCount(0)
|
await expect(secondMessage).toHaveCount(0)
|
||||||
|
|
||||||
await second.prompt.click()
|
await second.prompt.click()
|
||||||
@@ -226,8 +226,8 @@ test("slash undo/redo traverses multi-step revert stack", async ({ page, withPro
|
|||||||
})
|
})
|
||||||
.toBeUndefined()
|
.toBeUndefined()
|
||||||
|
|
||||||
await expect(firstMessage).toHaveCount(1)
|
await expect(firstMessage.first()).toBeVisible()
|
||||||
await expect(secondMessage).toHaveCount(1)
|
await expect(secondMessage.first()).toBeVisible()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -32,19 +32,22 @@ test("changing sidebar toggle keybind works", async ({ page, gotoSession }) => {
|
|||||||
|
|
||||||
await closeDialog(page, dialog)
|
await closeDialog(page, dialog)
|
||||||
|
|
||||||
const button = page.getByRole("button", { name: /toggle sidebar/i }).first()
|
const main = page.locator("main")
|
||||||
const initiallyClosed = (await button.getAttribute("aria-expanded")) !== "true"
|
const initialClasses = (await main.getAttribute("class")) ?? ""
|
||||||
|
const initiallyClosed = initialClasses.includes("xl:border-l")
|
||||||
|
|
||||||
await page.keyboard.press(`${modKey}+Shift+H`)
|
await page.keyboard.press(`${modKey}+Shift+H`)
|
||||||
await expect(button).toHaveAttribute("aria-expanded", initiallyClosed ? "true" : "false")
|
await page.waitForTimeout(100)
|
||||||
|
|
||||||
const afterToggleClosed = (await button.getAttribute("aria-expanded")) !== "true"
|
const afterToggleClasses = (await main.getAttribute("class")) ?? ""
|
||||||
|
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 expect(button).toHaveAttribute("aria-expanded", initiallyClosed ? "false" : "true")
|
await page.waitForTimeout(100)
|
||||||
|
|
||||||
const finalClosed = (await button.getAttribute("aria-expanded")) !== "true"
|
const finalClasses = (await main.getAttribute("class")) ?? ""
|
||||||
|
const finalClosed = finalClasses.includes("xl:border-l")
|
||||||
expect(finalClosed).toBe(initiallyClosed)
|
expect(finalClosed).toBe(initiallyClosed)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -83,23 +83,16 @@ test("changing theme persists in localStorage", async ({ page, gotoSession }) =>
|
|||||||
const select = dialog.locator(settingsThemeSelector)
|
const select = dialog.locator(settingsThemeSelector)
|
||||||
await expect(select).toBeVisible()
|
await expect(select).toBeVisible()
|
||||||
|
|
||||||
const currentThemeId = await page.evaluate(() => {
|
|
||||||
return document.documentElement.getAttribute("data-theme")
|
|
||||||
})
|
|
||||||
const currentTheme = (await select.locator('[data-slot="select-select-trigger-value"]').textContent())?.trim() ?? ""
|
|
||||||
|
|
||||||
await select.locator('[data-slot="select-select-trigger"]').click()
|
await select.locator('[data-slot="select-select-trigger"]').click()
|
||||||
|
|
||||||
const items = page.locator('[data-slot="select-select-item"]')
|
const items = page.locator('[data-slot="select-select-item"]')
|
||||||
const count = await items.count()
|
const count = await items.count()
|
||||||
expect(count).toBeGreaterThan(1)
|
expect(count).toBeGreaterThan(1)
|
||||||
|
|
||||||
const nextTheme = (await items.locator('[data-slot="select-select-item-label"]').allTextContents())
|
const firstTheme = await items.nth(1).locator('[data-slot="select-select-item-label"]').textContent()
|
||||||
.map((x) => x.trim())
|
expect(firstTheme).toBeTruthy()
|
||||||
.find((x) => x && x !== currentTheme)
|
|
||||||
expect(nextTheme).toBeTruthy()
|
|
||||||
|
|
||||||
await items.filter({ hasText: nextTheme! }).first().click()
|
await items.nth(1).click()
|
||||||
|
|
||||||
await page.keyboard.press("Escape")
|
await page.keyboard.press("Escape")
|
||||||
|
|
||||||
@@ -108,7 +101,7 @@ test("changing theme persists in localStorage", async ({ page, gotoSession }) =>
|
|||||||
})
|
})
|
||||||
|
|
||||||
expect(storedThemeId).not.toBeNull()
|
expect(storedThemeId).not.toBeNull()
|
||||||
expect(storedThemeId).not.toBe(currentThemeId)
|
expect(storedThemeId).not.toBe("oc-1")
|
||||||
|
|
||||||
const dataTheme = await page.evaluate(() => {
|
const dataTheme = await page.evaluate(() => {
|
||||||
return document.documentElement.getAttribute("data-theme")
|
return document.documentElement.getAttribute("data-theme")
|
||||||
@@ -116,42 +109,6 @@ test("changing theme persists in localStorage", async ({ page, gotoSession }) =>
|
|||||||
expect(dataTheme).toBe(storedThemeId)
|
expect(dataTheme).toBe(storedThemeId)
|
||||||
})
|
})
|
||||||
|
|
||||||
test("legacy oc-1 theme migrates to oc-2", async ({ page, gotoSession }) => {
|
|
||||||
await page.addInitScript(() => {
|
|
||||||
localStorage.setItem("opencode-theme-id", "oc-1")
|
|
||||||
localStorage.setItem("opencode-theme-css-light", "--background-base:#fff;")
|
|
||||||
localStorage.setItem("opencode-theme-css-dark", "--background-base:#000;")
|
|
||||||
})
|
|
||||||
|
|
||||||
await gotoSession()
|
|
||||||
|
|
||||||
await expect(page.locator("html")).toHaveAttribute("data-theme", "oc-2")
|
|
||||||
|
|
||||||
await expect
|
|
||||||
.poll(async () => {
|
|
||||||
return await page.evaluate(() => {
|
|
||||||
return localStorage.getItem("opencode-theme-id")
|
|
||||||
})
|
|
||||||
})
|
|
||||||
.toBe("oc-2")
|
|
||||||
|
|
||||||
await expect
|
|
||||||
.poll(async () => {
|
|
||||||
return await page.evaluate(() => {
|
|
||||||
return localStorage.getItem("opencode-theme-css-light")
|
|
||||||
})
|
|
||||||
})
|
|
||||||
.toBeNull()
|
|
||||||
|
|
||||||
await expect
|
|
||||||
.poll(async () => {
|
|
||||||
return await page.evaluate(() => {
|
|
||||||
return localStorage.getItem("opencode-theme-css-dark")
|
|
||||||
})
|
|
||||||
})
|
|
||||||
.toBeNull()
|
|
||||||
})
|
|
||||||
|
|
||||||
test("changing font persists in localStorage and updates CSS variable", async ({ page, gotoSession }) => {
|
test("changing font persists in localStorage and updates CSS variable", async ({ page, gotoSession }) => {
|
||||||
await gotoSession()
|
await gotoSession()
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { test, expect } from "../fixtures"
|
import { test, expect } from "../fixtures"
|
||||||
import { cleanupSession, closeSidebar, hoverSessionItem } from "../actions"
|
import { closeSidebar, hoverSessionItem } from "../actions"
|
||||||
import { projectSwitchSelector } from "../selectors"
|
import { projectSwitchSelector, sessionItemSelector } 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,15 +15,12 @@ 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(oneItem).toBeVisible()
|
await expect(page.locator(sessionItemSelector(one.id)).first()).toBeVisible()
|
||||||
await expect(twoItem).toBeVisible()
|
await expect(page.locator(sessionItemSelector(two.id)).first()).toBeVisible()
|
||||||
|
|
||||||
const item = await hoverSessionItem(page, one.id)
|
const item = await hoverSessionItem(page, one.id)
|
||||||
await item
|
await item
|
||||||
@@ -31,9 +28,9 @@ test("collapsed sidebar popover stays open when archiving a session", async ({ p
|
|||||||
.first()
|
.first()
|
||||||
.click()
|
.click()
|
||||||
|
|
||||||
await expect(twoItem).toBeVisible()
|
await expect(page.locator(sessionItemSelector(two.id)).first()).toBeVisible()
|
||||||
} finally {
|
} finally {
|
||||||
await cleanupSession({ sdk, sessionID: one.id })
|
await sdk.session.delete({ sessionID: one.id }).catch(() => undefined)
|
||||||
await cleanupSession({ sdk, sessionID: two.id })
|
await sdk.session.delete({ sessionID: two.id }).catch(() => undefined)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { test, expect } from "../fixtures"
|
import { test, expect } from "../fixtures"
|
||||||
import { cleanupSession, openSidebar, withSession } from "../actions"
|
import { 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,13 +18,14 @@ 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 cleanupSession({ sdk, sessionID: one.id })
|
await sdk.session.delete({ sessionID: one.id }).catch(() => undefined)
|
||||||
await cleanupSession({ sdk, sessionID: two.id })
|
await sdk.session.delete({ sessionID: two.id }).catch(() => undefined)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -5,14 +5,12 @@ 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(button).toHaveAttribute("aria-expanded", "false")
|
await expect(page.locator("main")).toHaveClass(/xl:border-l/)
|
||||||
|
|
||||||
await toggleSidebar(page)
|
await toggleSidebar(page)
|
||||||
await expect(button).toHaveAttribute("aria-expanded", "true")
|
await expect(page.locator("main")).not.toHaveClass(/xl:border-l/)
|
||||||
})
|
})
|
||||||
|
|
||||||
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 }) => {
|
||||||
@@ -21,15 +19,14 @@ 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(button).toHaveAttribute("aria-expanded", "false")
|
await expect(page.locator("main")).toHaveClass(/xl:border-l/)
|
||||||
|
|
||||||
await gotoSession(session2.id)
|
await gotoSession(session2.id)
|
||||||
await expect(button).toHaveAttribute("aria-expanded", "false")
|
await expect(page.locator("main")).toHaveClass(/xl:border-l/)
|
||||||
|
|
||||||
await page.reload()
|
await page.reload()
|
||||||
await expect(button).toHaveAttribute("aria-expanded", "false")
|
await expect(page.locator("main")).toHaveClass(/xl:border-l/)
|
||||||
|
|
||||||
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,
|
||||||
|
|||||||
@@ -1,139 +0,0 @@
|
|||||||
import type { Page } from "@playwright/test"
|
|
||||||
import { test, expect } from "../fixtures"
|
|
||||||
import { terminalSelector } from "../selectors"
|
|
||||||
import { terminalToggleKey, workspacePersistKey } from "../utils"
|
|
||||||
|
|
||||||
type State = {
|
|
||||||
active?: string
|
|
||||||
all: Array<{
|
|
||||||
id: string
|
|
||||||
title: string
|
|
||||||
titleNumber: number
|
|
||||||
buffer?: string
|
|
||||||
}>
|
|
||||||
}
|
|
||||||
|
|
||||||
async function open(page: Page) {
|
|
||||||
const terminal = page.locator(terminalSelector)
|
|
||||||
const visible = await terminal.isVisible().catch(() => false)
|
|
||||||
if (!visible) await page.keyboard.press(terminalToggleKey)
|
|
||||||
await expect(terminal).toBeVisible()
|
|
||||||
await expect(terminal.locator("textarea")).toHaveCount(1)
|
|
||||||
}
|
|
||||||
|
|
||||||
async function run(page: Page, cmd: string) {
|
|
||||||
const terminal = page.locator(terminalSelector)
|
|
||||||
await expect(terminal).toBeVisible()
|
|
||||||
await terminal.click()
|
|
||||||
await page.keyboard.type(cmd)
|
|
||||||
await page.keyboard.press("Enter")
|
|
||||||
}
|
|
||||||
|
|
||||||
async function store(page: Page, key: string) {
|
|
||||||
return page.evaluate((key) => {
|
|
||||||
const raw = localStorage.getItem(key)
|
|
||||||
if (raw) return JSON.parse(raw) as State
|
|
||||||
|
|
||||||
for (let i = 0; i < localStorage.length; i++) {
|
|
||||||
const next = localStorage.key(i)
|
|
||||||
if (!next?.endsWith(":workspace:terminal")) continue
|
|
||||||
const value = localStorage.getItem(next)
|
|
||||||
if (!value) continue
|
|
||||||
return JSON.parse(value) as State
|
|
||||||
}
|
|
||||||
}, key)
|
|
||||||
}
|
|
||||||
|
|
||||||
test("inactive terminal tab buffers persist across tab switches", async ({ page, withProject }) => {
|
|
||||||
await withProject(async ({ directory, gotoSession }) => {
|
|
||||||
const key = workspacePersistKey(directory, "terminal")
|
|
||||||
const one = `E2E_TERM_ONE_${Date.now()}`
|
|
||||||
const two = `E2E_TERM_TWO_${Date.now()}`
|
|
||||||
const tabs = page.locator('#terminal-panel [data-slot="tabs-trigger"]')
|
|
||||||
const first = tabs.filter({ hasText: /Terminal 1/ }).first()
|
|
||||||
const second = tabs.filter({ hasText: /Terminal 2/ }).first()
|
|
||||||
|
|
||||||
await gotoSession()
|
|
||||||
await open(page)
|
|
||||||
|
|
||||||
await run(page, `echo ${one}`)
|
|
||||||
|
|
||||||
await page.getByRole("button", { name: /new terminal/i }).click()
|
|
||||||
await expect(tabs).toHaveCount(2)
|
|
||||||
|
|
||||||
await run(page, `echo ${two}`)
|
|
||||||
|
|
||||||
await first.click()
|
|
||||||
await expect(first).toHaveAttribute("aria-selected", "true")
|
|
||||||
await expect
|
|
||||||
.poll(
|
|
||||||
async () => {
|
|
||||||
const state = await store(page, key)
|
|
||||||
const first = state?.all.find((item) => item.titleNumber === 1)?.buffer ?? ""
|
|
||||||
const second = state?.all.find((item) => item.titleNumber === 2)?.buffer ?? ""
|
|
||||||
return {
|
|
||||||
first: first.includes(one),
|
|
||||||
second: second.includes(two),
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{ timeout: 30_000 },
|
|
||||||
)
|
|
||||||
.toEqual({ first: false, second: true })
|
|
||||||
|
|
||||||
await second.click()
|
|
||||||
await expect(second).toHaveAttribute("aria-selected", "true")
|
|
||||||
await expect
|
|
||||||
.poll(
|
|
||||||
async () => {
|
|
||||||
const state = await store(page, key)
|
|
||||||
const first = state?.all.find((item) => item.titleNumber === 1)?.buffer ?? ""
|
|
||||||
const second = state?.all.find((item) => item.titleNumber === 2)?.buffer ?? ""
|
|
||||||
return {
|
|
||||||
first: first.includes(one),
|
|
||||||
second: second.includes(two),
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{ timeout: 30_000 },
|
|
||||||
)
|
|
||||||
.toEqual({ first: true, second: false })
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
test("closing the active terminal tab falls back to the previous tab", async ({ page, withProject }) => {
|
|
||||||
await withProject(async ({ directory, gotoSession }) => {
|
|
||||||
const key = workspacePersistKey(directory, "terminal")
|
|
||||||
const tabs = page.locator('#terminal-panel [data-slot="tabs-trigger"]')
|
|
||||||
|
|
||||||
await gotoSession()
|
|
||||||
await open(page)
|
|
||||||
|
|
||||||
await page.getByRole("button", { name: /new terminal/i }).click()
|
|
||||||
await expect(tabs).toHaveCount(2)
|
|
||||||
|
|
||||||
const second = tabs.filter({ hasText: /Terminal 2/ }).first()
|
|
||||||
await second.click()
|
|
||||||
await expect(second).toHaveAttribute("aria-selected", "true")
|
|
||||||
|
|
||||||
await second.hover()
|
|
||||||
await page
|
|
||||||
.getByRole("button", { name: /close terminal/i })
|
|
||||||
.nth(1)
|
|
||||||
.click({ force: true })
|
|
||||||
|
|
||||||
const first = tabs.filter({ hasText: /Terminal 1/ }).first()
|
|
||||||
await expect(tabs).toHaveCount(1)
|
|
||||||
await expect(first).toHaveAttribute("aria-selected", "true")
|
|
||||||
await expect
|
|
||||||
.poll(
|
|
||||||
async () => {
|
|
||||||
const state = await store(page, key)
|
|
||||||
return {
|
|
||||||
count: state?.all.length ?? 0,
|
|
||||||
first: state?.all.some((item) => item.titleNumber === 1) ?? false,
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{ timeout: 15_000 },
|
|
||||||
)
|
|
||||||
.toEqual({ count: 1, first: true })
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { createOpencodeClient } from "@opencode-ai/sdk/v2/client"
|
import { createOpencodeClient } from "@opencode-ai/sdk/v2/client"
|
||||||
import { base64Encode, checksum } from "@opencode-ai/util/encode"
|
import { base64Encode } 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"
|
||||||
@@ -7,22 +7,6 @@ export const serverPort = process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"
|
|||||||
export const serverUrl = `http://${serverHost}:${serverPort}`
|
export const serverUrl = `http://${serverHost}:${serverPort}`
|
||||||
export const serverName = `${serverHost}:${serverPort}`
|
export const serverName = `${serverHost}:${serverPort}`
|
||||||
|
|
||||||
const localHosts = ["127.0.0.1", "localhost"]
|
|
||||||
|
|
||||||
const serverLabels = (() => {
|
|
||||||
const url = new URL(serverUrl)
|
|
||||||
if (!localHosts.includes(url.hostname)) return [serverName]
|
|
||||||
return localHosts.map((host) => `${host}:${url.port}`)
|
|
||||||
})()
|
|
||||||
|
|
||||||
export const serverNames = [...new Set(serverLabels)]
|
|
||||||
|
|
||||||
export const serverUrls = serverNames.map((name) => `http://${name}`)
|
|
||||||
|
|
||||||
const escape = (value: string) => value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
|
|
||||||
|
|
||||||
export const serverNamePattern = new RegExp(`(?:${serverNames.map(escape).join("|")})`)
|
|
||||||
|
|
||||||
export const modKey = process.platform === "darwin" ? "Meta" : "Control"
|
export const modKey = process.platform === "darwin" ? "Meta" : "Control"
|
||||||
export const terminalToggleKey = "Control+Backquote"
|
export const terminalToggleKey = "Control+Backquote"
|
||||||
|
|
||||||
@@ -30,12 +14,6 @@ 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()
|
||||||
@@ -55,9 +33,3 @@ 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").replace(/[^a-zA-Z0-9._-]/g, "-")
|
|
||||||
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.24",
|
"version": "1.2.17",
|
||||||
"description": "",
|
"description": "",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"exports": {
|
"exports": {
|
||||||
@@ -57,7 +57,7 @@
|
|||||||
"@thisbeyond/solid-dnd": "0.7.5",
|
"@thisbeyond/solid-dnd": "0.7.5",
|
||||||
"diff": "catalog:",
|
"diff": "catalog:",
|
||||||
"fuzzysort": "catalog:",
|
"fuzzysort": "catalog:",
|
||||||
"ghostty-web": "github:anomalyco/ghostty-web#main",
|
"ghostty-web": "0.4.0",
|
||||||
"luxon": "catalog:",
|
"luxon": "catalog:",
|
||||||
"marked": "catalog:",
|
"marked": "catalog:",
|
||||||
"marked-shiki": "catalog:",
|
"marked-shiki": "catalog:",
|
||||||
|
|||||||
@@ -1,13 +1,6 @@
|
|||||||
;(function () {
|
;(function () {
|
||||||
var key = "opencode-theme-id"
|
var themeId = localStorage.getItem("opencode-theme-id")
|
||||||
var themeId = localStorage.getItem(key) || "oc-2"
|
if (!themeId) return
|
||||||
|
|
||||||
if (themeId === "oc-1") {
|
|
||||||
themeId = "oc-2"
|
|
||||||
localStorage.setItem(key, themeId)
|
|
||||||
localStorage.removeItem("opencode-theme-css-light")
|
|
||||||
localStorage.removeItem("opencode-theme-css-dark")
|
|
||||||
}
|
|
||||||
|
|
||||||
var scheme = localStorage.getItem("opencode-color-scheme") || "system"
|
var scheme = localStorage.getItem("opencode-color-scheme") || "system"
|
||||||
var isDark = scheme === "dark" || (scheme === "system" && matchMedia("(prefers-color-scheme: dark)").matches)
|
var isDark = scheme === "dark" || (scheme === "system" && matchMedia("(prefers-color-scheme: dark)").matches)
|
||||||
@@ -16,9 +9,9 @@
|
|||||||
document.documentElement.dataset.theme = themeId
|
document.documentElement.dataset.theme = themeId
|
||||||
document.documentElement.dataset.colorScheme = mode
|
document.documentElement.dataset.colorScheme = mode
|
||||||
|
|
||||||
if (themeId === "oc-2") return
|
if (themeId === "oc-1") return
|
||||||
|
|
||||||
var css = localStorage.getItem("opencode-theme-css-" + mode)
|
var css = localStorage.getItem("opencode-theme-css-" + themeId + "-" + mode)
|
||||||
if (css) {
|
if (css) {
|
||||||
var style = document.createElement("style")
|
var style = document.createElement("style")
|
||||||
style.id = "oc-theme-preload"
|
style.id = "oc-theme-preload"
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { SettingsGeneral } from "./settings-general"
|
|||||||
import { SettingsKeybinds } from "./settings-keybinds"
|
import { SettingsKeybinds } from "./settings-keybinds"
|
||||||
import { SettingsProviders } from "./settings-providers"
|
import { SettingsProviders } from "./settings-providers"
|
||||||
import { SettingsModels } from "./settings-models"
|
import { SettingsModels } from "./settings-models"
|
||||||
|
import { SettingsMcp } from "./settings-mcp"
|
||||||
|
|
||||||
export const DialogSettings: Component = () => {
|
export const DialogSettings: Component = () => {
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
@@ -45,6 +46,10 @@ export const DialogSettings: Component = () => {
|
|||||||
<Icon name="models" />
|
<Icon name="models" />
|
||||||
{language.t("settings.models.title")}
|
{language.t("settings.models.title")}
|
||||||
</Tabs.Trigger>
|
</Tabs.Trigger>
|
||||||
|
<Tabs.Trigger value="mcp">
|
||||||
|
<Icon name="mcp" />
|
||||||
|
{language.t("settings.mcp.title")}
|
||||||
|
</Tabs.Trigger>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -67,6 +72,9 @@ export const DialogSettings: Component = () => {
|
|||||||
<Tabs.Content value="models" class="no-scrollbar">
|
<Tabs.Content value="models" class="no-scrollbar">
|
||||||
<SettingsModels />
|
<SettingsModels />
|
||||||
</Tabs.Content>
|
</Tabs.Content>
|
||||||
|
<Tabs.Content value="mcp" class="no-scrollbar">
|
||||||
|
<SettingsMcp />
|
||||||
|
</Tabs.Content>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -244,6 +244,7 @@ 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,
|
||||||
@@ -252,6 +253,7 @@ 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 })
|
||||||
@@ -304,6 +306,12 @@ 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) => {
|
||||||
@@ -953,7 +961,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 permission.isAutoAcceptingDirectory(sdk.directory)
|
if (!id) return store.pendingAutoAccept
|
||||||
return permission.isAutoAccepting(id, sdk.directory)
|
return permission.isAutoAccepting(id, sdk.directory)
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -1203,9 +1211,9 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
aria-multiline="true"
|
aria-multiline="true"
|
||||||
aria-label={placeholder()}
|
aria-label={placeholder()}
|
||||||
contenteditable="true"
|
contenteditable="true"
|
||||||
autocapitalize={store.mode === "normal" ? "sentences" : "off"}
|
autocapitalize="off"
|
||||||
autocorrect={store.mode === "normal" ? "on" : "off"}
|
autocorrect="off"
|
||||||
spellcheck={store.mode === "normal"}
|
spellcheck={false}
|
||||||
onInput={handleInput}
|
onInput={handleInput}
|
||||||
onPaste={handlePaste}
|
onPaste={handlePaste}
|
||||||
onCompositionStart={() => setComposing(true)}
|
onCompositionStart={() => setComposing(true)}
|
||||||
@@ -1328,7 +1336,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
variant="ghost"
|
variant="ghost"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (!params.id) {
|
if (!params.id) {
|
||||||
permission.toggleAutoAcceptDirectory(sdk.directory)
|
setStore("pendingAutoAccept", (value) => !value)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
permission.toggleAutoAccept(params.id, sdk.directory)
|
permission.toggleAutoAccept(params.id, sdk.directory)
|
||||||
|
|||||||
@@ -6,19 +6,10 @@ let createPromptSubmit: typeof import("./submit").createPromptSubmit
|
|||||||
const createdClients: string[] = []
|
const createdClients: string[] = []
|
||||||
const createdSessions: string[] = []
|
const createdSessions: string[] = []
|
||||||
const enabledAutoAccept: Array<{ sessionID: string; directory: string }> = []
|
const enabledAutoAccept: Array<{ sessionID: string; directory: string }> = []
|
||||||
const optimistic: Array<{
|
|
||||||
message: {
|
|
||||||
agent: string
|
|
||||||
model: { providerID: string; modelID: string }
|
|
||||||
variant?: string
|
|
||||||
}
|
|
||||||
}> = []
|
|
||||||
const sentShell: string[] = []
|
const sentShell: string[] = []
|
||||||
const syncedDirectories: string[] = []
|
const syncedDirectories: string[] = []
|
||||||
|
|
||||||
let params: { id?: string } = {}
|
|
||||||
let selected = "/repo/worktree-a"
|
let selected = "/repo/worktree-a"
|
||||||
let variant: string | undefined
|
|
||||||
|
|
||||||
const promptValue: Prompt = [{ type: "text", content: "ls", start: 0, end: 2 }]
|
const promptValue: Prompt = [{ type: "text", content: "ls", start: 0, end: 2 }]
|
||||||
|
|
||||||
@@ -35,7 +26,6 @@ const clientFor = (directory: string) => {
|
|||||||
return { data: undefined }
|
return { data: undefined }
|
||||||
},
|
},
|
||||||
prompt: async () => ({ data: undefined }),
|
prompt: async () => ({ data: undefined }),
|
||||||
promptAsync: async () => ({ data: undefined }),
|
|
||||||
command: async () => ({ data: undefined }),
|
command: async () => ({ data: undefined }),
|
||||||
abort: async () => ({ data: undefined }),
|
abort: async () => ({ data: undefined }),
|
||||||
},
|
},
|
||||||
@@ -50,7 +40,7 @@ beforeAll(async () => {
|
|||||||
|
|
||||||
mock.module("@solidjs/router", () => ({
|
mock.module("@solidjs/router", () => ({
|
||||||
useNavigate: () => () => undefined,
|
useNavigate: () => () => undefined,
|
||||||
useParams: () => params,
|
useParams: () => ({}),
|
||||||
}))
|
}))
|
||||||
|
|
||||||
mock.module("@opencode-ai/sdk/v2/client", () => ({
|
mock.module("@opencode-ai/sdk/v2/client", () => ({
|
||||||
@@ -72,7 +62,7 @@ beforeAll(async () => {
|
|||||||
useLocal: () => ({
|
useLocal: () => ({
|
||||||
model: {
|
model: {
|
||||||
current: () => ({ id: "model", provider: { id: "provider" } }),
|
current: () => ({ id: "model", provider: { id: "provider" } }),
|
||||||
variant: { current: () => variant },
|
variant: { current: () => undefined },
|
||||||
},
|
},
|
||||||
agent: {
|
agent: {
|
||||||
current: () => ({ name: "agent" }),
|
current: () => ({ name: "agent" }),
|
||||||
@@ -128,11 +118,7 @@ beforeAll(async () => {
|
|||||||
data: { command: [] },
|
data: { command: [] },
|
||||||
session: {
|
session: {
|
||||||
optimistic: {
|
optimistic: {
|
||||||
add: (value: {
|
add: () => undefined,
|
||||||
message: { agent: string; model: { providerID: string; modelID: string }; variant?: string }
|
|
||||||
}) => {
|
|
||||||
optimistic.push(value)
|
|
||||||
},
|
|
||||||
remove: () => undefined,
|
remove: () => undefined,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -169,12 +155,9 @@ beforeEach(() => {
|
|||||||
createdClients.length = 0
|
createdClients.length = 0
|
||||||
createdSessions.length = 0
|
createdSessions.length = 0
|
||||||
enabledAutoAccept.length = 0
|
enabledAutoAccept.length = 0
|
||||||
optimistic.length = 0
|
|
||||||
params = {}
|
|
||||||
sentShell.length = 0
|
sentShell.length = 0
|
||||||
syncedDirectories.length = 0
|
syncedDirectories.length = 0
|
||||||
selected = "/repo/worktree-a"
|
selected = "/repo/worktree-a"
|
||||||
variant = undefined
|
|
||||||
})
|
})
|
||||||
|
|
||||||
describe("prompt submit worktree selection", () => {
|
describe("prompt submit worktree selection", () => {
|
||||||
@@ -236,39 +219,4 @@ describe("prompt submit worktree selection", () => {
|
|||||||
|
|
||||||
expect(enabledAutoAccept).toEqual([{ sessionID: "session-1", directory: "/repo/worktree-a" }])
|
expect(enabledAutoAccept).toEqual([{ sessionID: "session-1", directory: "/repo/worktree-a" }])
|
||||||
})
|
})
|
||||||
|
|
||||||
test("includes the selected variant on optimistic prompts", async () => {
|
|
||||||
params = { id: "session-1" }
|
|
||||||
variant = "high"
|
|
||||||
|
|
||||||
const submit = createPromptSubmit({
|
|
||||||
info: () => ({ id: "session-1" }),
|
|
||||||
imageAttachments: () => [],
|
|
||||||
commentCount: () => 0,
|
|
||||||
autoAccept: () => false,
|
|
||||||
mode: () => "normal",
|
|
||||||
working: () => false,
|
|
||||||
editor: () => undefined,
|
|
||||||
queueScroll: () => undefined,
|
|
||||||
promptLength: (value) => value.reduce((sum, part) => sum + ("content" in part ? part.content.length : 0), 0),
|
|
||||||
addToHistory: () => undefined,
|
|
||||||
resetHistoryNavigation: () => undefined,
|
|
||||||
setMode: () => undefined,
|
|
||||||
setPopover: () => undefined,
|
|
||||||
onSubmit: () => undefined,
|
|
||||||
})
|
|
||||||
|
|
||||||
const event = { preventDefault: () => undefined } as unknown as Event
|
|
||||||
|
|
||||||
await submit.handleSubmit(event)
|
|
||||||
|
|
||||||
expect(optimistic).toHaveLength(1)
|
|
||||||
expect(optimistic[0]).toMatchObject({
|
|
||||||
message: {
|
|
||||||
agent: "agent",
|
|
||||||
model: { providerID: "provider", modelID: "model" },
|
|
||||||
variant: "high",
|
|
||||||
},
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -316,7 +316,6 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
|||||||
time: { created: Date.now() },
|
time: { created: Date.now() },
|
||||||
agent,
|
agent,
|
||||||
model,
|
model,
|
||||||
variant,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const addOptimisticMessage = () =>
|
const addOptimisticMessage = () =>
|
||||||
|
|||||||
@@ -303,12 +303,7 @@ export function SessionHeader() {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const canOpen = createMemo(() => platform.platform === "desktop" && !!platform.openPath && server.isLocal())
|
const canOpen = createMemo(() => platform.platform === "desktop" && !!platform.openPath && server.isLocal())
|
||||||
const current = createMemo(
|
const current = createMemo(() => options().find((o) => o.id === prefs.app) ?? options()[0])
|
||||||
() =>
|
|
||||||
options().find((o) => o.id === prefs.app) ??
|
|
||||||
options()[0] ??
|
|
||||||
({ id: "finder", label: fileManager().label, icon: fileManager().icon } as const),
|
|
||||||
)
|
|
||||||
const opening = createMemo(() => openRequest.app !== undefined)
|
const opening = createMemo(() => openRequest.app !== undefined)
|
||||||
|
|
||||||
const selectApp = (app: OpenApp) => {
|
const selectApp = (app: OpenApp) => {
|
||||||
|
|||||||
@@ -4,12 +4,12 @@ import { useSync } from "@/context/sync"
|
|||||||
import { useSDK } from "@/context/sdk"
|
import { useSDK } from "@/context/sdk"
|
||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
import { Icon } from "@opencode-ai/ui/icon"
|
import { Icon } from "@opencode-ai/ui/icon"
|
||||||
import { Mark } from "@opencode-ai/ui/logo"
|
|
||||||
import { getDirectory, getFilename } from "@opencode-ai/util/path"
|
import { getDirectory, getFilename } from "@opencode-ai/util/path"
|
||||||
|
|
||||||
const MAIN_WORKTREE = "main"
|
const MAIN_WORKTREE = "main"
|
||||||
const CREATE_WORKTREE = "create"
|
const CREATE_WORKTREE = "create"
|
||||||
const ROOT_CLASS = "size-full flex flex-col"
|
const ROOT_CLASS =
|
||||||
|
"size-full flex flex-col justify-end items-start gap-4 flex-[1_0_0] self-stretch max-w-200 mx-auto 2xl:max-w-[1000px] px-6 pb-16"
|
||||||
|
|
||||||
interface NewSessionViewProps {
|
interface NewSessionViewProps {
|
||||||
worktree: string
|
worktree: string
|
||||||
@@ -50,30 +50,23 @@ export function NewSessionView(props: NewSessionViewProps) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div class={ROOT_CLASS}>
|
<div class={ROOT_CLASS}>
|
||||||
<div class="h-12 shrink-0" aria-hidden />
|
<div class="text-20-medium text-text-weaker">{language.t("command.session.new")}</div>
|
||||||
<div class="flex-1 px-6 pb-30 flex items-center justify-center text-center">
|
<div class="flex justify-center items-start gap-3 min-h-5">
|
||||||
<div class="w-full max-w-200 flex flex-col items-center text-center gap-4">
|
<Icon name="folder" size="small" class="mt-0.5 shrink-0" />
|
||||||
<div class="flex flex-col items-center gap-6">
|
<div class="text-12-medium text-text-weak select-text leading-5">
|
||||||
<Mark class="w-10" />
|
|
||||||
<div class="text-20-medium text-text-strong">{language.t("session.new.title")}</div>
|
|
||||||
</div>
|
|
||||||
<div class="w-full flex flex-col gap-4 items-center">
|
|
||||||
<div class="flex items-start justify-center gap-3 min-h-5">
|
|
||||||
<div class="text-12-medium text-text-weak select-text leading-5 min-w-0 max-w-160 break-words text-center">
|
|
||||||
{getDirectory(projectRoot())}
|
{getDirectory(projectRoot())}
|
||||||
<span class="text-text-strong">{getFilename(projectRoot())}</span>
|
<span class="text-text-strong">{getFilename(projectRoot())}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-start justify-center gap-1.5 min-h-5">
|
<div class="flex justify-center items-start gap-3 min-h-5">
|
||||||
<Icon name="branch" size="small" class="mt-0.5 shrink-0" />
|
<Icon name="branch" size="small" class="mt-0.5 shrink-0" />
|
||||||
<div class="text-12-medium text-text-weak select-text leading-5 min-w-0 max-w-160 break-words text-center">
|
<div class="text-12-medium text-text-weak select-text leading-5">{label(current())}</div>
|
||||||
{label(current())}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<Show when={sync.project}>
|
<Show when={sync.project}>
|
||||||
{(project) => (
|
{(project) => (
|
||||||
<div class="flex items-start justify-center gap-3 min-h-5">
|
<div class="flex justify-center items-start gap-3 min-h-5">
|
||||||
<div class="text-12-medium text-text-weak leading-5 min-w-0 max-w-160 break-words text-center">
|
<Icon name="pencil-line" size="small" class="mt-0.5 shrink-0" />
|
||||||
|
<div class="text-12-medium text-text-weak leading-5">
|
||||||
{language.t("session.new.lastModified")}
|
{language.t("session.new.lastModified")}
|
||||||
<span class="text-text-strong">
|
<span class="text-text-strong">
|
||||||
{DateTime.fromMillis(project().time.updated ?? project().time.created)
|
{DateTime.fromMillis(project().time.updated ?? project().time.created)
|
||||||
@@ -85,8 +78,5 @@ export function NewSessionView(props: NewSessionViewProps) {
|
|||||||
)}
|
)}
|
||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,15 +1,665 @@
|
|||||||
import { Component } from "solid-js"
|
import type { Config, McpLocalConfig, McpRemoteConfig, McpStatus } from "@opencode-ai/sdk/v2/client"
|
||||||
|
import { Button } from "@opencode-ai/ui/button"
|
||||||
|
import { Icon, type IconProps } from "@opencode-ai/ui/icon"
|
||||||
|
import { Tag } from "@opencode-ai/ui/tag"
|
||||||
|
import { Switch } from "@opencode-ai/ui/switch"
|
||||||
|
import { TextField } from "@opencode-ai/ui/text-field"
|
||||||
|
import { showToast } from "@opencode-ai/ui/toast"
|
||||||
|
import { For, Show, createMemo, onMount, type Component } from "solid-js"
|
||||||
|
import { createStore } from "solid-js/store"
|
||||||
|
import { useGlobalSDK } from "@/context/global-sdk"
|
||||||
|
import { useGlobalSync } from "@/context/global-sync"
|
||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
|
|
||||||
|
type Mode = "remote" | "local"
|
||||||
|
type McpMap = NonNullable<Config["mcp"]>
|
||||||
|
type McpEntry = McpMap[string]
|
||||||
|
type McpConfig = McpLocalConfig | McpRemoteConfig
|
||||||
|
type McpState = McpStatus["status"]
|
||||||
|
|
||||||
|
const FEATURED = [
|
||||||
|
{
|
||||||
|
name: "playwright",
|
||||||
|
title: "Playwright",
|
||||||
|
description: "Browser automation tools for testing, scraping, and repros.",
|
||||||
|
icon: "window-cursor",
|
||||||
|
panel: "linear-gradient(135deg, rgba(59, 130, 246, 0.14), rgba(15, 23, 42, 0.04))",
|
||||||
|
glow: "rgba(96, 165, 250, 0.18)",
|
||||||
|
badge: "rgba(37, 99, 235, 0.14)",
|
||||||
|
color: "rgb(37, 99, 235)",
|
||||||
|
config: {
|
||||||
|
type: "local",
|
||||||
|
command: ["npx", "-y", "@playwright/mcp@latest"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "context7",
|
||||||
|
title: "Context7",
|
||||||
|
description: "Fresh framework docs and API references in one remote server.",
|
||||||
|
icon: "code-lines",
|
||||||
|
panel: "linear-gradient(135deg, rgba(14, 165, 233, 0.16), rgba(15, 23, 42, 0.04))",
|
||||||
|
glow: "rgba(56, 189, 248, 0.2)",
|
||||||
|
badge: "rgba(8, 145, 178, 0.14)",
|
||||||
|
color: "rgb(8, 145, 178)",
|
||||||
|
config: {
|
||||||
|
type: "remote",
|
||||||
|
url: "https://mcp.context7.com/mcp",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "linear",
|
||||||
|
title: "Linear",
|
||||||
|
description: "Issue and project workflows from your Linear workspace.",
|
||||||
|
icon: "branch",
|
||||||
|
panel: "linear-gradient(135deg, rgba(124, 58, 237, 0.14), rgba(30, 41, 59, 0.04))",
|
||||||
|
glow: "rgba(139, 92, 246, 0.18)",
|
||||||
|
badge: "rgba(109, 40, 217, 0.14)",
|
||||||
|
color: "rgb(109, 40, 217)",
|
||||||
|
config: {
|
||||||
|
type: "remote",
|
||||||
|
url: "https://mcp.linear.app/sse",
|
||||||
|
oauth: {},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "sentry",
|
||||||
|
title: "Sentry",
|
||||||
|
description: "Error monitoring, traces, and issue triage in one place.",
|
||||||
|
icon: "warning",
|
||||||
|
panel: "linear-gradient(135deg, rgba(14, 116, 144, 0.16), rgba(15, 23, 42, 0.04))",
|
||||||
|
glow: "rgba(6, 182, 212, 0.16)",
|
||||||
|
badge: "rgba(8, 145, 178, 0.14)",
|
||||||
|
color: "rgb(8, 145, 178)",
|
||||||
|
config: {
|
||||||
|
type: "remote",
|
||||||
|
url: "https://mcp.sentry.dev/mcp",
|
||||||
|
oauth: {},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
] satisfies Array<{
|
||||||
|
name: string
|
||||||
|
title: string
|
||||||
|
description: string
|
||||||
|
icon: IconProps["name"]
|
||||||
|
panel: string
|
||||||
|
glow: string
|
||||||
|
badge: string
|
||||||
|
color: string
|
||||||
|
config: McpConfig
|
||||||
|
}>
|
||||||
|
|
||||||
|
const STATUS = {
|
||||||
|
connected: "mcp.status.connected",
|
||||||
|
failed: "mcp.status.failed",
|
||||||
|
needs_auth: "mcp.status.needs_auth",
|
||||||
|
disabled: "mcp.status.disabled",
|
||||||
|
needs_client_registration: "settings.mcp.status.needs_client_registration",
|
||||||
|
} satisfies Record<McpState, string>
|
||||||
|
|
||||||
|
const empty = (mode: Mode = "remote") => ({
|
||||||
|
mode,
|
||||||
|
name: "",
|
||||||
|
url: "",
|
||||||
|
command: "",
|
||||||
|
headers: "",
|
||||||
|
environment: "",
|
||||||
|
timeout: "",
|
||||||
|
})
|
||||||
|
|
||||||
|
const isConfig = (value: McpEntry | undefined): value is McpConfig =>
|
||||||
|
typeof value === "object" && value !== null && "type" in value
|
||||||
|
|
||||||
|
const split = (value: string) =>
|
||||||
|
value
|
||||||
|
.split("\n")
|
||||||
|
.map((line) => line.trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
|
||||||
|
const parseMap = (value: string, allowColon: boolean) => {
|
||||||
|
const out: Record<string, string> = {}
|
||||||
|
|
||||||
|
for (const line of split(value)) {
|
||||||
|
const eq = line.indexOf("=")
|
||||||
|
const cut = !allowColon ? eq : ([line.indexOf(":"), eq].filter((part) => part > 0).sort((a, b) => a - b)[0] ?? -1)
|
||||||
|
|
||||||
|
if (cut < 1) return { error: line }
|
||||||
|
|
||||||
|
const key = line.slice(0, cut).trim()
|
||||||
|
const item = line.slice(cut + 1).trim()
|
||||||
|
if (!key || !item) return { error: line }
|
||||||
|
out[key] = item
|
||||||
|
}
|
||||||
|
|
||||||
|
return { value: Object.keys(out).length > 0 ? out : undefined }
|
||||||
|
}
|
||||||
|
|
||||||
|
const parseCmd = (value: string) =>
|
||||||
|
(value.match(/"[^"]*"|'[^']*'|[^\s]+/g) ?? []).map((part) => {
|
||||||
|
if (part.startsWith('"') && part.endsWith('"')) return part.slice(1, -1)
|
||||||
|
if (part.startsWith("'") && part.endsWith("'")) return part.slice(1, -1)
|
||||||
|
return part
|
||||||
|
})
|
||||||
|
|
||||||
export const SettingsMcp: Component = () => {
|
export const SettingsMcp: Component = () => {
|
||||||
// TODO: Replace this placeholder with full MCP settings controls.
|
const lang = useLanguage()
|
||||||
const language = useLanguage()
|
const sdk = useGlobalSDK()
|
||||||
|
const sync = useGlobalSync()
|
||||||
|
const [state, setState] = createStore({
|
||||||
|
form: empty(),
|
||||||
|
submitting: "",
|
||||||
|
statusLoading: false,
|
||||||
|
status: {} as Record<string, McpStatus>,
|
||||||
|
})
|
||||||
|
|
||||||
|
const busy = createMemo(() => state.submitting.length > 0)
|
||||||
|
|
||||||
|
const items = createMemo(() => {
|
||||||
|
return Object.entries(sync.data.config.mcp ?? {})
|
||||||
|
.filter((item): item is [string, McpConfig] => isConfig(item[1]))
|
||||||
|
.map(([name, config]) => ({ name, config }))
|
||||||
|
.sort((a, b) => a.name.localeCompare(b.name))
|
||||||
|
})
|
||||||
|
|
||||||
|
const names = createMemo(() => new Set(items().map((item) => item.name)))
|
||||||
|
|
||||||
|
const spin = () => `${lang.t("common.loading")}${lang.t("common.loading.ellipsis")}`
|
||||||
|
|
||||||
|
const kind = (value: Mode) => {
|
||||||
|
if (value === "remote") return lang.t("settings.mcp.type.remote")
|
||||||
|
return lang.t("settings.mcp.type.local")
|
||||||
|
}
|
||||||
|
|
||||||
|
const fail = (description: string) => {
|
||||||
|
showToast({
|
||||||
|
variant: "error",
|
||||||
|
title: lang.t("common.requestFailed"),
|
||||||
|
description,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const load = () => {
|
||||||
|
setState("statusLoading", true)
|
||||||
|
return sdk.client.mcp
|
||||||
|
.status()
|
||||||
|
.then((x) => {
|
||||||
|
setState("status", x.data ?? {})
|
||||||
|
})
|
||||||
|
.catch(() => undefined)
|
||||||
|
.finally(() => {
|
||||||
|
setState("statusLoading", false)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const save = (next: McpMap, job: string, onSuccess: () => void, title: string, description: string) => {
|
||||||
|
const prev = sync.data.config.mcp
|
||||||
|
setState("submitting", job)
|
||||||
|
sync.set("config", "mcp", next)
|
||||||
|
|
||||||
|
sync
|
||||||
|
.updateConfig({ mcp: next })
|
||||||
|
.then(() => {
|
||||||
|
onSuccess()
|
||||||
|
void load()
|
||||||
|
showToast({
|
||||||
|
variant: "success",
|
||||||
|
icon: "circle-check",
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.catch((err: unknown) => {
|
||||||
|
sync.set("config", "mcp", prev)
|
||||||
|
fail(err instanceof Error ? err.message : String(err))
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
setState("submitting", "")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const add = (name: string, config: McpConfig, job: string, reset: boolean) => {
|
||||||
|
const key = name.trim()
|
||||||
|
if (!key) {
|
||||||
|
fail(lang.t("settings.mcp.validation.name"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (names().has(key)) {
|
||||||
|
fail(lang.t("settings.mcp.validation.duplicate", { name: key }))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const next = {
|
||||||
|
...(sync.data.config.mcp ?? {}),
|
||||||
|
[key]: config,
|
||||||
|
}
|
||||||
|
|
||||||
|
save(
|
||||||
|
next,
|
||||||
|
job,
|
||||||
|
() => {
|
||||||
|
if (!reset) return
|
||||||
|
setState("form", empty(state.form.mode))
|
||||||
|
},
|
||||||
|
lang.t("settings.mcp.toast.added.title"),
|
||||||
|
lang.t("settings.mcp.toast.added.description", { name: key }),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const addForm = () => {
|
||||||
|
if (busy()) return
|
||||||
|
|
||||||
|
const timeout = state.form.timeout.trim()
|
||||||
|
const wait = timeout ? Number(timeout) : undefined
|
||||||
|
if (wait !== undefined && (!Number.isInteger(wait) || wait <= 0)) {
|
||||||
|
fail(lang.t("settings.mcp.validation.timeout"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state.form.mode === "remote") {
|
||||||
|
const url = state.form.url.trim()
|
||||||
|
if (!url) {
|
||||||
|
fail(lang.t("settings.mcp.validation.url"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const headers = parseMap(state.form.headers, true)
|
||||||
|
if (headers.error) {
|
||||||
|
fail(lang.t("settings.mcp.validation.headers", { line: headers.error }))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
add(
|
||||||
|
state.form.name,
|
||||||
|
{
|
||||||
|
type: "remote",
|
||||||
|
url,
|
||||||
|
...(headers.value ? { headers: headers.value } : {}),
|
||||||
|
...(wait ? { timeout: wait } : {}),
|
||||||
|
},
|
||||||
|
"form",
|
||||||
|
true,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const command = parseCmd(state.form.command.trim())
|
||||||
|
if (command.length === 0) {
|
||||||
|
fail(lang.t("settings.mcp.validation.command"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const environment = parseMap(state.form.environment, false)
|
||||||
|
if (environment.error) {
|
||||||
|
fail(lang.t("settings.mcp.validation.environment", { line: environment.error }))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
add(
|
||||||
|
state.form.name,
|
||||||
|
{
|
||||||
|
type: "local",
|
||||||
|
command,
|
||||||
|
...(environment.value ? { environment: environment.value } : {}),
|
||||||
|
...(wait ? { timeout: wait } : {}),
|
||||||
|
},
|
||||||
|
"form",
|
||||||
|
true,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const addFeatured = (item: (typeof FEATURED)[number]) => {
|
||||||
|
if (busy()) return
|
||||||
|
add(item.name, item.config, `featured:${item.name}`, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
const toggle = (name: string, enabled: boolean) => {
|
||||||
|
if (busy()) return
|
||||||
|
const current = (sync.data.config.mcp ?? {})[name]
|
||||||
|
if (!isConfig(current)) return
|
||||||
|
|
||||||
|
const next = {
|
||||||
|
...(sync.data.config.mcp ?? {}),
|
||||||
|
[name]: {
|
||||||
|
...current,
|
||||||
|
enabled,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
save(
|
||||||
|
next,
|
||||||
|
`toggle:${name}`,
|
||||||
|
() => undefined,
|
||||||
|
lang.t("settings.mcp.toast.updated.title"),
|
||||||
|
lang.t("settings.mcp.toast.updated.description", {
|
||||||
|
name,
|
||||||
|
state: enabled ? lang.t("settings.mcp.state.enabled") : lang.t("settings.mcp.state.disabled"),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const remove = (name: string) => {
|
||||||
|
if (busy()) return
|
||||||
|
|
||||||
|
const next = { ...(sync.data.config.mcp ?? {}) }
|
||||||
|
delete next[name]
|
||||||
|
|
||||||
|
save(
|
||||||
|
next,
|
||||||
|
`remove:${name}`,
|
||||||
|
() => undefined,
|
||||||
|
lang.t("settings.mcp.toast.removed.title"),
|
||||||
|
lang.t("settings.mcp.toast.removed.description", { name }),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const label = (name: string) => {
|
||||||
|
const value = state.status[name]?.status
|
||||||
|
if (!value) return
|
||||||
|
return lang.t(STATUS[value])
|
||||||
|
}
|
||||||
|
|
||||||
|
const issue = (name: string) => {
|
||||||
|
const value = state.status[name]
|
||||||
|
if (!value || !("error" in value)) return
|
||||||
|
return value.error
|
||||||
|
}
|
||||||
|
|
||||||
|
const line = (config: McpConfig) => {
|
||||||
|
if (config.type === "remote") return config.url
|
||||||
|
return config.command.join(" ")
|
||||||
|
}
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
void load()
|
||||||
|
})
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div class="flex flex-col h-full overflow-y-auto">
|
<div class="flex flex-col h-full overflow-y-auto no-scrollbar px-4 pb-10 sm:px-10 sm:pb-10">
|
||||||
<div class="flex flex-col gap-6 p-6 max-w-[600px]">
|
<div class="sticky top-0 z-10 bg-[linear-gradient(to_bottom,var(--surface-stronger-non-alpha)_calc(100%_-_24px),transparent)]">
|
||||||
<h2 class="text-16-medium text-text-strong">{language.t("settings.mcp.title")}</h2>
|
<div class="flex flex-col gap-1 pt-6 pb-8 max-w-[720px]">
|
||||||
<p class="text-14-regular text-text-weak">{language.t("settings.mcp.description")}</p>
|
<h2 class="text-16-medium text-text-strong">{lang.t("settings.mcp.title")}</h2>
|
||||||
|
<p class="text-14-regular text-text-weak">{lang.t("settings.mcp.description")}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-8 max-w-[720px]">
|
||||||
|
<div class="flex flex-col gap-3">
|
||||||
|
<div class="flex flex-col gap-1">
|
||||||
|
<h3 class="text-14-medium text-text-strong">{lang.t("settings.mcp.section.featured")}</h3>
|
||||||
|
<p class="text-12-regular text-text-weak">{lang.t("settings.mcp.section.featured.description")}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid gap-3 sm:grid-cols-2">
|
||||||
|
<For each={FEATURED}>
|
||||||
|
{(item) => {
|
||||||
|
const added = () => names().has(item.name)
|
||||||
|
const pending = () => state.submitting === `featured:${item.name}`
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="group relative overflow-hidden rounded-2xl border border-border-weak-base p-4 text-left transition-transform duration-200 disabled:cursor-default"
|
||||||
|
classList={{
|
||||||
|
"hover:-translate-y-0.5": !added() && !busy(),
|
||||||
|
"opacity-60": added(),
|
||||||
|
}}
|
||||||
|
disabled={added() || busy()}
|
||||||
|
onClick={() => addFeatured(item)}
|
||||||
|
>
|
||||||
|
<div class="absolute inset-0" aria-hidden="true">
|
||||||
|
<div class="absolute inset-0" style={{ background: item.panel }} />
|
||||||
|
<div
|
||||||
|
class="absolute -right-6 -top-6 size-24 rounded-full blur-2xl"
|
||||||
|
style={{ background: item.glow }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="relative flex flex-col gap-4">
|
||||||
|
<div class="flex items-start justify-between gap-3">
|
||||||
|
<div
|
||||||
|
class="flex size-11 shrink-0 items-center justify-center rounded-2xl border border-border-weak-base"
|
||||||
|
style={{ background: item.badge, color: item.color }}
|
||||||
|
>
|
||||||
|
<Icon name={item.icon} class="size-5" />
|
||||||
|
</div>
|
||||||
|
<span class="rounded-full bg-surface-base px-2.5 py-1 text-11-medium text-text-weak">
|
||||||
|
{kind(item.config.type)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-1">
|
||||||
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
|
<span class="text-14-medium text-text-strong">{item.title}</span>
|
||||||
|
<Show when={added()}>
|
||||||
|
<span class="rounded-full bg-surface-base px-2 py-0.5 text-11-medium text-text-weak">
|
||||||
|
{lang.t("settings.mcp.featured.added")}
|
||||||
|
</span>
|
||||||
|
</Show>
|
||||||
|
<Show when={pending()}>
|
||||||
|
<span class="text-11-regular text-text-weak">{spin()}</span>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
<span class="text-12-regular leading-5 text-text-weak">{item.description}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
</For>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-3">
|
||||||
|
<div class="flex flex-col gap-1">
|
||||||
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
|
<h3 class="text-14-medium text-text-strong">{lang.t("settings.mcp.section.configured")}</h3>
|
||||||
|
<Show when={state.statusLoading}>
|
||||||
|
<span class="text-11-regular text-text-weak">{spin()}</span>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
<p class="text-12-regular text-text-weak">{lang.t("settings.mcp.section.configured.description")}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="bg-surface-raised-base px-4 rounded-lg">
|
||||||
|
<Show
|
||||||
|
when={items().length > 0}
|
||||||
|
fallback={<div class="py-4 text-14-regular text-text-weak">{lang.t("dialog.mcp.empty")}</div>}
|
||||||
|
>
|
||||||
|
<For each={items()}>
|
||||||
|
{(item) => {
|
||||||
|
const current = () => state.status[item.name]?.status
|
||||||
|
const text = () => label(item.name)
|
||||||
|
const problem = () => issue(item.name)
|
||||||
|
const enabled = () => item.config.enabled !== false
|
||||||
|
const pendingToggle = () => state.submitting === `toggle:${item.name}`
|
||||||
|
const pending = () => state.submitting === `remove:${item.name}`
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div class="flex flex-wrap items-start justify-between gap-4 py-4 border-b border-border-weak-base last:border-none">
|
||||||
|
<div class="min-w-0 flex-1 flex flex-col gap-2">
|
||||||
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
|
<span class="text-14-medium text-text-strong">{item.name}</span>
|
||||||
|
<Tag>{kind(item.config.type)}</Tag>
|
||||||
|
<Show when={text()}>
|
||||||
|
<span
|
||||||
|
class="rounded-full bg-surface-base px-2 py-0.5 text-11-medium"
|
||||||
|
classList={{
|
||||||
|
"text-icon-success-base": current() === "connected",
|
||||||
|
"text-icon-warning-base": current() === "needs_auth",
|
||||||
|
"text-icon-critical-base":
|
||||||
|
current() === "failed" || current() === "needs_client_registration",
|
||||||
|
"text-text-weak": current() === "disabled",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{text()}
|
||||||
|
</span>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<span class="text-12-regular text-text-weak break-all">{line(item.config)}</span>
|
||||||
|
|
||||||
|
<Show when={problem()}>
|
||||||
|
<span class="text-12-regular text-icon-critical-base break-all">{problem()}</span>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center gap-3" onClick={(e) => e.stopPropagation()}>
|
||||||
|
<div class="flex items-center gap-2 pl-1">
|
||||||
|
<Show when={pendingToggle()}>
|
||||||
|
<span class="text-11-regular text-text-weak">{spin()}</span>
|
||||||
|
</Show>
|
||||||
|
<Switch
|
||||||
|
checked={enabled()}
|
||||||
|
disabled={busy()}
|
||||||
|
onChange={(next) => toggle(item.name, next)}
|
||||||
|
hideLabel
|
||||||
|
>
|
||||||
|
{item.name}
|
||||||
|
</Switch>
|
||||||
|
<span class="text-12-regular text-text-weak">
|
||||||
|
{enabled() ? lang.t("settings.mcp.state.enabled") : lang.t("settings.mcp.state.disabled")}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button size="large" variant="ghost" disabled={busy()} onClick={() => remove(item.name)}>
|
||||||
|
{pending() ? spin() : lang.t("settings.mcp.action.remove")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
</For>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-3">
|
||||||
|
<div class="flex flex-col gap-1">
|
||||||
|
<h3 class="text-14-medium text-text-strong">{lang.t("settings.mcp.section.add")}</h3>
|
||||||
|
<p class="text-12-regular text-text-weak">{lang.t("settings.mcp.section.add.description")}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="rounded-2xl border border-border-weak-base bg-surface-raised-base p-4 sm:p-5">
|
||||||
|
<div class="flex flex-col gap-4">
|
||||||
|
<div class="flex flex-col gap-2">
|
||||||
|
<span class="text-12-medium text-text-strong">{lang.t("settings.mcp.form.type.label")}</span>
|
||||||
|
<div class="inline-flex w-full gap-1 rounded-xl bg-surface-base p-1 sm:w-auto">
|
||||||
|
<For each={["remote", "local"] as const}>
|
||||||
|
{(mode) => (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="h-9 flex-1 rounded-lg px-3 text-12-medium transition-colors sm:flex-none"
|
||||||
|
classList={{
|
||||||
|
"bg-surface-raised-base text-text-strong": state.form.mode === mode,
|
||||||
|
"text-text-weak": state.form.mode !== mode,
|
||||||
|
}}
|
||||||
|
onClick={() => {
|
||||||
|
if (busy()) return
|
||||||
|
setState("form", "mode", mode)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{kind(mode)}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid gap-4 sm:grid-cols-2">
|
||||||
|
<TextField
|
||||||
|
label={lang.t("settings.mcp.form.name.label")}
|
||||||
|
value={state.form.name}
|
||||||
|
onChange={(value) => setState("form", "name", value)}
|
||||||
|
placeholder={lang.t("settings.mcp.form.name.placeholder")}
|
||||||
|
spellcheck={false}
|
||||||
|
autocorrect="off"
|
||||||
|
autocomplete="off"
|
||||||
|
autocapitalize="off"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TextField
|
||||||
|
label={lang.t("settings.mcp.form.timeout.label")}
|
||||||
|
value={state.form.timeout}
|
||||||
|
onChange={(value) => setState("form", "timeout", value)}
|
||||||
|
placeholder={lang.t("settings.mcp.form.timeout.placeholder")}
|
||||||
|
inputMode="numeric"
|
||||||
|
spellcheck={false}
|
||||||
|
autocorrect="off"
|
||||||
|
autocomplete="off"
|
||||||
|
autocapitalize="off"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Show
|
||||||
|
when={state.form.mode === "remote"}
|
||||||
|
fallback={
|
||||||
|
<>
|
||||||
|
<TextField
|
||||||
|
label={lang.t("settings.mcp.form.command.label")}
|
||||||
|
value={state.form.command}
|
||||||
|
onChange={(value) => setState("form", "command", value)}
|
||||||
|
placeholder={lang.t("settings.mcp.form.command.placeholder")}
|
||||||
|
spellcheck={false}
|
||||||
|
autocorrect="off"
|
||||||
|
autocomplete="off"
|
||||||
|
autocapitalize="off"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TextField
|
||||||
|
label={lang.t("settings.mcp.form.environment.label")}
|
||||||
|
description={lang.t("settings.mcp.form.environment.description")}
|
||||||
|
value={state.form.environment}
|
||||||
|
onChange={(value) => setState("form", "environment", value)}
|
||||||
|
placeholder="API_KEY={env:API_KEY}"
|
||||||
|
multiline
|
||||||
|
rows={4}
|
||||||
|
spellcheck={false}
|
||||||
|
autocorrect="off"
|
||||||
|
autocomplete="off"
|
||||||
|
autocapitalize="off"
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<TextField
|
||||||
|
label={lang.t("settings.mcp.form.url.label")}
|
||||||
|
value={state.form.url}
|
||||||
|
onChange={(value) => setState("form", "url", value)}
|
||||||
|
placeholder={lang.t("settings.mcp.form.url.placeholder")}
|
||||||
|
spellcheck={false}
|
||||||
|
autocorrect="off"
|
||||||
|
autocomplete="off"
|
||||||
|
autocapitalize="off"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TextField
|
||||||
|
label={lang.t("settings.mcp.form.headers.label")}
|
||||||
|
description={lang.t("settings.mcp.form.headers.description")}
|
||||||
|
value={state.form.headers}
|
||||||
|
onChange={(value) => setState("form", "headers", value)}
|
||||||
|
placeholder="Authorization: Bearer {env:API_KEY}"
|
||||||
|
multiline
|
||||||
|
rows={4}
|
||||||
|
spellcheck={false}
|
||||||
|
autocorrect="off"
|
||||||
|
autocomplete="off"
|
||||||
|
autocapitalize="off"
|
||||||
|
/>
|
||||||
|
</Show>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
size="large"
|
||||||
|
variant="secondary"
|
||||||
|
icon="plus-small"
|
||||||
|
class="w-full justify-center sm:w-auto"
|
||||||
|
disabled={busy()}
|
||||||
|
onClick={addForm}
|
||||||
|
>
|
||||||
|
{state.submitting === "form" ? spin() : lang.t("settings.mcp.action.add")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -217,7 +217,7 @@ export const Terminal = (props: TerminalProps) => {
|
|||||||
const currentTheme = theme.themes()[theme.themeId()]
|
const currentTheme = theme.themes()[theme.themeId()]
|
||||||
if (!currentTheme) return fallback
|
if (!currentTheme) return fallback
|
||||||
const variant = mode === "dark" ? currentTheme.dark : currentTheme.light
|
const variant = mode === "dark" ? currentTheme.dark : currentTheme.light
|
||||||
if (!variant?.seeds && !variant?.palette) return fallback
|
if (!variant?.seeds) return fallback
|
||||||
const resolved = resolveThemeVariant(variant, mode === "dark")
|
const resolved = resolveThemeVariant(variant, mode === "dark")
|
||||||
const text = resolved["text-stronger"] ?? fallback.foreground
|
const text = resolved["text-stronger"] ?? fallback.foreground
|
||||||
const background = resolved["background-stronger"] ?? fallback.background
|
const background = resolved["background-stronger"] ?? fallback.background
|
||||||
|
|||||||
@@ -155,7 +155,7 @@ export function Titlebar() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<header
|
<header
|
||||||
class="h-10 shrink-0 bg-background-base relative grid grid-cols-[minmax(0,1fr)_auto_minmax(0,1fr)] items-center"
|
class="h-10 shrink-0 bg-background-base relative grid grid-cols-[auto_minmax(0,1fr)_auto] items-center"
|
||||||
style={{ "min-height": minHeight() }}
|
style={{ "min-height": minHeight() }}
|
||||||
data-tauri-drag-region
|
data-tauri-drag-region
|
||||||
onMouseDown={drag}
|
onMouseDown={drag}
|
||||||
@@ -269,7 +269,7 @@ export function Titlebar() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="min-w-0 flex items-center justify-center pointer-events-none">
|
<div class="min-w-0 flex items-center justify-center pointer-events-none">
|
||||||
<div id="opencode-titlebar-center" class="pointer-events-auto min-w-0 flex justify-center w-fit max-w-full" />
|
<div id="opencode-titlebar-center" class="pointer-events-auto w-full min-w-0 flex justify-center lg:w-fit" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ import type { InitError } from "../pages/error"
|
|||||||
import { useGlobalSDK } from "./global-sdk"
|
import { useGlobalSDK } from "./global-sdk"
|
||||||
import { bootstrapDirectory, bootstrapGlobal } from "./global-sync/bootstrap"
|
import { bootstrapDirectory, bootstrapGlobal } from "./global-sync/bootstrap"
|
||||||
import { createChildStoreManager } from "./global-sync/child-store"
|
import { createChildStoreManager } from "./global-sync/child-store"
|
||||||
import { applyDirectoryEvent, applyGlobalEvent, cleanupDroppedSessionCaches } from "./global-sync/event-reducer"
|
import { applyDirectoryEvent, applyGlobalEvent } from "./global-sync/event-reducer"
|
||||||
import { createRefreshQueue } from "./global-sync/queue"
|
import { createRefreshQueue } from "./global-sync/queue"
|
||||||
import { estimateRootSessionTotal, loadRootSessionsWithFallback } from "./global-sync/session-load"
|
import { estimateRootSessionTotal, loadRootSessionsWithFallback } from "./global-sync/session-load"
|
||||||
import { trimSessions } from "./global-sync/session-trim"
|
import { trimSessions } from "./global-sync/session-trim"
|
||||||
@@ -189,7 +189,6 @@ function createGlobalSync() {
|
|||||||
})
|
})
|
||||||
if (next.length !== store.session.length) {
|
if (next.length !== store.session.length) {
|
||||||
setStore("session", reconcile(next, { key: "id" }))
|
setStore("session", reconcile(next, { key: "id" }))
|
||||||
cleanupDroppedSessionCaches(store, setStore, next, setSessionTodo)
|
|
||||||
}
|
}
|
||||||
children.unpin(directory)
|
children.unpin(directory)
|
||||||
return
|
return
|
||||||
@@ -221,7 +220,6 @@ function createGlobalSync() {
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
setStore("session", reconcile(sessions, { key: "id" }))
|
setStore("session", reconcile(sessions, { key: "id" }))
|
||||||
cleanupDroppedSessionCaches(store, setStore, sessions, setSessionTodo)
|
|
||||||
sessionMeta.set(directory, { limit })
|
sessionMeta.set(directory, { limit })
|
||||||
})
|
})
|
||||||
.catch((err) => {
|
.catch((err) => {
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { describe, expect, test } from "bun:test"
|
|||||||
import type { Message, Part, PermissionRequest, Project, QuestionRequest, Session } from "@opencode-ai/sdk/v2/client"
|
import type { Message, Part, PermissionRequest, Project, QuestionRequest, Session } from "@opencode-ai/sdk/v2/client"
|
||||||
import { createStore } from "solid-js/store"
|
import { createStore } from "solid-js/store"
|
||||||
import type { State } from "./types"
|
import type { State } from "./types"
|
||||||
import { applyDirectoryEvent, applyGlobalEvent, cleanupDroppedSessionCaches } from "./event-reducer"
|
import { applyDirectoryEvent, applyGlobalEvent } from "./event-reducer"
|
||||||
|
|
||||||
const rootSession = (input: { id: string; parentID?: string; archived?: number }) =>
|
const rootSession = (input: { id: string; parentID?: string; archived?: number }) =>
|
||||||
({
|
({
|
||||||
@@ -248,62 +248,6 @@ describe("applyDirectoryEvent", () => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
test("cleans caches for trimmed sessions on session.created", () => {
|
|
||||||
const dropped = rootSession({ id: "ses_b" })
|
|
||||||
const kept = rootSession({ id: "ses_a" })
|
|
||||||
const message = userMessage("msg_1", dropped.id)
|
|
||||||
const todos: string[] = []
|
|
||||||
const [store, setStore] = createStore(
|
|
||||||
baseState({
|
|
||||||
limit: 1,
|
|
||||||
session: [dropped],
|
|
||||||
message: { [dropped.id]: [message] },
|
|
||||||
part: { [message.id]: [textPart("prt_1", dropped.id, message.id)] },
|
|
||||||
session_diff: { [dropped.id]: [] },
|
|
||||||
todo: { [dropped.id]: [] },
|
|
||||||
permission: { [dropped.id]: [] },
|
|
||||||
question: { [dropped.id]: [] },
|
|
||||||
session_status: { [dropped.id]: { type: "busy" } },
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
applyDirectoryEvent({
|
|
||||||
event: { type: "session.created", properties: { info: kept } },
|
|
||||||
store,
|
|
||||||
setStore,
|
|
||||||
push() {},
|
|
||||||
directory: "/tmp",
|
|
||||||
loadLsp() {},
|
|
||||||
setSessionTodo(sessionID, value) {
|
|
||||||
if (value !== undefined) return
|
|
||||||
todos.push(sessionID)
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
expect(store.session.map((x) => x.id)).toEqual([kept.id])
|
|
||||||
expect(store.message[dropped.id]).toBeUndefined()
|
|
||||||
expect(store.part[message.id]).toBeUndefined()
|
|
||||||
expect(store.session_diff[dropped.id]).toBeUndefined()
|
|
||||||
expect(store.todo[dropped.id]).toBeUndefined()
|
|
||||||
expect(store.permission[dropped.id]).toBeUndefined()
|
|
||||||
expect(store.question[dropped.id]).toBeUndefined()
|
|
||||||
expect(store.session_status[dropped.id]).toBeUndefined()
|
|
||||||
expect(todos).toEqual([dropped.id])
|
|
||||||
})
|
|
||||||
|
|
||||||
test("cleanupDroppedSessionCaches clears part-only orphan state", () => {
|
|
||||||
const [store, setStore] = createStore(
|
|
||||||
baseState({
|
|
||||||
session: [rootSession({ id: "ses_keep" })],
|
|
||||||
part: { msg_1: [textPart("prt_1", "ses_drop", "msg_1")] },
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
cleanupDroppedSessionCaches(store, setStore, store.session)
|
|
||||||
|
|
||||||
expect(store.part.msg_1).toBeUndefined()
|
|
||||||
})
|
|
||||||
|
|
||||||
test("upserts and removes messages while clearing orphaned parts", () => {
|
test("upserts and removes messages while clearing orphaned parts", () => {
|
||||||
const sessionID = "ses_1"
|
const sessionID = "ses_1"
|
||||||
const [store, setStore] = createStore(
|
const [store, setStore] = createStore(
|
||||||
|
|||||||
@@ -13,7 +13,6 @@ import type {
|
|||||||
} from "@opencode-ai/sdk/v2/client"
|
} from "@opencode-ai/sdk/v2/client"
|
||||||
import type { State, VcsCache } from "./types"
|
import type { State, VcsCache } from "./types"
|
||||||
import { trimSessions } from "./session-trim"
|
import { trimSessions } from "./session-trim"
|
||||||
import { dropSessionCaches } from "./session-cache"
|
|
||||||
|
|
||||||
export function applyGlobalEvent(input: {
|
export function applyGlobalEvent(input: {
|
||||||
event: { type: string; properties?: unknown }
|
event: { type: string; properties?: unknown }
|
||||||
@@ -41,44 +40,37 @@ export function applyGlobalEvent(input: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function cleanupSessionCaches(
|
function cleanupSessionCaches(
|
||||||
|
store: Store<State>,
|
||||||
setStore: SetStoreFunction<State>,
|
setStore: SetStoreFunction<State>,
|
||||||
sessionID: string,
|
sessionID: string,
|
||||||
setSessionTodo?: (sessionID: string, todos: Todo[] | undefined) => void,
|
setSessionTodo?: (sessionID: string, todos: Todo[] | undefined) => void,
|
||||||
) {
|
) {
|
||||||
if (!sessionID) return
|
if (!sessionID) return
|
||||||
|
const hasAny =
|
||||||
|
store.message[sessionID] !== undefined ||
|
||||||
|
store.session_diff[sessionID] !== undefined ||
|
||||||
|
store.todo[sessionID] !== undefined ||
|
||||||
|
store.permission[sessionID] !== undefined ||
|
||||||
|
store.question[sessionID] !== undefined ||
|
||||||
|
store.session_status[sessionID] !== undefined
|
||||||
setSessionTodo?.(sessionID, undefined)
|
setSessionTodo?.(sessionID, undefined)
|
||||||
|
if (!hasAny) return
|
||||||
setStore(
|
setStore(
|
||||||
produce((draft) => {
|
produce((draft) => {
|
||||||
dropSessionCaches(draft, [sessionID])
|
const messages = draft.message[sessionID]
|
||||||
}),
|
if (messages) {
|
||||||
)
|
for (const message of messages) {
|
||||||
|
const id = message?.id
|
||||||
|
if (!id) continue
|
||||||
|
delete draft.part[id]
|
||||||
}
|
}
|
||||||
|
|
||||||
export function cleanupDroppedSessionCaches(
|
|
||||||
store: Store<State>,
|
|
||||||
setStore: SetStoreFunction<State>,
|
|
||||||
next: Session[],
|
|
||||||
setSessionTodo?: (sessionID: string, todos: Todo[] | undefined) => void,
|
|
||||||
) {
|
|
||||||
const keep = new Set(next.map((item) => item.id))
|
|
||||||
const stale = [
|
|
||||||
...Object.keys(store.message),
|
|
||||||
...Object.keys(store.session_diff),
|
|
||||||
...Object.keys(store.todo),
|
|
||||||
...Object.keys(store.permission),
|
|
||||||
...Object.keys(store.question),
|
|
||||||
...Object.keys(store.session_status),
|
|
||||||
...Object.values(store.part)
|
|
||||||
.map((parts) => parts?.find((part) => !!part?.sessionID)?.sessionID)
|
|
||||||
.filter((sessionID): sessionID is string => !!sessionID),
|
|
||||||
].filter((sessionID, index, list) => !keep.has(sessionID) && list.indexOf(sessionID) === index)
|
|
||||||
if (stale.length === 0) return
|
|
||||||
for (const sessionID of stale) {
|
|
||||||
setSessionTodo?.(sessionID, undefined)
|
|
||||||
}
|
}
|
||||||
setStore(
|
delete draft.message[sessionID]
|
||||||
produce((draft) => {
|
delete draft.session_diff[sessionID]
|
||||||
dropSessionCaches(draft, stale)
|
delete draft.todo[sessionID]
|
||||||
|
delete draft.permission[sessionID]
|
||||||
|
delete draft.question[sessionID]
|
||||||
|
delete draft.session_status[sessionID]
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -110,7 +102,6 @@ export function applyDirectoryEvent(input: {
|
|||||||
next.splice(result.index, 0, info)
|
next.splice(result.index, 0, info)
|
||||||
const trimmed = trimSessions(next, { limit: input.store.limit, permission: input.store.permission })
|
const trimmed = trimSessions(next, { limit: input.store.limit, permission: input.store.permission })
|
||||||
input.setStore("session", reconcile(trimmed, { key: "id" }))
|
input.setStore("session", reconcile(trimmed, { key: "id" }))
|
||||||
cleanupDroppedSessionCaches(input.store, input.setStore, trimmed, input.setSessionTodo)
|
|
||||||
if (!info.parentID) input.setStore("sessionTotal", (value) => value + 1)
|
if (!info.parentID) input.setStore("sessionTotal", (value) => value + 1)
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
@@ -126,7 +117,7 @@ export function applyDirectoryEvent(input: {
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
cleanupSessionCaches(input.setStore, info.id, input.setSessionTodo)
|
cleanupSessionCaches(input.store, input.setStore, info.id, input.setSessionTodo)
|
||||||
if (info.parentID) break
|
if (info.parentID) break
|
||||||
input.setStore("sessionTotal", (value) => Math.max(0, value - 1))
|
input.setStore("sessionTotal", (value) => Math.max(0, value - 1))
|
||||||
break
|
break
|
||||||
@@ -139,7 +130,6 @@ export function applyDirectoryEvent(input: {
|
|||||||
next.splice(result.index, 0, info)
|
next.splice(result.index, 0, info)
|
||||||
const trimmed = trimSessions(next, { limit: input.store.limit, permission: input.store.permission })
|
const trimmed = trimSessions(next, { limit: input.store.limit, permission: input.store.permission })
|
||||||
input.setStore("session", reconcile(trimmed, { key: "id" }))
|
input.setStore("session", reconcile(trimmed, { key: "id" }))
|
||||||
cleanupDroppedSessionCaches(input.store, input.setStore, trimmed, input.setSessionTodo)
|
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
case "session.deleted": {
|
case "session.deleted": {
|
||||||
@@ -153,7 +143,7 @@ export function applyDirectoryEvent(input: {
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
cleanupSessionCaches(input.setStore, info.id, input.setSessionTodo)
|
cleanupSessionCaches(input.store, input.setStore, info.id, input.setSessionTodo)
|
||||||
if (info.parentID) break
|
if (info.parentID) break
|
||||||
input.setStore("sessionTotal", (value) => Math.max(0, value - 1))
|
input.setStore("sessionTotal", (value) => Math.max(0, value - 1))
|
||||||
break
|
break
|
||||||
|
|||||||
@@ -1,102 +0,0 @@
|
|||||||
import { describe, expect, test } from "bun:test"
|
|
||||||
import type {
|
|
||||||
FileDiff,
|
|
||||||
Message,
|
|
||||||
Part,
|
|
||||||
PermissionRequest,
|
|
||||||
QuestionRequest,
|
|
||||||
SessionStatus,
|
|
||||||
Todo,
|
|
||||||
} from "@opencode-ai/sdk/v2/client"
|
|
||||||
import { dropSessionCaches, pickSessionCacheEvictions } from "./session-cache"
|
|
||||||
|
|
||||||
const msg = (id: string, sessionID: string) =>
|
|
||||||
({
|
|
||||||
id,
|
|
||||||
sessionID,
|
|
||||||
role: "user",
|
|
||||||
time: { created: 1 },
|
|
||||||
agent: "assistant",
|
|
||||||
model: { providerID: "openai", modelID: "gpt" },
|
|
||||||
}) as Message
|
|
||||||
|
|
||||||
const part = (id: string, sessionID: string, messageID: string) =>
|
|
||||||
({
|
|
||||||
id,
|
|
||||||
sessionID,
|
|
||||||
messageID,
|
|
||||||
type: "text",
|
|
||||||
text: id,
|
|
||||||
}) as Part
|
|
||||||
|
|
||||||
describe("app session cache", () => {
|
|
||||||
test("dropSessionCaches clears orphaned parts without message rows", () => {
|
|
||||||
const store: {
|
|
||||||
session_status: Record<string, SessionStatus | undefined>
|
|
||||||
session_diff: Record<string, FileDiff[] | undefined>
|
|
||||||
todo: Record<string, Todo[] | undefined>
|
|
||||||
message: Record<string, Message[] | undefined>
|
|
||||||
part: Record<string, Part[] | undefined>
|
|
||||||
permission: Record<string, PermissionRequest[] | undefined>
|
|
||||||
question: Record<string, QuestionRequest[] | undefined>
|
|
||||||
} = {
|
|
||||||
session_status: { ses_1: { type: "busy" } as SessionStatus },
|
|
||||||
session_diff: { ses_1: [] },
|
|
||||||
todo: { ses_1: [] as Todo[] },
|
|
||||||
message: {},
|
|
||||||
part: { msg_1: [part("prt_1", "ses_1", "msg_1")] },
|
|
||||||
permission: { ses_1: [] as PermissionRequest[] },
|
|
||||||
question: { ses_1: [] as QuestionRequest[] },
|
|
||||||
}
|
|
||||||
|
|
||||||
dropSessionCaches(store, ["ses_1"])
|
|
||||||
|
|
||||||
expect(store.message.ses_1).toBeUndefined()
|
|
||||||
expect(store.part.msg_1).toBeUndefined()
|
|
||||||
expect(store.todo.ses_1).toBeUndefined()
|
|
||||||
expect(store.session_diff.ses_1).toBeUndefined()
|
|
||||||
expect(store.session_status.ses_1).toBeUndefined()
|
|
||||||
expect(store.permission.ses_1).toBeUndefined()
|
|
||||||
expect(store.question.ses_1).toBeUndefined()
|
|
||||||
})
|
|
||||||
|
|
||||||
test("dropSessionCaches clears message-backed parts", () => {
|
|
||||||
const m = msg("msg_1", "ses_1")
|
|
||||||
const store: {
|
|
||||||
session_status: Record<string, SessionStatus | undefined>
|
|
||||||
session_diff: Record<string, FileDiff[] | undefined>
|
|
||||||
todo: Record<string, Todo[] | undefined>
|
|
||||||
message: Record<string, Message[] | undefined>
|
|
||||||
part: Record<string, Part[] | undefined>
|
|
||||||
permission: Record<string, PermissionRequest[] | undefined>
|
|
||||||
question: Record<string, QuestionRequest[] | undefined>
|
|
||||||
} = {
|
|
||||||
session_status: {},
|
|
||||||
session_diff: {},
|
|
||||||
todo: {},
|
|
||||||
message: { ses_1: [m] },
|
|
||||||
part: { [m.id]: [part("prt_1", "ses_1", m.id)] },
|
|
||||||
permission: {},
|
|
||||||
question: {},
|
|
||||||
}
|
|
||||||
|
|
||||||
dropSessionCaches(store, ["ses_1"])
|
|
||||||
|
|
||||||
expect(store.message.ses_1).toBeUndefined()
|
|
||||||
expect(store.part[m.id]).toBeUndefined()
|
|
||||||
})
|
|
||||||
|
|
||||||
test("pickSessionCacheEvictions preserves requested sessions", () => {
|
|
||||||
const seen = new Set(["ses_1", "ses_2", "ses_3"])
|
|
||||||
|
|
||||||
const stale = pickSessionCacheEvictions({
|
|
||||||
seen,
|
|
||||||
keep: "ses_4",
|
|
||||||
limit: 2,
|
|
||||||
preserve: ["ses_1"],
|
|
||||||
})
|
|
||||||
|
|
||||||
expect(stale).toEqual(["ses_2", "ses_3"])
|
|
||||||
expect([...seen]).toEqual(["ses_1", "ses_4"])
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -1,62 +0,0 @@
|
|||||||
import type {
|
|
||||||
FileDiff,
|
|
||||||
Message,
|
|
||||||
Part,
|
|
||||||
PermissionRequest,
|
|
||||||
QuestionRequest,
|
|
||||||
SessionStatus,
|
|
||||||
Todo,
|
|
||||||
} from "@opencode-ai/sdk/v2/client"
|
|
||||||
|
|
||||||
export const SESSION_CACHE_LIMIT = 40
|
|
||||||
|
|
||||||
type SessionCache = {
|
|
||||||
session_status: Record<string, SessionStatus | undefined>
|
|
||||||
session_diff: Record<string, FileDiff[] | undefined>
|
|
||||||
todo: Record<string, Todo[] | undefined>
|
|
||||||
message: Record<string, Message[] | undefined>
|
|
||||||
part: Record<string, Part[] | undefined>
|
|
||||||
permission: Record<string, PermissionRequest[] | undefined>
|
|
||||||
question: Record<string, QuestionRequest[] | undefined>
|
|
||||||
}
|
|
||||||
|
|
||||||
export function dropSessionCaches(store: SessionCache, sessionIDs: Iterable<string>) {
|
|
||||||
const stale = new Set(Array.from(sessionIDs).filter(Boolean))
|
|
||||||
if (stale.size === 0) return
|
|
||||||
|
|
||||||
for (const key of Object.keys(store.part)) {
|
|
||||||
const parts = store.part[key]
|
|
||||||
if (!parts?.some((part) => stale.has(part?.sessionID ?? ""))) continue
|
|
||||||
delete store.part[key]
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const sessionID of stale) {
|
|
||||||
delete store.message[sessionID]
|
|
||||||
delete store.todo[sessionID]
|
|
||||||
delete store.session_diff[sessionID]
|
|
||||||
delete store.session_status[sessionID]
|
|
||||||
delete store.permission[sessionID]
|
|
||||||
delete store.question[sessionID]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function pickSessionCacheEvictions(input: {
|
|
||||||
seen: Set<string>
|
|
||||||
keep: string
|
|
||||||
limit: number
|
|
||||||
preserve?: Iterable<string>
|
|
||||||
}) {
|
|
||||||
const stale: string[] = []
|
|
||||||
const keep = new Set([input.keep, ...Array.from(input.preserve ?? [])])
|
|
||||||
if (input.seen.has(input.keep)) input.seen.delete(input.keep)
|
|
||||||
input.seen.add(input.keep)
|
|
||||||
for (const id of input.seen) {
|
|
||||||
if (input.seen.size - stale.length <= input.limit) break
|
|
||||||
if (keep.has(id)) continue
|
|
||||||
stale.push(id)
|
|
||||||
}
|
|
||||||
for (const id of stale) {
|
|
||||||
input.seen.delete(id)
|
|
||||||
}
|
|
||||||
return stale
|
|
||||||
}
|
|
||||||
@@ -146,7 +146,6 @@ 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") },
|
||||||
@@ -218,7 +217,6 @@ 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, isDirectoryAutoAccepting } from "./permission-auto-respond"
|
import { autoRespondsPermission } from "./permission-auto-respond"
|
||||||
|
|
||||||
const session = (input: { id: string; parentID?: string }) =>
|
const session = (input: { id: string; parentID?: string }) =>
|
||||||
({
|
({
|
||||||
@@ -60,43 +60,4 @@ 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,19 +5,9 @@ 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)
|
||||||
const directoryKey = directory ? directoryAcceptKey(directory) : undefined
|
return autoAccept[key] ?? autoAccept[sessionID]
|
||||||
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 { createEffect, createMemo, onCleanup } from "solid-js"
|
import { 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,12 +7,7 @@ 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 {
|
import { acceptKey, autoRespondsPermission } from "./permission-auto-respond"
|
||||||
acceptKey,
|
|
||||||
directoryAcceptKey,
|
|
||||||
isDirectoryAutoAccepting,
|
|
||||||
autoRespondsPermission,
|
|
||||||
} from "./permission-auto-respond"
|
|
||||||
|
|
||||||
type PermissionRespondFn = (input: {
|
type PermissionRespondFn = (input: {
|
||||||
sessionID: string
|
sessionID: string
|
||||||
@@ -81,25 +76,6 @@ 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>()
|
||||||
@@ -143,10 +119,6 @@ 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)
|
||||||
@@ -170,36 +142,6 @@ 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)
|
||||||
@@ -243,7 +185,6 @@ 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)
|
||||||
@@ -252,13 +193,6 @@ 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)
|
||||||
@@ -267,11 +201,6 @@ 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"
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import { createSimpleContext } from "@opencode-ai/ui/context"
|
|||||||
import { useGlobalSync } from "./global-sync"
|
import { useGlobalSync } from "./global-sync"
|
||||||
import { useSDK } from "./sdk"
|
import { useSDK } from "./sdk"
|
||||||
import type { Message, Part } from "@opencode-ai/sdk/v2/client"
|
import type { Message, Part } from "@opencode-ai/sdk/v2/client"
|
||||||
import { SESSION_CACHE_LIMIT, dropSessionCaches, pickSessionCacheEvictions } from "./global-sync/session-cache"
|
|
||||||
|
|
||||||
function sortParts(parts: Part[]) {
|
function sortParts(parts: Part[]) {
|
||||||
return parts.filter((part) => !!part?.id).sort((a, b) => cmp(a.id, b.id))
|
return parts.filter((part) => !!part?.id).sort((a, b) => cmp(a.id, b.id))
|
||||||
@@ -109,8 +108,6 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
|||||||
const inflight = new Map<string, Promise<void>>()
|
const inflight = new Map<string, Promise<void>>()
|
||||||
const inflightDiff = new Map<string, Promise<void>>()
|
const inflightDiff = new Map<string, Promise<void>>()
|
||||||
const inflightTodo = new Map<string, Promise<void>>()
|
const inflightTodo = new Map<string, Promise<void>>()
|
||||||
const maxDirs = 30
|
|
||||||
const seen = new Map<string, Set<string>>()
|
|
||||||
const [meta, setMeta] = createStore({
|
const [meta, setMeta] = createStore({
|
||||||
limit: {} as Record<string, number>,
|
limit: {} as Record<string, number>,
|
||||||
complete: {} as Record<string, boolean>,
|
complete: {} as Record<string, boolean>,
|
||||||
@@ -124,62 +121,6 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
|||||||
return undefined
|
return undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
const seenFor = (directory: string) => {
|
|
||||||
const existing = seen.get(directory)
|
|
||||||
if (existing) {
|
|
||||||
seen.delete(directory)
|
|
||||||
seen.set(directory, existing)
|
|
||||||
return existing
|
|
||||||
}
|
|
||||||
const created = new Set<string>()
|
|
||||||
seen.set(directory, created)
|
|
||||||
while (seen.size > maxDirs) {
|
|
||||||
const first = seen.keys().next().value
|
|
||||||
if (!first) break
|
|
||||||
const stale = [...(seen.get(first) ?? [])]
|
|
||||||
seen.delete(first)
|
|
||||||
const [, setStore] = globalSync.child(first, { bootstrap: false })
|
|
||||||
evict(first, setStore, stale)
|
|
||||||
}
|
|
||||||
return created
|
|
||||||
}
|
|
||||||
|
|
||||||
const clearMeta = (directory: string, sessionIDs: string[]) => {
|
|
||||||
if (sessionIDs.length === 0) return
|
|
||||||
setMeta(
|
|
||||||
produce((draft) => {
|
|
||||||
for (const sessionID of sessionIDs) {
|
|
||||||
const key = keyFor(directory, sessionID)
|
|
||||||
delete draft.limit[key]
|
|
||||||
delete draft.complete[key]
|
|
||||||
delete draft.loading[key]
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const evict = (directory: string, setStore: Setter, sessionIDs: string[]) => {
|
|
||||||
if (sessionIDs.length === 0) return
|
|
||||||
for (const sessionID of sessionIDs) {
|
|
||||||
globalSync.todo.set(sessionID, undefined)
|
|
||||||
}
|
|
||||||
setStore(
|
|
||||||
produce((draft) => {
|
|
||||||
dropSessionCaches(draft, sessionIDs)
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
clearMeta(directory, sessionIDs)
|
|
||||||
}
|
|
||||||
|
|
||||||
const touch = (directory: string, setStore: Setter, sessionID: string) => {
|
|
||||||
const stale = pickSessionCacheEvictions({
|
|
||||||
seen: seenFor(directory),
|
|
||||||
keep: sessionID,
|
|
||||||
limit: SESSION_CACHE_LIMIT,
|
|
||||||
})
|
|
||||||
evict(directory, setStore, stale)
|
|
||||||
}
|
|
||||||
|
|
||||||
const fetchMessages = async (input: { client: typeof sdk.client; sessionID: string; limit: number }) => {
|
const fetchMessages = async (input: { client: typeof sdk.client; sessionID: string; limit: number }) => {
|
||||||
const messages = await retry(() =>
|
const messages = await retry(() =>
|
||||||
input.client.session.messages({ sessionID: input.sessionID, limit: input.limit }),
|
input.client.session.messages({ sessionID: input.sessionID, limit: input.limit }),
|
||||||
@@ -194,8 +135,6 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const tracked = (directory: string, sessionID: string) => seen.get(directory)?.has(sessionID) ?? false
|
|
||||||
|
|
||||||
const loadMessages = async (input: {
|
const loadMessages = async (input: {
|
||||||
directory: string
|
directory: string
|
||||||
client: typeof sdk.client
|
client: typeof sdk.client
|
||||||
@@ -209,7 +148,6 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
|||||||
setMeta("loading", key, true)
|
setMeta("loading", key, true)
|
||||||
await fetchMessages(input)
|
await fetchMessages(input)
|
||||||
.then((next) => {
|
.then((next) => {
|
||||||
if (!tracked(input.directory, input.sessionID)) return
|
|
||||||
batch(() => {
|
batch(() => {
|
||||||
input.setStore("message", input.sessionID, reconcile(next.session, { key: "id" }))
|
input.setStore("message", input.sessionID, reconcile(next.session, { key: "id" }))
|
||||||
for (const p of next.part) {
|
for (const p of next.part) {
|
||||||
@@ -220,7 +158,6 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
.finally(() => {
|
.finally(() => {
|
||||||
if (!tracked(input.directory, input.sessionID)) return
|
|
||||||
setMeta("loading", key, false)
|
setMeta("loading", key, false)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -262,7 +199,6 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
|||||||
parts: Part[]
|
parts: Part[]
|
||||||
agent: string
|
agent: string
|
||||||
model: { providerID: string; modelID: string }
|
model: { providerID: string; modelID: string }
|
||||||
variant?: string
|
|
||||||
}) {
|
}) {
|
||||||
const message: Message = {
|
const message: Message = {
|
||||||
id: input.messageID,
|
id: input.messageID,
|
||||||
@@ -271,7 +207,6 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
|||||||
time: { created: Date.now() },
|
time: { created: Date.now() },
|
||||||
agent: input.agent,
|
agent: input.agent,
|
||||||
model: input.model,
|
model: input.model,
|
||||||
variant: input.variant,
|
|
||||||
}
|
}
|
||||||
const [, setStore] = target()
|
const [, setStore] = target()
|
||||||
setOptimisticAdd(setStore as (...args: unknown[]) => void, {
|
setOptimisticAdd(setStore as (...args: unknown[]) => void, {
|
||||||
@@ -287,16 +222,11 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
|||||||
const key = keyFor(directory, sessionID)
|
const key = keyFor(directory, sessionID)
|
||||||
const hasSession = Binary.search(store.session, sessionID, (s) => s.id).found
|
const hasSession = Binary.search(store.session, sessionID, (s) => s.id).found
|
||||||
|
|
||||||
touch(directory, setStore, sessionID)
|
|
||||||
|
|
||||||
if (store.message[sessionID] !== undefined && hasSession && meta.limit[key] !== undefined) return
|
|
||||||
|
|
||||||
const limit = meta.limit[key] ?? messagePageSize
|
const limit = meta.limit[key] ?? messagePageSize
|
||||||
|
|
||||||
const sessionReq = hasSession
|
const sessionReq = hasSession
|
||||||
? Promise.resolve()
|
? Promise.resolve()
|
||||||
: retry(() => client.session.get({ sessionID })).then((session) => {
|
: retry(() => client.session.get({ sessionID })).then((session) => {
|
||||||
if (!tracked(directory, sessionID)) return
|
|
||||||
const data = session.data
|
const data = session.data
|
||||||
if (!data) return
|
if (!data) return
|
||||||
setStore(
|
setStore(
|
||||||
@@ -326,13 +256,11 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
|||||||
const directory = sdk.directory
|
const directory = sdk.directory
|
||||||
const client = sdk.client
|
const client = sdk.client
|
||||||
const [store, setStore] = globalSync.child(directory)
|
const [store, setStore] = globalSync.child(directory)
|
||||||
touch(directory, setStore, sessionID)
|
|
||||||
if (store.session_diff[sessionID] !== undefined) return
|
if (store.session_diff[sessionID] !== undefined) return
|
||||||
|
|
||||||
const key = keyFor(directory, sessionID)
|
const key = keyFor(directory, sessionID)
|
||||||
return runInflight(inflightDiff, key, () =>
|
return runInflight(inflightDiff, key, () =>
|
||||||
retry(() => client.session.diff({ sessionID })).then((diff) => {
|
retry(() => client.session.diff({ sessionID })).then((diff) => {
|
||||||
if (!tracked(directory, sessionID)) return
|
|
||||||
setStore("session_diff", sessionID, reconcile(diff.data ?? [], { key: "file" }))
|
setStore("session_diff", sessionID, reconcile(diff.data ?? [], { key: "file" }))
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
@@ -341,7 +269,6 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
|||||||
const directory = sdk.directory
|
const directory = sdk.directory
|
||||||
const client = sdk.client
|
const client = sdk.client
|
||||||
const [store, setStore] = globalSync.child(directory)
|
const [store, setStore] = globalSync.child(directory)
|
||||||
touch(directory, setStore, sessionID)
|
|
||||||
const existing = store.todo[sessionID]
|
const existing = store.todo[sessionID]
|
||||||
const cached = globalSync.data.session_todo[sessionID]
|
const cached = globalSync.data.session_todo[sessionID]
|
||||||
if (existing !== undefined) {
|
if (existing !== undefined) {
|
||||||
@@ -358,7 +285,6 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
|||||||
const key = keyFor(directory, sessionID)
|
const key = keyFor(directory, sessionID)
|
||||||
return runInflight(inflightTodo, key, () =>
|
return runInflight(inflightTodo, key, () =>
|
||||||
retry(() => client.session.todo({ sessionID })).then((todo) => {
|
retry(() => client.session.todo({ sessionID })).then((todo) => {
|
||||||
if (!tracked(directory, sessionID)) return
|
|
||||||
const list = todo.data ?? []
|
const list = todo.data ?? []
|
||||||
setStore("todo", sessionID, reconcile(list, { key: "id" }))
|
setStore("todo", sessionID, reconcile(list, { key: "id" }))
|
||||||
globalSync.todo.set(sessionID, list)
|
globalSync.todo.set(sessionID, list)
|
||||||
@@ -382,7 +308,6 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
|||||||
const directory = sdk.directory
|
const directory = sdk.directory
|
||||||
const client = sdk.client
|
const client = sdk.client
|
||||||
const [, setStore] = globalSync.child(directory)
|
const [, setStore] = globalSync.child(directory)
|
||||||
touch(directory, setStore, sessionID)
|
|
||||||
const key = keyFor(directory, sessionID)
|
const key = keyFor(directory, sessionID)
|
||||||
const step = count ?? messagePageSize
|
const step = count ?? messagePageSize
|
||||||
if (meta.loading[key]) return
|
if (meta.loading[key]) return
|
||||||
@@ -398,11 +323,6 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
|||||||
})
|
})
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
evict(sessionID: string, directory = sdk.directory) {
|
|
||||||
const [, setStore] = globalSync.child(directory)
|
|
||||||
seenFor(directory).delete(sessionID)
|
|
||||||
evict(directory, setStore, [sessionID])
|
|
||||||
},
|
|
||||||
fetch: async (count = 10) => {
|
fetch: async (count = 10) => {
|
||||||
const directory = sdk.directory
|
const directory = sdk.directory
|
||||||
const client = sdk.client
|
const client = sdk.client
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
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 { batch, createEffect, createMemo, createRoot, on, onCleanup } from "solid-js"
|
import { batch, createEffect, createMemo, createRoot, onCleanup } from "solid-js"
|
||||||
import { useParams } from "@solidjs/router"
|
import { useParams } from "@solidjs/router"
|
||||||
import { useSDK } from "./sdk"
|
import { useSDK } from "./sdk"
|
||||||
import type { Platform } from "./platform"
|
import type { Platform } from "./platform"
|
||||||
@@ -38,16 +38,6 @@ type TerminalCacheEntry = {
|
|||||||
|
|
||||||
const caches = new Set<Map<string, TerminalCacheEntry>>()
|
const caches = new Set<Map<string, TerminalCacheEntry>>()
|
||||||
|
|
||||||
const trimTerminal = (pty: LocalPTY) => {
|
|
||||||
if (!pty.buffer && pty.cursor === undefined && pty.scrollY === undefined) return pty
|
|
||||||
return {
|
|
||||||
...pty,
|
|
||||||
buffer: undefined,
|
|
||||||
cursor: undefined,
|
|
||||||
scrollY: undefined,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function clearWorkspaceTerminals(dir: string, sessionIDs?: string[], platform?: Platform) {
|
export function clearWorkspaceTerminals(dir: string, sessionIDs?: string[], platform?: Platform) {
|
||||||
const key = getWorkspaceTerminalCacheKey(dir)
|
const key = getWorkspaceTerminalCacheKey(dir)
|
||||||
for (const cache of caches) {
|
for (const cache of caches) {
|
||||||
@@ -198,18 +188,6 @@ function createWorkspaceTerminalSession(sdk: ReturnType<typeof useSDK>, dir: str
|
|||||||
console.error("Failed to update terminal", error)
|
console.error("Failed to update terminal", error)
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
trim(id: string) {
|
|
||||||
const index = store.all.findIndex((x) => x.id === id)
|
|
||||||
if (index === -1) return
|
|
||||||
setStore("all", index, (pty) => trimTerminal(pty))
|
|
||||||
},
|
|
||||||
trimAll() {
|
|
||||||
setStore("all", (all) => {
|
|
||||||
const next = all.map(trimTerminal)
|
|
||||||
if (next.every((pty, index) => pty === all[index])) return all
|
|
||||||
return next
|
|
||||||
})
|
|
||||||
},
|
|
||||||
async clone(id: string) {
|
async clone(id: string) {
|
||||||
const index = store.all.findIndex((x) => x.id === id)
|
const index = store.all.findIndex((x) => x.id === id)
|
||||||
const pty = store.all[index]
|
const pty = store.all[index]
|
||||||
@@ -344,27 +322,12 @@ export const { use: useTerminal, provider: TerminalProvider } = createSimpleCont
|
|||||||
|
|
||||||
const workspace = createMemo(() => loadWorkspace(params.dir!, params.id))
|
const workspace = createMemo(() => loadWorkspace(params.dir!, params.id))
|
||||||
|
|
||||||
createEffect(
|
|
||||||
on(
|
|
||||||
() => ({ dir: params.dir, id: params.id }),
|
|
||||||
(next, prev) => {
|
|
||||||
if (!prev?.dir) return
|
|
||||||
if (next.dir === prev.dir && next.id === prev.id) return
|
|
||||||
if (next.dir === prev.dir && next.id) return
|
|
||||||
loadWorkspace(prev.dir, prev.id).trimAll()
|
|
||||||
},
|
|
||||||
{ defer: true },
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
ready: () => workspace().ready(),
|
ready: () => workspace().ready(),
|
||||||
all: () => workspace().all(),
|
all: () => workspace().all(),
|
||||||
active: () => workspace().active(),
|
active: () => workspace().active(),
|
||||||
new: () => workspace().new(),
|
new: () => workspace().new(),
|
||||||
update: (pty: Partial<LocalPTY> & { id: string }) => workspace().update(pty),
|
update: (pty: Partial<LocalPTY> & { id: string }) => workspace().update(pty),
|
||||||
trim: (id: string) => workspace().trim(id),
|
|
||||||
trimAll: () => workspace().trimAll(),
|
|
||||||
clone: (id: string) => workspace().clone(id),
|
clone: (id: string) => workspace().clone(id),
|
||||||
open: (id: string) => workspace().open(id),
|
open: (id: string) => workspace().open(id),
|
||||||
close: (id: string) => workspace().close(id),
|
close: (id: string) => workspace().close(id),
|
||||||
|
|||||||
@@ -456,7 +456,6 @@ export const dict = {
|
|||||||
"session.todo.title": "المهام",
|
"session.todo.title": "المهام",
|
||||||
"session.todo.collapse": "طي",
|
"session.todo.collapse": "طي",
|
||||||
"session.todo.expand": "توسيع",
|
"session.todo.expand": "توسيع",
|
||||||
"session.new.title": "ابنِ أي شيء",
|
|
||||||
"session.new.worktree.main": "الفرع الرئيسي",
|
"session.new.worktree.main": "الفرع الرئيسي",
|
||||||
"session.new.worktree.mainWithBranch": "الفرع الرئيسي ({{branch}})",
|
"session.new.worktree.mainWithBranch": "الفرع الرئيسي ({{branch}})",
|
||||||
"session.new.worktree.create": "إنشاء شجرة عمل جديدة",
|
"session.new.worktree.create": "إنشاء شجرة عمل جديدة",
|
||||||
@@ -666,6 +665,47 @@ export const dict = {
|
|||||||
"settings.commands.description": "ستكون إعدادات الأمر قابلة للتكوين هنا.",
|
"settings.commands.description": "ستكون إعدادات الأمر قابلة للتكوين هنا.",
|
||||||
"settings.mcp.title": "MCP",
|
"settings.mcp.title": "MCP",
|
||||||
"settings.mcp.description": "ستكون إعدادات MCP قابلة للتكوين هنا.",
|
"settings.mcp.description": "ستكون إعدادات MCP قابلة للتكوين هنا.",
|
||||||
|
"settings.mcp.section.featured": "مميّز",
|
||||||
|
"settings.mcp.section.featured.description": "أضف إعدادًا مسبقًا لخوادم MCP الشهيرة بنقرة واحدة.",
|
||||||
|
"settings.mcp.section.configured": "الخوادم المهيأة",
|
||||||
|
"settings.mcp.section.configured.description":
|
||||||
|
"اطلع على خوادم MCP المثبتة، وكيفية اتصالها، وقم بإزالة تلك التي لم تعد بحاجة إليها.",
|
||||||
|
"settings.mcp.section.add": "أضف خادمًا",
|
||||||
|
"settings.mcp.section.add.description": "أنشئ تكوين خادم MCP محلي أو بعيد خاص بك.",
|
||||||
|
"settings.mcp.type.local": "محلي",
|
||||||
|
"settings.mcp.type.remote": "بعيد",
|
||||||
|
"settings.mcp.featured.added": "تمت الإضافة",
|
||||||
|
"settings.mcp.action.add": "أضف خادمًا",
|
||||||
|
"settings.mcp.action.remove": "إزالة",
|
||||||
|
"settings.mcp.state.enabled": "مفعل",
|
||||||
|
"settings.mcp.state.disabled": "معطل",
|
||||||
|
"settings.mcp.form.type.label": "نوع الاتصال",
|
||||||
|
"settings.mcp.form.name.label": "اسم الخادم",
|
||||||
|
"settings.mcp.form.name.placeholder": "my-mcp-server",
|
||||||
|
"settings.mcp.form.url.label": "عنوان URL البعيد",
|
||||||
|
"settings.mcp.form.url.placeholder": "https://mcp.example.com/mcp",
|
||||||
|
"settings.mcp.form.command.label": "الأمر",
|
||||||
|
"settings.mcp.form.command.placeholder": "npx -y @modelcontextprotocol/server-memory",
|
||||||
|
"settings.mcp.form.headers.label": "الرؤوس (Headers)",
|
||||||
|
"settings.mcp.form.headers.description": "اختياري. أضف رأسًا واحدًا في كل سطر بصيغة KEY: value.",
|
||||||
|
"settings.mcp.form.environment.label": "البيئة",
|
||||||
|
"settings.mcp.form.environment.description": "اختياري. أضف متغيرًا واحدًا في كل سطر بصيغة KEY=value.",
|
||||||
|
"settings.mcp.form.timeout.label": "المهلة (مللي ثانية)",
|
||||||
|
"settings.mcp.form.timeout.placeholder": "5000",
|
||||||
|
"settings.mcp.toast.added.title": "تمت إضافة خادم MCP",
|
||||||
|
"settings.mcp.toast.added.description": "تم حفظ {{name}} في إعدادات MCP الخاصة بك.",
|
||||||
|
"settings.mcp.toast.updated.title": "تم تحديث خادم MCP",
|
||||||
|
"settings.mcp.toast.updated.description": "أصبح {{name}} الآن {{state}}.",
|
||||||
|
"settings.mcp.toast.removed.title": "تمت إزالة خادم MCP",
|
||||||
|
"settings.mcp.toast.removed.description": "تمت إزالة {{name}} من إعدادات MCP الخاصة بك.",
|
||||||
|
"settings.mcp.validation.name": "أدخل اسم الخادم قبل الحفظ.",
|
||||||
|
"settings.mcp.validation.duplicate": "{{name}} مهيأ بالفعل.",
|
||||||
|
"settings.mcp.validation.url": "أدخل عنوان URL لخادم MCP البعيد.",
|
||||||
|
"settings.mcp.validation.command": "أدخل الأمر المستخدم لبدء تشغيل خادم MCP المحلي.",
|
||||||
|
"settings.mcp.validation.timeout": "يجب أن تكون المهلة عددًا صحيحًا موجبًا.",
|
||||||
|
"settings.mcp.validation.headers": "تعذر تحليل سطر الرأس: {{line}}",
|
||||||
|
"settings.mcp.validation.environment": "تعذر تحليل سطر البيئة: {{line}}",
|
||||||
|
"settings.mcp.status.needs_client_registration": "يحتاج إلى تسجيل العميل",
|
||||||
"settings.permissions.title": "الأذونات",
|
"settings.permissions.title": "الأذونات",
|
||||||
"settings.permissions.description": "تحكم في الأدوات التي يمكن للخادم استخدامها بشكل افتراضي.",
|
"settings.permissions.description": "تحكم في الأدوات التي يمكن للخادم استخدامها بشكل افتراضي.",
|
||||||
"settings.permissions.section.tools": "الأدوات",
|
"settings.permissions.section.tools": "الأدوات",
|
||||||
|
|||||||
@@ -459,7 +459,6 @@ export const dict = {
|
|||||||
"session.todo.title": "Tarefas",
|
"session.todo.title": "Tarefas",
|
||||||
"session.todo.collapse": "Recolher",
|
"session.todo.collapse": "Recolher",
|
||||||
"session.todo.expand": "Expandir",
|
"session.todo.expand": "Expandir",
|
||||||
"session.new.title": "Crie qualquer coisa",
|
|
||||||
"session.new.worktree.main": "Branch principal",
|
"session.new.worktree.main": "Branch principal",
|
||||||
"session.new.worktree.mainWithBranch": "Branch principal ({{branch}})",
|
"session.new.worktree.mainWithBranch": "Branch principal ({{branch}})",
|
||||||
"session.new.worktree.create": "Criar novo worktree",
|
"session.new.worktree.create": "Criar novo worktree",
|
||||||
@@ -674,6 +673,48 @@ export const dict = {
|
|||||||
"settings.commands.description": "Configurações de comandos estarão disponíveis aqui.",
|
"settings.commands.description": "Configurações de comandos estarão disponíveis aqui.",
|
||||||
"settings.mcp.title": "MCP",
|
"settings.mcp.title": "MCP",
|
||||||
"settings.mcp.description": "Configurações de MCP estarão disponíveis aqui.",
|
"settings.mcp.description": "Configurações de MCP estarão disponíveis aqui.",
|
||||||
|
"settings.mcp.section.featured": "Destaques",
|
||||||
|
"settings.mcp.section.featured.description":
|
||||||
|
"Adicione uma predefinição refinada para servidores MCP populares com um clique.",
|
||||||
|
"settings.mcp.section.configured": "Servidores configurados",
|
||||||
|
"settings.mcp.section.configured.description":
|
||||||
|
"Veja quais servidores MCP estão instalados, como eles se conectam e remova os que você não precisa mais.",
|
||||||
|
"settings.mcp.section.add": "Adicionar servidor",
|
||||||
|
"settings.mcp.section.add.description": "Crie sua própria configuração de servidor MCP local ou remoto.",
|
||||||
|
"settings.mcp.type.local": "Local",
|
||||||
|
"settings.mcp.type.remote": "Remoto",
|
||||||
|
"settings.mcp.featured.added": "Adicionado",
|
||||||
|
"settings.mcp.action.add": "Adicionar servidor",
|
||||||
|
"settings.mcp.action.remove": "Remover",
|
||||||
|
"settings.mcp.state.enabled": "Habilitado",
|
||||||
|
"settings.mcp.state.disabled": "Desabilitado",
|
||||||
|
"settings.mcp.form.type.label": "Tipo de conexão",
|
||||||
|
"settings.mcp.form.name.label": "Nome do servidor",
|
||||||
|
"settings.mcp.form.name.placeholder": "my-mcp-server",
|
||||||
|
"settings.mcp.form.url.label": "URL remota",
|
||||||
|
"settings.mcp.form.url.placeholder": "https://mcp.example.com/mcp",
|
||||||
|
"settings.mcp.form.command.label": "Comando",
|
||||||
|
"settings.mcp.form.command.placeholder": "npx -y @modelcontextprotocol/server-memory",
|
||||||
|
"settings.mcp.form.headers.label": "Cabeçalhos",
|
||||||
|
"settings.mcp.form.headers.description": "Opcional. Adicione um cabeçalho por linha usando KEY: value.",
|
||||||
|
"settings.mcp.form.environment.label": "Ambiente",
|
||||||
|
"settings.mcp.form.environment.description": "Opcional. Adicione uma variável por linha usando KEY=value.",
|
||||||
|
"settings.mcp.form.timeout.label": "Tempo limite (ms)",
|
||||||
|
"settings.mcp.form.timeout.placeholder": "5000",
|
||||||
|
"settings.mcp.toast.added.title": "Servidor MCP adicionado",
|
||||||
|
"settings.mcp.toast.added.description": "{{name}} foi salvo nas suas configurações do MCP.",
|
||||||
|
"settings.mcp.toast.updated.title": "Servidor MCP atualizado",
|
||||||
|
"settings.mcp.toast.updated.description": "{{name}} está agora {{state}}.",
|
||||||
|
"settings.mcp.toast.removed.title": "Servidor MCP removido",
|
||||||
|
"settings.mcp.toast.removed.description": "{{name}} foi removido das suas configurações do MCP.",
|
||||||
|
"settings.mcp.validation.name": "Insira um nome para o servidor antes de salvar.",
|
||||||
|
"settings.mcp.validation.duplicate": "{{name}} já está configurado.",
|
||||||
|
"settings.mcp.validation.url": "Insira uma URL de servidor MCP remoto.",
|
||||||
|
"settings.mcp.validation.command": "Insira o comando usado para iniciar o servidor MCP local.",
|
||||||
|
"settings.mcp.validation.timeout": "O tempo limite deve ser um número inteiro positivo.",
|
||||||
|
"settings.mcp.validation.headers": "Não foi possível analisar a linha do cabeçalho: {{line}}",
|
||||||
|
"settings.mcp.validation.environment": "Não foi possível analisar a linha de ambiente: {{line}}",
|
||||||
|
"settings.mcp.status.needs_client_registration": "Requer registro do cliente",
|
||||||
"settings.permissions.title": "Permissões",
|
"settings.permissions.title": "Permissões",
|
||||||
"settings.permissions.description": "Controle quais ferramentas o servidor pode usar por padrão.",
|
"settings.permissions.description": "Controle quais ferramentas o servidor pode usar por padrão.",
|
||||||
"settings.permissions.section.tools": "Ferramentas",
|
"settings.permissions.section.tools": "Ferramentas",
|
||||||
|
|||||||
@@ -515,7 +515,6 @@ export const dict = {
|
|||||||
"session.todo.collapse": "Sažmi",
|
"session.todo.collapse": "Sažmi",
|
||||||
"session.todo.expand": "Proširi",
|
"session.todo.expand": "Proširi",
|
||||||
|
|
||||||
"session.new.title": "Napravi bilo šta",
|
|
||||||
"session.new.worktree.main": "Glavna grana",
|
"session.new.worktree.main": "Glavna grana",
|
||||||
"session.new.worktree.mainWithBranch": "Glavna grana ({{branch}})",
|
"session.new.worktree.mainWithBranch": "Glavna grana ({{branch}})",
|
||||||
"session.new.worktree.create": "Kreiraj novi worktree",
|
"session.new.worktree.create": "Kreiraj novi worktree",
|
||||||
@@ -746,6 +745,47 @@ export const dict = {
|
|||||||
"settings.commands.description": "Postavke komandi će se ovdje moći podešavati.",
|
"settings.commands.description": "Postavke komandi će se ovdje moći podešavati.",
|
||||||
"settings.mcp.title": "MCP",
|
"settings.mcp.title": "MCP",
|
||||||
"settings.mcp.description": "MCP postavke će se ovdje moći podešavati.",
|
"settings.mcp.description": "MCP postavke će se ovdje moći podešavati.",
|
||||||
|
"settings.mcp.section.featured": "Izdvojeno",
|
||||||
|
"settings.mcp.section.featured.description": "Dodajte gotovu postavku za popularne MCP servere jednim klikom.",
|
||||||
|
"settings.mcp.section.configured": "Konfigurisani serveri",
|
||||||
|
"settings.mcp.section.configured.description":
|
||||||
|
"Pogledajte koji su MCP serveri instalirani, kako se povezuju i uklonite one koji vam više ne trebaju.",
|
||||||
|
"settings.mcp.section.add": "Dodaj server",
|
||||||
|
"settings.mcp.section.add.description": "Kreirajte vlastitu konfiguraciju lokalnog ili udaljenog MCP servera.",
|
||||||
|
"settings.mcp.type.local": "Lokalno",
|
||||||
|
"settings.mcp.type.remote": "Udaljeno",
|
||||||
|
"settings.mcp.featured.added": "Dodano",
|
||||||
|
"settings.mcp.action.add": "Dodaj server",
|
||||||
|
"settings.mcp.action.remove": "Ukloni",
|
||||||
|
"settings.mcp.state.enabled": "Omogućeno",
|
||||||
|
"settings.mcp.state.disabled": "Onemogućeno",
|
||||||
|
"settings.mcp.form.type.label": "Tip konekcije",
|
||||||
|
"settings.mcp.form.name.label": "Naziv servera",
|
||||||
|
"settings.mcp.form.name.placeholder": "my-mcp-server",
|
||||||
|
"settings.mcp.form.url.label": "Udaljeni URL",
|
||||||
|
"settings.mcp.form.url.placeholder": "https://mcp.example.com/mcp",
|
||||||
|
"settings.mcp.form.command.label": "Komanda",
|
||||||
|
"settings.mcp.form.command.placeholder": "npx -y @modelcontextprotocol/server-memory",
|
||||||
|
"settings.mcp.form.headers.label": "Zaglavlja",
|
||||||
|
"settings.mcp.form.headers.description": "Opcionalno. Dodajte jedno zaglavlje po liniji koristeći KEY: value.",
|
||||||
|
"settings.mcp.form.environment.label": "Okruženje",
|
||||||
|
"settings.mcp.form.environment.description": "Opcionalno. Dodajte jednu varijablu po liniji koristeći KEY=value.",
|
||||||
|
"settings.mcp.form.timeout.label": "Istek vremena (ms)",
|
||||||
|
"settings.mcp.form.timeout.placeholder": "5000",
|
||||||
|
"settings.mcp.toast.added.title": "MCP server dodan",
|
||||||
|
"settings.mcp.toast.added.description": "{{name}} je sačuvan u vaše MCP postavke.",
|
||||||
|
"settings.mcp.toast.updated.title": "MCP server ažuriran",
|
||||||
|
"settings.mcp.toast.updated.description": "{{name}} je sada {{state}}.",
|
||||||
|
"settings.mcp.toast.removed.title": "MCP server uklonjen",
|
||||||
|
"settings.mcp.toast.removed.description": "{{name}} je uklonjen iz vaših MCP postavki.",
|
||||||
|
"settings.mcp.validation.name": "Unesite naziv servera prije čuvanja.",
|
||||||
|
"settings.mcp.validation.duplicate": "{{name}} je već konfigurisan.",
|
||||||
|
"settings.mcp.validation.url": "Unesite URL udaljenog MCP servera.",
|
||||||
|
"settings.mcp.validation.command": "Unesite komandu koja se koristi za pokretanje lokalnog MCP servera.",
|
||||||
|
"settings.mcp.validation.timeout": "Istek vremena mora biti pozitivan cijeli broj.",
|
||||||
|
"settings.mcp.validation.headers": "Nije moguće parsirati liniju zaglavlja: {{line}}",
|
||||||
|
"settings.mcp.validation.environment": "Nije moguće parsirati liniju okruženja: {{line}}",
|
||||||
|
"settings.mcp.status.needs_client_registration": "Potrebna registracija klijenta",
|
||||||
|
|
||||||
"settings.permissions.title": "Dozvole",
|
"settings.permissions.title": "Dozvole",
|
||||||
"settings.permissions.description": "Kontroliši koje alate server smije koristiti po defaultu.",
|
"settings.permissions.description": "Kontroliši koje alate server smije koristiti po defaultu.",
|
||||||
|
|||||||
@@ -510,7 +510,6 @@ export const dict = {
|
|||||||
"session.todo.collapse": "Skjul",
|
"session.todo.collapse": "Skjul",
|
||||||
"session.todo.expand": "Udvid",
|
"session.todo.expand": "Udvid",
|
||||||
|
|
||||||
"session.new.title": "Byg hvad som helst",
|
|
||||||
"session.new.worktree.main": "Hovedgren",
|
"session.new.worktree.main": "Hovedgren",
|
||||||
"session.new.worktree.mainWithBranch": "Hovedgren ({{branch}})",
|
"session.new.worktree.mainWithBranch": "Hovedgren ({{branch}})",
|
||||||
"session.new.worktree.create": "Opret nyt worktree",
|
"session.new.worktree.create": "Opret nyt worktree",
|
||||||
@@ -740,6 +739,48 @@ export const dict = {
|
|||||||
"settings.commands.description": "Kommandoindstillinger vil kunne konfigureres her.",
|
"settings.commands.description": "Kommandoindstillinger vil kunne konfigureres her.",
|
||||||
"settings.mcp.title": "MCP",
|
"settings.mcp.title": "MCP",
|
||||||
"settings.mcp.description": "MCP-indstillinger vil kunne konfigureres her.",
|
"settings.mcp.description": "MCP-indstillinger vil kunne konfigureres her.",
|
||||||
|
"settings.mcp.section.featured": "Udvalgte",
|
||||||
|
"settings.mcp.section.featured.description":
|
||||||
|
"Tilføj en poleret forudindstilling for populære MCP-servere med ét klik.",
|
||||||
|
"settings.mcp.section.configured": "Konfigurerede servere",
|
||||||
|
"settings.mcp.section.configured.description":
|
||||||
|
"Se hvilke MCP-servere der er installeret, hvordan de forbinder, og fjern dem du ikke længere har brug for.",
|
||||||
|
"settings.mcp.section.add": "Tilføj en server",
|
||||||
|
"settings.mcp.section.add.description": "Opret din egen lokale eller fjern MCP-serverkonfiguration.",
|
||||||
|
"settings.mcp.type.local": "Lokal",
|
||||||
|
"settings.mcp.type.remote": "Fjern",
|
||||||
|
"settings.mcp.featured.added": "Tilføjet",
|
||||||
|
"settings.mcp.action.add": "Tilføj server",
|
||||||
|
"settings.mcp.action.remove": "Fjern",
|
||||||
|
"settings.mcp.state.enabled": "Aktiveret",
|
||||||
|
"settings.mcp.state.disabled": "Deaktiveret",
|
||||||
|
"settings.mcp.form.type.label": "Forbindelsestype",
|
||||||
|
"settings.mcp.form.name.label": "Servernavn",
|
||||||
|
"settings.mcp.form.name.placeholder": "my-mcp-server",
|
||||||
|
"settings.mcp.form.url.label": "Fjern-URL",
|
||||||
|
"settings.mcp.form.url.placeholder": "https://mcp.example.com/mcp",
|
||||||
|
"settings.mcp.form.command.label": "Kommando",
|
||||||
|
"settings.mcp.form.command.placeholder": "npx -y @modelcontextprotocol/server-memory",
|
||||||
|
"settings.mcp.form.headers.label": "Headere",
|
||||||
|
"settings.mcp.form.headers.description": "Valgfrit. Tilføj én header pr. linje ved brug af KEY: value.",
|
||||||
|
"settings.mcp.form.environment.label": "Miljø",
|
||||||
|
"settings.mcp.form.environment.description": "Valgfrit. Tilføj én variabel pr. linje ved brug af KEY=value.",
|
||||||
|
"settings.mcp.form.timeout.label": "Timeout (ms)",
|
||||||
|
"settings.mcp.form.timeout.placeholder": "5000",
|
||||||
|
"settings.mcp.toast.added.title": "MCP-server tilføjet",
|
||||||
|
"settings.mcp.toast.added.description": "{{name}} er blevet gemt i dine MCP-indstillinger.",
|
||||||
|
"settings.mcp.toast.updated.title": "MCP-server opdateret",
|
||||||
|
"settings.mcp.toast.updated.description": "{{name}} er nu {{state}}.",
|
||||||
|
"settings.mcp.toast.removed.title": "MCP-server fjernet",
|
||||||
|
"settings.mcp.toast.removed.description": "{{name}} er blevet fjernet fra dine MCP-indstillinger.",
|
||||||
|
"settings.mcp.validation.name": "Indtast et servernavn før du gemmer.",
|
||||||
|
"settings.mcp.validation.duplicate": "{{name}} er allerede konfigureret.",
|
||||||
|
"settings.mcp.validation.url": "Indtast en URL til en fjern MCP-server.",
|
||||||
|
"settings.mcp.validation.command": "Indtast kommandoen, der bruges til at starte den lokale MCP-server.",
|
||||||
|
"settings.mcp.validation.timeout": "Timeout skal være et positivt heltal.",
|
||||||
|
"settings.mcp.validation.headers": "Kunne ikke fortolke header-linje: {{line}}",
|
||||||
|
"settings.mcp.validation.environment": "Kunne ikke fortolke miljø-linje: {{line}}",
|
||||||
|
"settings.mcp.status.needs_client_registration": "Kræver klientregistrering",
|
||||||
|
|
||||||
"settings.permissions.title": "Tilladelser",
|
"settings.permissions.title": "Tilladelser",
|
||||||
"settings.permissions.description": "Styr hvilke værktøjer serveren kan bruge som standard.",
|
"settings.permissions.description": "Styr hvilke værktøjer serveren kan bruge som standard.",
|
||||||
|
|||||||
@@ -467,7 +467,6 @@ export const dict = {
|
|||||||
"session.todo.title": "Aufgaben",
|
"session.todo.title": "Aufgaben",
|
||||||
"session.todo.collapse": "Einklappen",
|
"session.todo.collapse": "Einklappen",
|
||||||
"session.todo.expand": "Ausklappen",
|
"session.todo.expand": "Ausklappen",
|
||||||
"session.new.title": "Baue, was du willst",
|
|
||||||
"session.new.worktree.main": "Haupt-Branch",
|
"session.new.worktree.main": "Haupt-Branch",
|
||||||
"session.new.worktree.mainWithBranch": "Haupt-Branch ({{branch}})",
|
"session.new.worktree.mainWithBranch": "Haupt-Branch ({{branch}})",
|
||||||
"session.new.worktree.create": "Neuen Worktree erstellen",
|
"session.new.worktree.create": "Neuen Worktree erstellen",
|
||||||
@@ -683,6 +682,47 @@ export const dict = {
|
|||||||
"settings.commands.description": "Befehlseinstellungen können hier konfiguriert werden.",
|
"settings.commands.description": "Befehlseinstellungen können hier konfiguriert werden.",
|
||||||
"settings.mcp.title": "MCP",
|
"settings.mcp.title": "MCP",
|
||||||
"settings.mcp.description": "MCP-Einstellungen können hier konfiguriert werden.",
|
"settings.mcp.description": "MCP-Einstellungen können hier konfiguriert werden.",
|
||||||
|
"settings.mcp.section.featured": "Vorgestellt",
|
||||||
|
"settings.mcp.section.featured.description": "Fügen Sie mit einem Klick eine Vorlage für beliebte MCP-Server hinzu.",
|
||||||
|
"settings.mcp.section.configured": "Konfigurierte Server",
|
||||||
|
"settings.mcp.section.configured.description":
|
||||||
|
"Sehen Sie, welche MCP-Server installiert sind, wie sie verbunden sind, und entfernen Sie nicht mehr benötigte.",
|
||||||
|
"settings.mcp.section.add": "Server hinzufügen",
|
||||||
|
"settings.mcp.section.add.description": "Erstellen Sie Ihre eigene lokale oder Remote-MCP-Server-Konfiguration.",
|
||||||
|
"settings.mcp.type.local": "Lokal",
|
||||||
|
"settings.mcp.type.remote": "Remote",
|
||||||
|
"settings.mcp.featured.added": "Hinzugefügt",
|
||||||
|
"settings.mcp.action.add": "Server hinzufügen",
|
||||||
|
"settings.mcp.action.remove": "Entfernen",
|
||||||
|
"settings.mcp.state.enabled": "Aktiviert",
|
||||||
|
"settings.mcp.state.disabled": "Deaktiviert",
|
||||||
|
"settings.mcp.form.type.label": "Verbindungstyp",
|
||||||
|
"settings.mcp.form.name.label": "Servername",
|
||||||
|
"settings.mcp.form.name.placeholder": "my-mcp-server",
|
||||||
|
"settings.mcp.form.url.label": "Remote-URL",
|
||||||
|
"settings.mcp.form.url.placeholder": "https://mcp.example.com/mcp",
|
||||||
|
"settings.mcp.form.command.label": "Befehl",
|
||||||
|
"settings.mcp.form.command.placeholder": "npx -y @modelcontextprotocol/server-memory",
|
||||||
|
"settings.mcp.form.headers.label": "Header",
|
||||||
|
"settings.mcp.form.headers.description": "Optional. Fügen Sie einen Header pro Zeile im Format KEY: value hinzu.",
|
||||||
|
"settings.mcp.form.environment.label": "Umgebung",
|
||||||
|
"settings.mcp.form.environment.description": "Optional. Fügen Sie eine Variable pro Zeile im Format KEY=value hinzu.",
|
||||||
|
"settings.mcp.form.timeout.label": "Timeout (ms)",
|
||||||
|
"settings.mcp.form.timeout.placeholder": "5000",
|
||||||
|
"settings.mcp.toast.added.title": "MCP-Server hinzugefügt",
|
||||||
|
"settings.mcp.toast.added.description": "{{name}} wurde in Ihren MCP-Einstellungen gespeichert.",
|
||||||
|
"settings.mcp.toast.updated.title": "MCP-Server aktualisiert",
|
||||||
|
"settings.mcp.toast.updated.description": "{{name}} ist jetzt {{state}}.",
|
||||||
|
"settings.mcp.toast.removed.title": "MCP-Server entfernt",
|
||||||
|
"settings.mcp.toast.removed.description": "{{name}} wurde aus Ihren MCP-Einstellungen entfernt.",
|
||||||
|
"settings.mcp.validation.name": "Geben Sie vor dem Speichern einen Servernamen ein.",
|
||||||
|
"settings.mcp.validation.duplicate": "{{name}} ist bereits konfiguriert.",
|
||||||
|
"settings.mcp.validation.url": "Geben Sie eine Remote-MCP-Server-URL ein.",
|
||||||
|
"settings.mcp.validation.command": "Geben Sie den Befehl zum Starten des lokalen MCP-Servers ein.",
|
||||||
|
"settings.mcp.validation.timeout": "Das Timeout muss eine positive ganze Zahl sein.",
|
||||||
|
"settings.mcp.validation.headers": "Header-Zeile konnte nicht verarbeitet werden: {{line}}",
|
||||||
|
"settings.mcp.validation.environment": "Umgebungsvariablen-Zeile konnte nicht verarbeitet werden: {{line}}",
|
||||||
|
"settings.mcp.status.needs_client_registration": "Client-Registrierung erforderlich",
|
||||||
"settings.permissions.title": "Berechtigungen",
|
"settings.permissions.title": "Berechtigungen",
|
||||||
"settings.permissions.description": "Steuern Sie, welche Tools der Server standardmäßig verwenden darf.",
|
"settings.permissions.description": "Steuern Sie, welche Tools der Server standardmäßig verwenden darf.",
|
||||||
"settings.permissions.section.tools": "Tools",
|
"settings.permissions.section.tools": "Tools",
|
||||||
|
|||||||
@@ -511,13 +511,11 @@ 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 Version Control System detected, changes not displayed",
|
"session.review.noVcs": "No git VCS detected, so session changes will not be detected",
|
||||||
"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",
|
||||||
@@ -531,7 +529,6 @@ export const dict = {
|
|||||||
"session.todo.collapse": "Collapse",
|
"session.todo.collapse": "Collapse",
|
||||||
"session.todo.expand": "Expand",
|
"session.todo.expand": "Expand",
|
||||||
|
|
||||||
"session.new.title": "Build anything",
|
|
||||||
"session.new.worktree.main": "Main branch",
|
"session.new.worktree.main": "Main branch",
|
||||||
"session.new.worktree.mainWithBranch": "Main branch ({{branch}})",
|
"session.new.worktree.mainWithBranch": "Main branch ({{branch}})",
|
||||||
"session.new.worktree.create": "Create new worktree",
|
"session.new.worktree.create": "Create new worktree",
|
||||||
@@ -773,7 +770,48 @@ export const dict = {
|
|||||||
"settings.commands.title": "Commands",
|
"settings.commands.title": "Commands",
|
||||||
"settings.commands.description": "Command settings will be configurable here.",
|
"settings.commands.description": "Command settings will be configurable here.",
|
||||||
"settings.mcp.title": "MCP",
|
"settings.mcp.title": "MCP",
|
||||||
"settings.mcp.description": "MCP settings will be configurable here.",
|
"settings.mcp.description": "Manage local and remote MCP servers that OpenCode can use across your workspaces.",
|
||||||
|
"settings.mcp.section.featured": "Featured",
|
||||||
|
"settings.mcp.section.featured.description": "Add a polished preset for popular MCP servers in one click.",
|
||||||
|
"settings.mcp.section.configured": "Configured servers",
|
||||||
|
"settings.mcp.section.configured.description":
|
||||||
|
"See which MCP servers are installed, how they connect, and remove the ones you no longer need.",
|
||||||
|
"settings.mcp.section.add": "Add a server",
|
||||||
|
"settings.mcp.section.add.description": "Create your own local or remote MCP server configuration.",
|
||||||
|
"settings.mcp.type.local": "Local",
|
||||||
|
"settings.mcp.type.remote": "Remote",
|
||||||
|
"settings.mcp.featured.added": "Added",
|
||||||
|
"settings.mcp.action.add": "Add server",
|
||||||
|
"settings.mcp.action.remove": "Remove",
|
||||||
|
"settings.mcp.state.enabled": "Enabled",
|
||||||
|
"settings.mcp.state.disabled": "Disabled",
|
||||||
|
"settings.mcp.form.type.label": "Connection type",
|
||||||
|
"settings.mcp.form.name.label": "Server name",
|
||||||
|
"settings.mcp.form.name.placeholder": "my-mcp-server",
|
||||||
|
"settings.mcp.form.url.label": "Remote URL",
|
||||||
|
"settings.mcp.form.url.placeholder": "https://mcp.example.com/mcp",
|
||||||
|
"settings.mcp.form.command.label": "Command",
|
||||||
|
"settings.mcp.form.command.placeholder": "npx -y @modelcontextprotocol/server-memory",
|
||||||
|
"settings.mcp.form.headers.label": "Headers",
|
||||||
|
"settings.mcp.form.headers.description": "Optional. Add one header per line using KEY: value.",
|
||||||
|
"settings.mcp.form.environment.label": "Environment",
|
||||||
|
"settings.mcp.form.environment.description": "Optional. Add one variable per line using KEY=value.",
|
||||||
|
"settings.mcp.form.timeout.label": "Timeout (ms)",
|
||||||
|
"settings.mcp.form.timeout.placeholder": "5000",
|
||||||
|
"settings.mcp.toast.added.title": "MCP server added",
|
||||||
|
"settings.mcp.toast.added.description": "{{name}} has been saved to your MCP settings.",
|
||||||
|
"settings.mcp.toast.updated.title": "MCP server updated",
|
||||||
|
"settings.mcp.toast.updated.description": "{{name}} is now {{state}}.",
|
||||||
|
"settings.mcp.toast.removed.title": "MCP server removed",
|
||||||
|
"settings.mcp.toast.removed.description": "{{name}} has been removed from your MCP settings.",
|
||||||
|
"settings.mcp.validation.name": "Enter a server name before saving.",
|
||||||
|
"settings.mcp.validation.duplicate": "{{name}} is already configured.",
|
||||||
|
"settings.mcp.validation.url": "Enter a remote MCP server URL.",
|
||||||
|
"settings.mcp.validation.command": "Enter the command used to start the local MCP server.",
|
||||||
|
"settings.mcp.validation.timeout": "Timeout must be a positive whole number.",
|
||||||
|
"settings.mcp.validation.headers": "Couldn't parse header line: {{line}}",
|
||||||
|
"settings.mcp.validation.environment": "Couldn't parse environment line: {{line}}",
|
||||||
|
"settings.mcp.status.needs_client_registration": "Needs client registration",
|
||||||
|
|
||||||
"settings.permissions.title": "Permissions",
|
"settings.permissions.title": "Permissions",
|
||||||
"settings.permissions.description": "Control what tools the server can use by default.",
|
"settings.permissions.description": "Control what tools the server can use by default.",
|
||||||
|
|||||||
@@ -516,7 +516,6 @@ export const dict = {
|
|||||||
"session.todo.collapse": "Contraer",
|
"session.todo.collapse": "Contraer",
|
||||||
"session.todo.expand": "Expandir",
|
"session.todo.expand": "Expandir",
|
||||||
|
|
||||||
"session.new.title": "Construye lo que quieras",
|
|
||||||
"session.new.worktree.main": "Rama principal",
|
"session.new.worktree.main": "Rama principal",
|
||||||
"session.new.worktree.mainWithBranch": "Rama principal ({{branch}})",
|
"session.new.worktree.mainWithBranch": "Rama principal ({{branch}})",
|
||||||
"session.new.worktree.create": "Crear nuevo árbol de trabajo",
|
"session.new.worktree.create": "Crear nuevo árbol de trabajo",
|
||||||
@@ -752,6 +751,48 @@ export const dict = {
|
|||||||
"settings.commands.description": "La configuración de comandos estará disponible aquí.",
|
"settings.commands.description": "La configuración de comandos estará disponible aquí.",
|
||||||
"settings.mcp.title": "MCP",
|
"settings.mcp.title": "MCP",
|
||||||
"settings.mcp.description": "La configuración de MCP estará disponible aquí.",
|
"settings.mcp.description": "La configuración de MCP estará disponible aquí.",
|
||||||
|
"settings.mcp.section.featured": "Destacados",
|
||||||
|
"settings.mcp.section.featured.description":
|
||||||
|
"Añade un ajuste preestablecido para servidores MCP populares con un solo clic.",
|
||||||
|
"settings.mcp.section.configured": "Servidores configurados",
|
||||||
|
"settings.mcp.section.configured.description":
|
||||||
|
"Consulta qué servidores MCP están instalados, cómo se conectan y elimina los que ya no necesites.",
|
||||||
|
"settings.mcp.section.add": "Añadir un servidor",
|
||||||
|
"settings.mcp.section.add.description": "Crea tu propia configuración de servidor MCP local o remoto.",
|
||||||
|
"settings.mcp.type.local": "Local",
|
||||||
|
"settings.mcp.type.remote": "Remoto",
|
||||||
|
"settings.mcp.featured.added": "Añadido",
|
||||||
|
"settings.mcp.action.add": "Añadir servidor",
|
||||||
|
"settings.mcp.action.remove": "Eliminar",
|
||||||
|
"settings.mcp.state.enabled": "Habilitado",
|
||||||
|
"settings.mcp.state.disabled": "Deshabilitado",
|
||||||
|
"settings.mcp.form.type.label": "Tipo de conexión",
|
||||||
|
"settings.mcp.form.name.label": "Nombre del servidor",
|
||||||
|
"settings.mcp.form.name.placeholder": "my-mcp-server",
|
||||||
|
"settings.mcp.form.url.label": "URL remota",
|
||||||
|
"settings.mcp.form.url.placeholder": "https://mcp.example.com/mcp",
|
||||||
|
"settings.mcp.form.command.label": "Comando",
|
||||||
|
"settings.mcp.form.command.placeholder": "npx -y @modelcontextprotocol/server-memory",
|
||||||
|
"settings.mcp.form.headers.label": "Encabezados",
|
||||||
|
"settings.mcp.form.headers.description": "Opcional. Añade un encabezado por línea usando KEY: value.",
|
||||||
|
"settings.mcp.form.environment.label": "Entorno",
|
||||||
|
"settings.mcp.form.environment.description": "Opcional. Añade una variable por línea usando KEY=value.",
|
||||||
|
"settings.mcp.form.timeout.label": "Tiempo de espera (ms)",
|
||||||
|
"settings.mcp.form.timeout.placeholder": "5000",
|
||||||
|
"settings.mcp.toast.added.title": "Servidor MCP añadido",
|
||||||
|
"settings.mcp.toast.added.description": "{{name}} se ha guardado en tu configuración de MCP.",
|
||||||
|
"settings.mcp.toast.updated.title": "Servidor MCP actualizado",
|
||||||
|
"settings.mcp.toast.updated.description": "{{name}} está ahora {{state}}.",
|
||||||
|
"settings.mcp.toast.removed.title": "Servidor MCP eliminado",
|
||||||
|
"settings.mcp.toast.removed.description": "{{name}} se ha eliminado de tu configuración de MCP.",
|
||||||
|
"settings.mcp.validation.name": "Introduce un nombre de servidor antes de guardar.",
|
||||||
|
"settings.mcp.validation.duplicate": "{{name}} ya está configurado.",
|
||||||
|
"settings.mcp.validation.url": "Introduce una URL de servidor MCP remoto.",
|
||||||
|
"settings.mcp.validation.command": "Introduce el comando utilizado para iniciar el servidor MCP local.",
|
||||||
|
"settings.mcp.validation.timeout": "El tiempo de espera debe ser un número entero positivo.",
|
||||||
|
"settings.mcp.validation.headers": "No se pudo analizar la línea de encabezado: {{line}}",
|
||||||
|
"settings.mcp.validation.environment": "No se pudo analizar la línea de entorno: {{line}}",
|
||||||
|
"settings.mcp.status.needs_client_registration": "Requiere registro de cliente",
|
||||||
|
|
||||||
"settings.permissions.title": "Permisos",
|
"settings.permissions.title": "Permisos",
|
||||||
"settings.permissions.description": "Controla qué herramientas puede usar el servidor por defecto.",
|
"settings.permissions.description": "Controla qué herramientas puede usar el servidor por defecto.",
|
||||||
|
|||||||
@@ -463,7 +463,6 @@ export const dict = {
|
|||||||
"session.todo.title": "Tâches",
|
"session.todo.title": "Tâches",
|
||||||
"session.todo.collapse": "Réduire",
|
"session.todo.collapse": "Réduire",
|
||||||
"session.todo.expand": "Développer",
|
"session.todo.expand": "Développer",
|
||||||
"session.new.title": "Créez ce que vous voulez",
|
|
||||||
"session.new.worktree.main": "Branche principale",
|
"session.new.worktree.main": "Branche principale",
|
||||||
"session.new.worktree.mainWithBranch": "Branche principale ({{branch}})",
|
"session.new.worktree.mainWithBranch": "Branche principale ({{branch}})",
|
||||||
"session.new.worktree.create": "Créer un nouvel arbre de travail",
|
"session.new.worktree.create": "Créer un nouvel arbre de travail",
|
||||||
@@ -680,6 +679,48 @@ export const dict = {
|
|||||||
"settings.commands.description": "Les paramètres des commandes seront configurables ici.",
|
"settings.commands.description": "Les paramètres des commandes seront configurables ici.",
|
||||||
"settings.mcp.title": "MCP",
|
"settings.mcp.title": "MCP",
|
||||||
"settings.mcp.description": "Les paramètres MCP seront configurables ici.",
|
"settings.mcp.description": "Les paramètres MCP seront configurables ici.",
|
||||||
|
"settings.mcp.section.featured": "En vedette",
|
||||||
|
"settings.mcp.section.featured.description":
|
||||||
|
"Ajoutez un préréglage soigné pour les serveurs MCP populaires en un clic.",
|
||||||
|
"settings.mcp.section.configured": "Serveurs configurés",
|
||||||
|
"settings.mcp.section.configured.description":
|
||||||
|
"Consultez les serveurs MCP installés, leur mode de connexion, et supprimez ceux dont vous n'avez plus besoin.",
|
||||||
|
"settings.mcp.section.add": "Ajouter un serveur",
|
||||||
|
"settings.mcp.section.add.description": "Créez votre propre configuration de serveur MCP local ou distant.",
|
||||||
|
"settings.mcp.type.local": "Local",
|
||||||
|
"settings.mcp.type.remote": "Distant",
|
||||||
|
"settings.mcp.featured.added": "Ajouté",
|
||||||
|
"settings.mcp.action.add": "Ajouter un serveur",
|
||||||
|
"settings.mcp.action.remove": "Supprimer",
|
||||||
|
"settings.mcp.state.enabled": "Activé",
|
||||||
|
"settings.mcp.state.disabled": "Désactivé",
|
||||||
|
"settings.mcp.form.type.label": "Type de connexion",
|
||||||
|
"settings.mcp.form.name.label": "Nom du serveur",
|
||||||
|
"settings.mcp.form.name.placeholder": "my-mcp-server",
|
||||||
|
"settings.mcp.form.url.label": "URL distante",
|
||||||
|
"settings.mcp.form.url.placeholder": "https://mcp.example.com/mcp",
|
||||||
|
"settings.mcp.form.command.label": "Commande",
|
||||||
|
"settings.mcp.form.command.placeholder": "npx -y @modelcontextprotocol/server-memory",
|
||||||
|
"settings.mcp.form.headers.label": "En-têtes",
|
||||||
|
"settings.mcp.form.headers.description": "Facultatif. Ajoutez un en-tête par ligne au format KEY: value.",
|
||||||
|
"settings.mcp.form.environment.label": "Environnement",
|
||||||
|
"settings.mcp.form.environment.description": "Facultatif. Ajoutez une variable par ligne au format KEY=value.",
|
||||||
|
"settings.mcp.form.timeout.label": "Délai d'attente (ms)",
|
||||||
|
"settings.mcp.form.timeout.placeholder": "5000",
|
||||||
|
"settings.mcp.toast.added.title": "Serveur MCP ajouté",
|
||||||
|
"settings.mcp.toast.added.description": "{{name}} a été enregistré dans vos paramètres MCP.",
|
||||||
|
"settings.mcp.toast.updated.title": "Serveur MCP mis à jour",
|
||||||
|
"settings.mcp.toast.updated.description": "{{name}} est désormais {{state}}.",
|
||||||
|
"settings.mcp.toast.removed.title": "Serveur MCP supprimé",
|
||||||
|
"settings.mcp.toast.removed.description": "{{name}} a été supprimé de vos paramètres MCP.",
|
||||||
|
"settings.mcp.validation.name": "Saisissez un nom de serveur avant d'enregistrer.",
|
||||||
|
"settings.mcp.validation.duplicate": "{{name}} est déjà configuré.",
|
||||||
|
"settings.mcp.validation.url": "Saisissez une URL de serveur MCP distant.",
|
||||||
|
"settings.mcp.validation.command": "Saisissez la commande utilisée pour démarrer le serveur MCP local.",
|
||||||
|
"settings.mcp.validation.timeout": "Le délai d'attente doit être un nombre entier positif.",
|
||||||
|
"settings.mcp.validation.headers": "Impossible d'analyser la ligne d'en-tête : {{line}}",
|
||||||
|
"settings.mcp.validation.environment": "Impossible d'analyser la ligne d'environnement : {{line}}",
|
||||||
|
"settings.mcp.status.needs_client_registration": "Nécessite l'enregistrement du client",
|
||||||
"settings.permissions.title": "Permissions",
|
"settings.permissions.title": "Permissions",
|
||||||
"settings.permissions.description": "Contrôlez les outils que le serveur peut utiliser par défaut.",
|
"settings.permissions.description": "Contrôlez les outils que le serveur peut utiliser par défaut.",
|
||||||
"settings.permissions.section.tools": "Outils",
|
"settings.permissions.section.tools": "Outils",
|
||||||
|
|||||||
@@ -457,7 +457,6 @@ export const dict = {
|
|||||||
"session.todo.title": "ToDo",
|
"session.todo.title": "ToDo",
|
||||||
"session.todo.collapse": "折りたたむ",
|
"session.todo.collapse": "折りたたむ",
|
||||||
"session.todo.expand": "展開",
|
"session.todo.expand": "展開",
|
||||||
"session.new.title": "何でも作る",
|
|
||||||
"session.new.worktree.main": "メインブランチ",
|
"session.new.worktree.main": "メインブランチ",
|
||||||
"session.new.worktree.mainWithBranch": "メインブランチ ({{branch}})",
|
"session.new.worktree.mainWithBranch": "メインブランチ ({{branch}})",
|
||||||
"session.new.worktree.create": "新しいワークツリーを作成",
|
"session.new.worktree.create": "新しいワークツリーを作成",
|
||||||
@@ -671,6 +670,48 @@ export const dict = {
|
|||||||
"settings.commands.description": "コマンド設定はここで構成できます。",
|
"settings.commands.description": "コマンド設定はここで構成できます。",
|
||||||
"settings.mcp.title": "MCP",
|
"settings.mcp.title": "MCP",
|
||||||
"settings.mcp.description": "MCP設定はここで構成できます。",
|
"settings.mcp.description": "MCP設定はここで構成できます。",
|
||||||
|
"settings.mcp.section.featured": "おすすめ",
|
||||||
|
"settings.mcp.section.featured.description":
|
||||||
|
"人気のMCPサーバー向けに調整されたプリセットをワンクリックで追加できます。",
|
||||||
|
"settings.mcp.section.configured": "設定済みのサーバー",
|
||||||
|
"settings.mcp.section.configured.description":
|
||||||
|
"インストールされているMCPサーバーや接続方法を確認したり、不要になったサーバーを削除したりできます。",
|
||||||
|
"settings.mcp.section.add": "サーバーを追加",
|
||||||
|
"settings.mcp.section.add.description": "ローカルまたはリモートの独自のMCPサーバー設定を作成します。",
|
||||||
|
"settings.mcp.type.local": "ローカル",
|
||||||
|
"settings.mcp.type.remote": "リモート",
|
||||||
|
"settings.mcp.featured.added": "追加済み",
|
||||||
|
"settings.mcp.action.add": "サーバーを追加",
|
||||||
|
"settings.mcp.action.remove": "削除",
|
||||||
|
"settings.mcp.state.enabled": "有効",
|
||||||
|
"settings.mcp.state.disabled": "無効",
|
||||||
|
"settings.mcp.form.type.label": "接続タイプ",
|
||||||
|
"settings.mcp.form.name.label": "サーバー名",
|
||||||
|
"settings.mcp.form.name.placeholder": "my-mcp-server",
|
||||||
|
"settings.mcp.form.url.label": "リモートURL",
|
||||||
|
"settings.mcp.form.url.placeholder": "https://mcp.example.com/mcp",
|
||||||
|
"settings.mcp.form.command.label": "コマンド",
|
||||||
|
"settings.mcp.form.command.placeholder": "npx -y @modelcontextprotocol/server-memory",
|
||||||
|
"settings.mcp.form.headers.label": "ヘッダー",
|
||||||
|
"settings.mcp.form.headers.description": "任意。1行につき1つのヘッダーを KEY: value の形式で追加してください。",
|
||||||
|
"settings.mcp.form.environment.label": "環境変数",
|
||||||
|
"settings.mcp.form.environment.description": "任意。1行につき1つの変数を KEY=value の形式で追加してください。",
|
||||||
|
"settings.mcp.form.timeout.label": "タイムアウト (ms)",
|
||||||
|
"settings.mcp.form.timeout.placeholder": "5000",
|
||||||
|
"settings.mcp.toast.added.title": "MCPサーバーを追加しました",
|
||||||
|
"settings.mcp.toast.added.description": "{{name}} をMCP設定に保存しました。",
|
||||||
|
"settings.mcp.toast.updated.title": "MCPサーバーを更新しました",
|
||||||
|
"settings.mcp.toast.updated.description": "{{name}} は現在{{state}}です。",
|
||||||
|
"settings.mcp.toast.removed.title": "MCPサーバーを削除しました",
|
||||||
|
"settings.mcp.toast.removed.description": "MCP設定から {{name}} を削除しました。",
|
||||||
|
"settings.mcp.validation.name": "保存する前にサーバー名を入力してください。",
|
||||||
|
"settings.mcp.validation.duplicate": "{{name}} は既に設定されています。",
|
||||||
|
"settings.mcp.validation.url": "リモートMCPサーバーのURLを入力してください。",
|
||||||
|
"settings.mcp.validation.command": "ローカルMCPサーバーの起動コマンドを入力してください。",
|
||||||
|
"settings.mcp.validation.timeout": "タイムアウトには正の整数を指定してください。",
|
||||||
|
"settings.mcp.validation.headers": "ヘッダー行を解析できませんでした: {{line}}",
|
||||||
|
"settings.mcp.validation.environment": "環境変数の行を解析できませんでした: {{line}}",
|
||||||
|
"settings.mcp.status.needs_client_registration": "クライアント登録が必要です",
|
||||||
"settings.permissions.title": "権限",
|
"settings.permissions.title": "権限",
|
||||||
"settings.permissions.description": "サーバーがデフォルトで使用できるツールを制御します。",
|
"settings.permissions.description": "サーバーがデフォルトで使用できるツールを制御します。",
|
||||||
"settings.permissions.section.tools": "ツール",
|
"settings.permissions.section.tools": "ツール",
|
||||||
|
|||||||
@@ -459,7 +459,6 @@ export const dict = {
|
|||||||
"session.todo.title": "할 일",
|
"session.todo.title": "할 일",
|
||||||
"session.todo.collapse": "접기",
|
"session.todo.collapse": "접기",
|
||||||
"session.todo.expand": "펼치기",
|
"session.todo.expand": "펼치기",
|
||||||
"session.new.title": "무엇이든 만들기",
|
|
||||||
"session.new.worktree.main": "메인 브랜치",
|
"session.new.worktree.main": "메인 브랜치",
|
||||||
"session.new.worktree.mainWithBranch": "메인 브랜치 ({{branch}})",
|
"session.new.worktree.mainWithBranch": "메인 브랜치 ({{branch}})",
|
||||||
"session.new.worktree.create": "새 작업 트리 생성",
|
"session.new.worktree.create": "새 작업 트리 생성",
|
||||||
@@ -671,6 +670,49 @@ export const dict = {
|
|||||||
"settings.commands.description": "명령어 설정은 여기서 구성할 수 있습니다.",
|
"settings.commands.description": "명령어 설정은 여기서 구성할 수 있습니다.",
|
||||||
"settings.mcp.title": "MCP",
|
"settings.mcp.title": "MCP",
|
||||||
"settings.mcp.description": "MCP 설정은 여기서 구성할 수 있습니다.",
|
"settings.mcp.description": "MCP 설정은 여기서 구성할 수 있습니다.",
|
||||||
|
"settings.mcp.section.featured": "추천",
|
||||||
|
"settings.mcp.section.featured.description": "클릭 한 번으로 인기 있는 MCP 서버에 대한 프리셋을 추가하세요.",
|
||||||
|
"settings.mcp.section.configured": "구성된 서버",
|
||||||
|
"settings.mcp.section.configured.description":
|
||||||
|
"설치된 MCP 서버와 연결 방식을 확인하고, 더 이상 필요하지 않은 서버를 제거하세요.",
|
||||||
|
"settings.mcp.section.add": "서버 추가",
|
||||||
|
"settings.mcp.section.add.description": "로컬 또는 원격 MCP 서버 구성을 직접 생성하세요.",
|
||||||
|
"settings.mcp.type.local": "로컬",
|
||||||
|
"settings.mcp.type.remote": "원격",
|
||||||
|
"settings.mcp.featured.added": "추가됨",
|
||||||
|
"settings.mcp.action.add": "서버 추가",
|
||||||
|
"settings.mcp.action.remove": "제거",
|
||||||
|
"settings.mcp.state.enabled": "활성화됨",
|
||||||
|
"settings.mcp.state.disabled": "비활성화됨",
|
||||||
|
"settings.mcp.form.type.label": "연결 유형",
|
||||||
|
"settings.mcp.form.name.label": "서버 이름",
|
||||||
|
"settings.mcp.form.name.placeholder": "my-mcp-server",
|
||||||
|
"settings.mcp.form.url.label": "원격 URL",
|
||||||
|
"settings.mcp.form.url.placeholder": "https://mcp.example.com/mcp",
|
||||||
|
"settings.mcp.form.command.label": "명령",
|
||||||
|
"settings.mcp.form.command.placeholder": "npx -y @modelcontextprotocol/server-memory",
|
||||||
|
"settings.mcp.form.headers.label": "헤더",
|
||||||
|
"settings.mcp.form.headers.description":
|
||||||
|
"선택 사항입니다. KEY: value 형식을 사용하여 한 줄에 하나의 헤더를 추가하세요.",
|
||||||
|
"settings.mcp.form.environment.label": "환경 변수",
|
||||||
|
"settings.mcp.form.environment.description":
|
||||||
|
"선택 사항입니다. KEY=value 형식을 사용하여 한 줄에 하나의 변수를 추가하세요.",
|
||||||
|
"settings.mcp.form.timeout.label": "시간 초과 (ms)",
|
||||||
|
"settings.mcp.form.timeout.placeholder": "5000",
|
||||||
|
"settings.mcp.toast.added.title": "MCP 서버 추가됨",
|
||||||
|
"settings.mcp.toast.added.description": "{{name}} 서버가 MCP 설정에 저장되었습니다.",
|
||||||
|
"settings.mcp.toast.updated.title": "MCP 서버 업데이트됨",
|
||||||
|
"settings.mcp.toast.updated.description": "{{name}}이(가) 이제 {{state}} 상태입니다.",
|
||||||
|
"settings.mcp.toast.removed.title": "MCP 서버 제거됨",
|
||||||
|
"settings.mcp.toast.removed.description": "{{name}} 서버가 MCP 설정에서 제거되었습니다.",
|
||||||
|
"settings.mcp.validation.name": "저장하기 전에 서버 이름을 입력하세요.",
|
||||||
|
"settings.mcp.validation.duplicate": "{{name}}은(는) 이미 구성되어 있습니다.",
|
||||||
|
"settings.mcp.validation.url": "원격 MCP 서버 URL을 입력하세요.",
|
||||||
|
"settings.mcp.validation.command": "로컬 MCP 서버를 시작하는 데 사용되는 명령을 입력하세요.",
|
||||||
|
"settings.mcp.validation.timeout": "시간 초과는 양의 정수여야 합니다.",
|
||||||
|
"settings.mcp.validation.headers": "헤더 줄을 구문 분석할 수 없습니다: {{line}}",
|
||||||
|
"settings.mcp.validation.environment": "환경 변수 줄을 구문 분석할 수 없습니다: {{line}}",
|
||||||
|
"settings.mcp.status.needs_client_registration": "클라이언트 등록 필요",
|
||||||
"settings.permissions.title": "권한",
|
"settings.permissions.title": "권한",
|
||||||
"settings.permissions.description": "서버가 기본적으로 사용할 수 있는 도구를 제어합니다.",
|
"settings.permissions.description": "서버가 기본적으로 사용할 수 있는 도구를 제어합니다.",
|
||||||
"settings.permissions.section.tools": "도구",
|
"settings.permissions.section.tools": "도구",
|
||||||
|
|||||||
@@ -516,7 +516,6 @@ export const dict = {
|
|||||||
"session.todo.collapse": "Skjul",
|
"session.todo.collapse": "Skjul",
|
||||||
"session.todo.expand": "Utvid",
|
"session.todo.expand": "Utvid",
|
||||||
|
|
||||||
"session.new.title": "Bygg hva som helst",
|
|
||||||
"session.new.worktree.main": "Hovedgren",
|
"session.new.worktree.main": "Hovedgren",
|
||||||
"session.new.worktree.mainWithBranch": "Hovedgren ({{branch}})",
|
"session.new.worktree.mainWithBranch": "Hovedgren ({{branch}})",
|
||||||
"session.new.worktree.create": "Opprett nytt worktree",
|
"session.new.worktree.create": "Opprett nytt worktree",
|
||||||
@@ -748,6 +747,47 @@ export const dict = {
|
|||||||
"settings.commands.description": "Kommandoinnstillinger vil kunne konfigureres her.",
|
"settings.commands.description": "Kommandoinnstillinger vil kunne konfigureres her.",
|
||||||
"settings.mcp.title": "MCP",
|
"settings.mcp.title": "MCP",
|
||||||
"settings.mcp.description": "MCP-innstillinger vil kunne konfigureres her.",
|
"settings.mcp.description": "MCP-innstillinger vil kunne konfigureres her.",
|
||||||
|
"settings.mcp.section.featured": "Utvalgte",
|
||||||
|
"settings.mcp.section.featured.description": "Legg til et ferdig oppsett for populære MCP-servere med ett klikk.",
|
||||||
|
"settings.mcp.section.configured": "Konfigurerte servere",
|
||||||
|
"settings.mcp.section.configured.description":
|
||||||
|
"Se hvilke MCP-servere som er installert, hvordan de kobler til, og fjern de du ikke lenger trenger.",
|
||||||
|
"settings.mcp.section.add": "Legg til en server",
|
||||||
|
"settings.mcp.section.add.description": "Opprett din egen konfigurasjon for lokal eller ekstern MCP-server.",
|
||||||
|
"settings.mcp.type.local": "Lokal",
|
||||||
|
"settings.mcp.type.remote": "Ekstern",
|
||||||
|
"settings.mcp.featured.added": "Lagt til",
|
||||||
|
"settings.mcp.action.add": "Legg til server",
|
||||||
|
"settings.mcp.action.remove": "Fjern",
|
||||||
|
"settings.mcp.state.enabled": "Aktivert",
|
||||||
|
"settings.mcp.state.disabled": "Deaktivert",
|
||||||
|
"settings.mcp.form.type.label": "Tilkoblingstype",
|
||||||
|
"settings.mcp.form.name.label": "Servernavn",
|
||||||
|
"settings.mcp.form.name.placeholder": "my-mcp-server",
|
||||||
|
"settings.mcp.form.url.label": "Ekstern URL",
|
||||||
|
"settings.mcp.form.url.placeholder": "https://mcp.example.com/mcp",
|
||||||
|
"settings.mcp.form.command.label": "Kommando",
|
||||||
|
"settings.mcp.form.command.placeholder": "npx -y @modelcontextprotocol/server-memory",
|
||||||
|
"settings.mcp.form.headers.label": "Headere",
|
||||||
|
"settings.mcp.form.headers.description": "Valgfritt. Legg til én header per linje med KEY: value.",
|
||||||
|
"settings.mcp.form.environment.label": "Miljø",
|
||||||
|
"settings.mcp.form.environment.description": "Valgfritt. Legg til én variabel per linje med KEY=value.",
|
||||||
|
"settings.mcp.form.timeout.label": "Tidsavbrudd (ms)",
|
||||||
|
"settings.mcp.form.timeout.placeholder": "5000",
|
||||||
|
"settings.mcp.toast.added.title": "MCP-server lagt til",
|
||||||
|
"settings.mcp.toast.added.description": "{{name}} er lagret i dine MCP-innstillinger.",
|
||||||
|
"settings.mcp.toast.updated.title": "MCP-server oppdatert",
|
||||||
|
"settings.mcp.toast.updated.description": "{{name}} er nå {{state}}.",
|
||||||
|
"settings.mcp.toast.removed.title": "MCP-server fjernet",
|
||||||
|
"settings.mcp.toast.removed.description": "{{name}} er fjernet fra dine MCP-innstillinger.",
|
||||||
|
"settings.mcp.validation.name": "Skriv inn et servernavn før du lagrer.",
|
||||||
|
"settings.mcp.validation.duplicate": "{{name}} er allerede konfigurert.",
|
||||||
|
"settings.mcp.validation.url": "Skriv inn URL for ekstern MCP-server.",
|
||||||
|
"settings.mcp.validation.command": "Skriv inn kommandoen for å starte den lokale MCP-serveren.",
|
||||||
|
"settings.mcp.validation.timeout": "Tidsavbrudd må være et positivt heltall.",
|
||||||
|
"settings.mcp.validation.headers": "Kunne ikke tolke header-linje: {{line}}",
|
||||||
|
"settings.mcp.validation.environment": "Kunne ikke tolke miljøvariabel-linje: {{line}}",
|
||||||
|
"settings.mcp.status.needs_client_registration": "Krever klientregistrering",
|
||||||
|
|
||||||
"settings.permissions.title": "Tillatelser",
|
"settings.permissions.title": "Tillatelser",
|
||||||
"settings.permissions.description": "Kontroller hvilke verktøy serveren kan bruke som standard.",
|
"settings.permissions.description": "Kontroller hvilke verktøy serveren kan bruke som standard.",
|
||||||
|
|||||||
@@ -458,7 +458,6 @@ export const dict = {
|
|||||||
"session.todo.title": "Zadania",
|
"session.todo.title": "Zadania",
|
||||||
"session.todo.collapse": "Zwiń",
|
"session.todo.collapse": "Zwiń",
|
||||||
"session.todo.expand": "Rozwiń",
|
"session.todo.expand": "Rozwiń",
|
||||||
"session.new.title": "Zbuduj cokolwiek",
|
|
||||||
"session.new.worktree.main": "Główna gałąź",
|
"session.new.worktree.main": "Główna gałąź",
|
||||||
"session.new.worktree.mainWithBranch": "Główna gałąź ({{branch}})",
|
"session.new.worktree.mainWithBranch": "Główna gałąź ({{branch}})",
|
||||||
"session.new.worktree.create": "Utwórz nowe drzewo robocze",
|
"session.new.worktree.create": "Utwórz nowe drzewo robocze",
|
||||||
@@ -673,6 +672,48 @@ export const dict = {
|
|||||||
"settings.commands.description": "Ustawienia poleceń będą tutaj konfigurowalne.",
|
"settings.commands.description": "Ustawienia poleceń będą tutaj konfigurowalne.",
|
||||||
"settings.mcp.title": "MCP",
|
"settings.mcp.title": "MCP",
|
||||||
"settings.mcp.description": "Ustawienia MCP będą tutaj konfigurowalne.",
|
"settings.mcp.description": "Ustawienia MCP będą tutaj konfigurowalne.",
|
||||||
|
"settings.mcp.section.featured": "Wyróżnione",
|
||||||
|
"settings.mcp.section.featured.description":
|
||||||
|
"Dodaj dopracowany zestaw dla popularnych serwerów MCP jednym kliknięciem.",
|
||||||
|
"settings.mcp.section.configured": "Skonfigurowane serwery",
|
||||||
|
"settings.mcp.section.configured.description":
|
||||||
|
"Zobacz, które serwery MCP są zainstalowane, jak się łączą, i usuń te, których już nie potrzebujesz.",
|
||||||
|
"settings.mcp.section.add": "Dodaj serwer",
|
||||||
|
"settings.mcp.section.add.description": "Utwórz własną konfigurację lokalnego lub zdalnego serwera MCP.",
|
||||||
|
"settings.mcp.type.local": "Lokalny",
|
||||||
|
"settings.mcp.type.remote": "Zdalny",
|
||||||
|
"settings.mcp.featured.added": "Dodano",
|
||||||
|
"settings.mcp.action.add": "Dodaj serwer",
|
||||||
|
"settings.mcp.action.remove": "Usuń",
|
||||||
|
"settings.mcp.state.enabled": "Włączony",
|
||||||
|
"settings.mcp.state.disabled": "Wyłączony",
|
||||||
|
"settings.mcp.form.type.label": "Typ połączenia",
|
||||||
|
"settings.mcp.form.name.label": "Nazwa serwera",
|
||||||
|
"settings.mcp.form.name.placeholder": "my-mcp-server",
|
||||||
|
"settings.mcp.form.url.label": "Zdalny adres URL",
|
||||||
|
"settings.mcp.form.url.placeholder": "https://mcp.example.com/mcp",
|
||||||
|
"settings.mcp.form.command.label": "Polecenie",
|
||||||
|
"settings.mcp.form.command.placeholder": "npx -y @modelcontextprotocol/server-memory",
|
||||||
|
"settings.mcp.form.headers.label": "Nagłówki",
|
||||||
|
"settings.mcp.form.headers.description": "Opcjonalne. Dodaj jeden nagłówek w wierszu używając formatu KEY: value.",
|
||||||
|
"settings.mcp.form.environment.label": "Środowisko",
|
||||||
|
"settings.mcp.form.environment.description": "Opcjonalne. Dodaj jedną zmienną w wierszu używając formatu KEY=value.",
|
||||||
|
"settings.mcp.form.timeout.label": "Limit czasu (ms)",
|
||||||
|
"settings.mcp.form.timeout.placeholder": "5000",
|
||||||
|
"settings.mcp.toast.added.title": "Dodano serwer MCP",
|
||||||
|
"settings.mcp.toast.added.description": "{{name}} został zapisany w ustawieniach MCP.",
|
||||||
|
"settings.mcp.toast.updated.title": "Zaktualizowano serwer MCP",
|
||||||
|
"settings.mcp.toast.updated.description": "{{name}} jest teraz {{state}}.",
|
||||||
|
"settings.mcp.toast.removed.title": "Usunięto serwer MCP",
|
||||||
|
"settings.mcp.toast.removed.description": "{{name}} został usunięty z ustawień MCP.",
|
||||||
|
"settings.mcp.validation.name": "Wprowadź nazwę serwera przed zapisaniem.",
|
||||||
|
"settings.mcp.validation.duplicate": "{{name}} jest już skonfigurowany.",
|
||||||
|
"settings.mcp.validation.url": "Wprowadź adres URL zdalnego serwera MCP.",
|
||||||
|
"settings.mcp.validation.command": "Wprowadź polecenie służące do uruchomienia lokalnego serwera MCP.",
|
||||||
|
"settings.mcp.validation.timeout": "Limit czasu musi być dodatnią liczbą całkowitą.",
|
||||||
|
"settings.mcp.validation.headers": "Nie udało się przetworzyć wiersza nagłówka: {{line}}",
|
||||||
|
"settings.mcp.validation.environment": "Nie udało się przetworzyć wiersza środowiska: {{line}}",
|
||||||
|
"settings.mcp.status.needs_client_registration": "Wymaga rejestracji klienta",
|
||||||
"settings.permissions.title": "Uprawnienia",
|
"settings.permissions.title": "Uprawnienia",
|
||||||
"settings.permissions.description": "Kontroluj, jakich narzędzi serwer może używać domyślnie.",
|
"settings.permissions.description": "Kontroluj, jakich narzędzi serwer może używać domyślnie.",
|
||||||
"settings.permissions.section.tools": "Narzędzia",
|
"settings.permissions.section.tools": "Narzędzia",
|
||||||
|
|||||||
@@ -514,7 +514,6 @@ export const dict = {
|
|||||||
"session.todo.collapse": "Свернуть",
|
"session.todo.collapse": "Свернуть",
|
||||||
"session.todo.expand": "Развернуть",
|
"session.todo.expand": "Развернуть",
|
||||||
|
|
||||||
"session.new.title": "Создавайте что угодно",
|
|
||||||
"session.new.worktree.main": "Основная ветка",
|
"session.new.worktree.main": "Основная ветка",
|
||||||
"session.new.worktree.mainWithBranch": "Основная ветка ({{branch}})",
|
"session.new.worktree.mainWithBranch": "Основная ветка ({{branch}})",
|
||||||
"session.new.worktree.create": "Создать новый worktree",
|
"session.new.worktree.create": "Создать новый worktree",
|
||||||
@@ -748,6 +747,49 @@ export const dict = {
|
|||||||
"settings.commands.description": "Настройки команд будут доступны здесь.",
|
"settings.commands.description": "Настройки команд будут доступны здесь.",
|
||||||
"settings.mcp.title": "MCP",
|
"settings.mcp.title": "MCP",
|
||||||
"settings.mcp.description": "Настройки MCP будут доступны здесь.",
|
"settings.mcp.description": "Настройки MCP будут доступны здесь.",
|
||||||
|
"settings.mcp.section.featured": "Рекомендуемые",
|
||||||
|
"settings.mcp.section.featured.description": "Добавляйте готовые настройки для популярных серверов MCP в один клик.",
|
||||||
|
"settings.mcp.section.configured": "Настроенные серверы",
|
||||||
|
"settings.mcp.section.configured.description":
|
||||||
|
"Просматривайте установленные серверы MCP, способы их подключения и удаляйте ненужные.",
|
||||||
|
"settings.mcp.section.add": "Добавить сервер",
|
||||||
|
"settings.mcp.section.add.description": "Создайте собственную конфигурацию локального или удаленного сервера MCP.",
|
||||||
|
"settings.mcp.type.local": "Локальный",
|
||||||
|
"settings.mcp.type.remote": "Удаленный",
|
||||||
|
"settings.mcp.featured.added": "Добавлено",
|
||||||
|
"settings.mcp.action.add": "Добавить сервер",
|
||||||
|
"settings.mcp.action.remove": "Удалить",
|
||||||
|
"settings.mcp.state.enabled": "Включено",
|
||||||
|
"settings.mcp.state.disabled": "Отключено",
|
||||||
|
"settings.mcp.form.type.label": "Тип подключения",
|
||||||
|
"settings.mcp.form.name.label": "Имя сервера",
|
||||||
|
"settings.mcp.form.name.placeholder": "my-mcp-server",
|
||||||
|
"settings.mcp.form.url.label": "URL удаленного сервера",
|
||||||
|
"settings.mcp.form.url.placeholder": "https://mcp.example.com/mcp",
|
||||||
|
"settings.mcp.form.command.label": "Команда",
|
||||||
|
"settings.mcp.form.command.placeholder": "npx -y @modelcontextprotocol/server-memory",
|
||||||
|
"settings.mcp.form.headers.label": "Заголовки",
|
||||||
|
"settings.mcp.form.headers.description":
|
||||||
|
"Необязательно. Добавьте по одному заголовку на строку в формате KEY: value.",
|
||||||
|
"settings.mcp.form.environment.label": "Окружение",
|
||||||
|
"settings.mcp.form.environment.description":
|
||||||
|
"Необязательно. Добавьте по одной переменной на строку в формате KEY=value.",
|
||||||
|
"settings.mcp.form.timeout.label": "Тайм-аут (мс)",
|
||||||
|
"settings.mcp.form.timeout.placeholder": "5000",
|
||||||
|
"settings.mcp.toast.added.title": "Сервер MCP добавлен",
|
||||||
|
"settings.mcp.toast.added.description": "{{name}} сохранен в настройках MCP.",
|
||||||
|
"settings.mcp.toast.updated.title": "Сервер MCP обновлен",
|
||||||
|
"settings.mcp.toast.updated.description": "{{name}} теперь {{state}}.",
|
||||||
|
"settings.mcp.toast.removed.title": "Сервер MCP удален",
|
||||||
|
"settings.mcp.toast.removed.description": "{{name}} удален из настроек MCP.",
|
||||||
|
"settings.mcp.validation.name": "Введите имя сервера перед сохранением.",
|
||||||
|
"settings.mcp.validation.duplicate": "{{name}} уже настроен.",
|
||||||
|
"settings.mcp.validation.url": "Введите URL удаленного сервера MCP.",
|
||||||
|
"settings.mcp.validation.command": "Введите команду для запуска локального сервера MCP.",
|
||||||
|
"settings.mcp.validation.timeout": "Тайм-аут должен быть положительным целым числом.",
|
||||||
|
"settings.mcp.validation.headers": "Не удалось разобрать строку заголовка: {{line}}",
|
||||||
|
"settings.mcp.validation.environment": "Не удалось разобрать строку окружения: {{line}}",
|
||||||
|
"settings.mcp.status.needs_client_registration": "Требуется регистрация клиента",
|
||||||
|
|
||||||
"settings.permissions.title": "Разрешения",
|
"settings.permissions.title": "Разрешения",
|
||||||
"settings.permissions.description": "Контролируйте какие инструменты сервер может использовать по умолчанию.",
|
"settings.permissions.description": "Контролируйте какие инструменты сервер может использовать по умолчанию.",
|
||||||
|
|||||||
@@ -511,7 +511,6 @@ export const dict = {
|
|||||||
"session.todo.collapse": "ย่อ",
|
"session.todo.collapse": "ย่อ",
|
||||||
"session.todo.expand": "ขยาย",
|
"session.todo.expand": "ขยาย",
|
||||||
|
|
||||||
"session.new.title": "สร้างอะไรก็ได้",
|
|
||||||
"session.new.worktree.main": "สาขาหลัก",
|
"session.new.worktree.main": "สาขาหลัก",
|
||||||
"session.new.worktree.mainWithBranch": "สาขาหลัก ({{branch}})",
|
"session.new.worktree.mainWithBranch": "สาขาหลัก ({{branch}})",
|
||||||
"session.new.worktree.create": "สร้าง worktree ใหม่",
|
"session.new.worktree.create": "สร้าง worktree ใหม่",
|
||||||
@@ -739,6 +738,47 @@ export const dict = {
|
|||||||
"settings.commands.description": "การตั้งค่าคำสั่งจะสามารถกำหนดค่าได้ที่นี่",
|
"settings.commands.description": "การตั้งค่าคำสั่งจะสามารถกำหนดค่าได้ที่นี่",
|
||||||
"settings.mcp.title": "MCP",
|
"settings.mcp.title": "MCP",
|
||||||
"settings.mcp.description": "การตั้งค่า MCP จะสามารถกำหนดค่าได้ที่นี่",
|
"settings.mcp.description": "การตั้งค่า MCP จะสามารถกำหนดค่าได้ที่นี่",
|
||||||
|
"settings.mcp.section.featured": "แนะนำ",
|
||||||
|
"settings.mcp.section.featured.description": "เพิ่มค่าที่ตั้งไว้ล่วงหน้าสำหรับเซิร์ฟเวอร์ MCP ยอดนิยมได้ในคลิกเดียว",
|
||||||
|
"settings.mcp.section.configured": "เซิร์ฟเวอร์ที่กำหนดค่าแล้ว",
|
||||||
|
"settings.mcp.section.configured.description":
|
||||||
|
"ดูว่าเซิร์ฟเวอร์ MCP ใดติดตั้งอยู่ เชื่อมต่ออย่างไร และลบสิ่งที่ไม่ต้องการออก",
|
||||||
|
"settings.mcp.section.add": "เพิ่มเซิร์ฟเวอร์",
|
||||||
|
"settings.mcp.section.add.description": "สร้างการกำหนดค่าเซิร์ฟเวอร์ MCP แบบ Local หรือ Remote ของคุณเอง",
|
||||||
|
"settings.mcp.type.local": "Local",
|
||||||
|
"settings.mcp.type.remote": "Remote",
|
||||||
|
"settings.mcp.featured.added": "เพิ่มแล้ว",
|
||||||
|
"settings.mcp.action.add": "เพิ่มเซิร์ฟเวอร์",
|
||||||
|
"settings.mcp.action.remove": "ลบ",
|
||||||
|
"settings.mcp.state.enabled": "เปิดใช้งาน",
|
||||||
|
"settings.mcp.state.disabled": "ปิดใช้งาน",
|
||||||
|
"settings.mcp.form.type.label": "ประเภทการเชื่อมต่อ",
|
||||||
|
"settings.mcp.form.name.label": "ชื่อเซิร์ฟเวอร์",
|
||||||
|
"settings.mcp.form.name.placeholder": "my-mcp-server",
|
||||||
|
"settings.mcp.form.url.label": "URL ระยะไกล",
|
||||||
|
"settings.mcp.form.url.placeholder": "https://mcp.example.com/mcp",
|
||||||
|
"settings.mcp.form.command.label": "คำสั่ง",
|
||||||
|
"settings.mcp.form.command.placeholder": "npx -y @modelcontextprotocol/server-memory",
|
||||||
|
"settings.mcp.form.headers.label": "Headers",
|
||||||
|
"settings.mcp.form.headers.description": "ไม่บังคับ เพิ่มหนึ่ง header ต่อบรรทัดโดยใช้ KEY: value",
|
||||||
|
"settings.mcp.form.environment.label": "Environment",
|
||||||
|
"settings.mcp.form.environment.description": "ไม่บังคับ เพิ่มหนึ่งตัวแปรต่อบรรทัดโดยใช้ KEY=value",
|
||||||
|
"settings.mcp.form.timeout.label": "Timeout (ms)",
|
||||||
|
"settings.mcp.form.timeout.placeholder": "5000",
|
||||||
|
"settings.mcp.toast.added.title": "เพิ่มเซิร์ฟเวอร์ MCP แล้ว",
|
||||||
|
"settings.mcp.toast.added.description": "บันทึก {{name}} ลงในค่าติดตั้ง MCP ของคุณแล้ว",
|
||||||
|
"settings.mcp.toast.updated.title": "อัปเดตเซิร์ฟเวอร์ MCP แล้ว",
|
||||||
|
"settings.mcp.toast.updated.description": "ขณะนี้ {{name}} อยู่ในสถานะ {{state}}",
|
||||||
|
"settings.mcp.toast.removed.title": "ลบเซิร์ฟเวอร์ MCP แล้ว",
|
||||||
|
"settings.mcp.toast.removed.description": "ลบ {{name}} ออกจากค่าติดตั้ง MCP ของคุณแล้ว",
|
||||||
|
"settings.mcp.validation.name": "ป้อนชื่อเซิร์ฟเวอร์ก่อนบันทึก",
|
||||||
|
"settings.mcp.validation.duplicate": "มีการกำหนดค่า {{name}} ไว้แล้ว",
|
||||||
|
"settings.mcp.validation.url": "ป้อน URL ของเซิร์ฟเวอร์ MCP ระยะไกล",
|
||||||
|
"settings.mcp.validation.command": "ป้อนคำสั่งที่ใช้เริ่มต้นเซิร์ฟเวอร์ MCP แบบ Local",
|
||||||
|
"settings.mcp.validation.timeout": "Timeout ต้องเป็นจำนวนเต็มบวก",
|
||||||
|
"settings.mcp.validation.headers": "ไม่สามารถแยกวิเคราะห์บรรทัด header: {{line}}",
|
||||||
|
"settings.mcp.validation.environment": "ไม่สามารถแยกวิเคราะห์บรรทัด environment: {{line}}",
|
||||||
|
"settings.mcp.status.needs_client_registration": "ต้องลงทะเบียนไคลเอนต์",
|
||||||
|
|
||||||
"settings.permissions.title": "สิทธิ์",
|
"settings.permissions.title": "สิทธิ์",
|
||||||
"settings.permissions.description": "ควบคุมเครื่องมือที่เซิร์ฟเวอร์สามารถใช้โดยค่าเริ่มต้น",
|
"settings.permissions.description": "ควบคุมเครื่องมือที่เซิร์ฟเวอร์สามารถใช้โดยค่าเริ่มต้น",
|
||||||
|
|||||||
@@ -523,7 +523,6 @@ export const dict = {
|
|||||||
"session.todo.collapse": "Daralt",
|
"session.todo.collapse": "Daralt",
|
||||||
"session.todo.expand": "Genişlet",
|
"session.todo.expand": "Genişlet",
|
||||||
|
|
||||||
"session.new.title": "İstediğini yap",
|
|
||||||
"session.new.worktree.main": "Ana dal",
|
"session.new.worktree.main": "Ana dal",
|
||||||
"session.new.worktree.mainWithBranch": "Ana dal ({{branch}})",
|
"session.new.worktree.mainWithBranch": "Ana dal ({{branch}})",
|
||||||
"session.new.worktree.create": "Yeni çalışma ağacı oluştur",
|
"session.new.worktree.create": "Yeni çalışma ağacı oluştur",
|
||||||
@@ -760,6 +759,50 @@ export const dict = {
|
|||||||
"settings.commands.description": "Komut ayarları burada yapılandırılabilecek.",
|
"settings.commands.description": "Komut ayarları burada yapılandırılabilecek.",
|
||||||
"settings.mcp.title": "MCP",
|
"settings.mcp.title": "MCP",
|
||||||
"settings.mcp.description": "MCP ayarları burada yapılandırılabilecek.",
|
"settings.mcp.description": "MCP ayarları burada yapılandırılabilecek.",
|
||||||
|
"settings.mcp.section.featured": "Öne Çıkanlar",
|
||||||
|
"settings.mcp.section.featured.description":
|
||||||
|
"Tek tıkla popüler MCP sunucuları için cilalanmış bir hazır ayar ekleyin.",
|
||||||
|
"settings.mcp.section.configured": "Yapılandırılmış sunucular",
|
||||||
|
"settings.mcp.section.configured.description":
|
||||||
|
"Hangi MCP sunucularının yüklü olduğunu ve nasıl bağlandıklarını görün; artık ihtiyaç duymadıklarınızı kaldırın.",
|
||||||
|
"settings.mcp.section.add": "Sunucu ekle",
|
||||||
|
"settings.mcp.section.add.description": "Kendi yerel veya uzak MCP sunucu yapılandırmanızı oluşturun.",
|
||||||
|
"settings.mcp.type.local": "Yerel",
|
||||||
|
"settings.mcp.type.remote": "Uzak",
|
||||||
|
"settings.mcp.featured.added": "Eklendi",
|
||||||
|
"settings.mcp.action.add": "Sunucu ekle",
|
||||||
|
"settings.mcp.action.remove": "Kaldır",
|
||||||
|
"settings.mcp.state.enabled": "Etkin",
|
||||||
|
"settings.mcp.state.disabled": "Devre Dışı",
|
||||||
|
"settings.mcp.form.type.label": "Bağlantı türü",
|
||||||
|
"settings.mcp.form.name.label": "Sunucu adı",
|
||||||
|
"settings.mcp.form.name.placeholder": "my-mcp-server",
|
||||||
|
"settings.mcp.form.url.label": "Uzak URL",
|
||||||
|
"settings.mcp.form.url.placeholder": "https://mcp.example.com/mcp",
|
||||||
|
"settings.mcp.form.command.label": "Komut",
|
||||||
|
"settings.mcp.form.command.placeholder": "npx -y @modelcontextprotocol/server-memory",
|
||||||
|
"settings.mcp.form.headers.label": "Başlıklar",
|
||||||
|
"settings.mcp.form.headers.description":
|
||||||
|
"İsteğe bağlı. Her satıra bir başlık olacak şekilde KEY: value biçiminde ekleyin.",
|
||||||
|
"settings.mcp.form.environment.label": "Ortam",
|
||||||
|
"settings.mcp.form.environment.description":
|
||||||
|
"İsteğe bağlı. Her satıra bir değişken olacak şekilde KEY=value biçiminde ekleyin.",
|
||||||
|
"settings.mcp.form.timeout.label": "Zaman aşımı (ms)",
|
||||||
|
"settings.mcp.form.timeout.placeholder": "5000",
|
||||||
|
"settings.mcp.toast.added.title": "MCP sunucusu eklendi",
|
||||||
|
"settings.mcp.toast.added.description": "{{name}}, MCP ayarlarınıza kaydedildi.",
|
||||||
|
"settings.mcp.toast.updated.title": "MCP sunucusu güncellendi",
|
||||||
|
"settings.mcp.toast.updated.description": "{{name}} artık {{state}}.",
|
||||||
|
"settings.mcp.toast.removed.title": "MCP sunucusu kaldırıldı",
|
||||||
|
"settings.mcp.toast.removed.description": "{{name}}, MCP ayarlarınızdan kaldırıldı.",
|
||||||
|
"settings.mcp.validation.name": "Kaydetmeden önce bir sunucu adı girin.",
|
||||||
|
"settings.mcp.validation.duplicate": "{{name}} zaten yapılandırılmış.",
|
||||||
|
"settings.mcp.validation.url": "Bir uzak MCP sunucu URL'si girin.",
|
||||||
|
"settings.mcp.validation.command": "Yerel MCP sunucusunu başlatmak için kullanılan komutu girin.",
|
||||||
|
"settings.mcp.validation.timeout": "Zaman aşımı pozitif bir tam sayı olmalıdır.",
|
||||||
|
"settings.mcp.validation.headers": "Başlık satırı ayrıştırılamadı: {{line}}",
|
||||||
|
"settings.mcp.validation.environment": "Ortam satırı ayrıştırılamadı: {{line}}",
|
||||||
|
"settings.mcp.status.needs_client_registration": "İstemci kaydı gerekiyor",
|
||||||
|
|
||||||
"settings.permissions.title": "İzinler",
|
"settings.permissions.title": "İzinler",
|
||||||
"settings.permissions.description": "Sunucunun varsayılan olarak hangi araçları kullanabileceğini kontrol edin.",
|
"settings.permissions.description": "Sunucunun varsayılan olarak hangi araçları kullanabileceğini kontrol edin.",
|
||||||
|
|||||||
@@ -510,7 +510,6 @@ export const dict = {
|
|||||||
"session.todo.title": "待办事项",
|
"session.todo.title": "待办事项",
|
||||||
"session.todo.collapse": "折叠",
|
"session.todo.collapse": "折叠",
|
||||||
"session.todo.expand": "展开",
|
"session.todo.expand": "展开",
|
||||||
"session.new.title": "构建任何东西",
|
|
||||||
"session.new.worktree.main": "主分支",
|
"session.new.worktree.main": "主分支",
|
||||||
"session.new.worktree.mainWithBranch": "主分支({{branch}})",
|
"session.new.worktree.mainWithBranch": "主分支({{branch}})",
|
||||||
"session.new.worktree.create": "创建新的 worktree",
|
"session.new.worktree.create": "创建新的 worktree",
|
||||||
@@ -739,6 +738,46 @@ export const dict = {
|
|||||||
|
|
||||||
"settings.mcp.title": "MCP",
|
"settings.mcp.title": "MCP",
|
||||||
"settings.mcp.description": "MCP 设置将在此处可配置。",
|
"settings.mcp.description": "MCP 设置将在此处可配置。",
|
||||||
|
"settings.mcp.section.featured": "精选",
|
||||||
|
"settings.mcp.section.featured.description": "一键添加经过精心优化的热门 MCP 服务器预设。",
|
||||||
|
"settings.mcp.section.configured": "已配置的服务器",
|
||||||
|
"settings.mcp.section.configured.description": "查看已安装的 MCP 服务器及其连接方式,并移除不再需要的服务器。",
|
||||||
|
"settings.mcp.section.add": "添加服务器",
|
||||||
|
"settings.mcp.section.add.description": "创建您自己的本地或远程 MCP 服务器配置。",
|
||||||
|
"settings.mcp.type.local": "本地",
|
||||||
|
"settings.mcp.type.remote": "远程",
|
||||||
|
"settings.mcp.featured.added": "已添加",
|
||||||
|
"settings.mcp.action.add": "添加服务器",
|
||||||
|
"settings.mcp.action.remove": "移除",
|
||||||
|
"settings.mcp.state.enabled": "已启用",
|
||||||
|
"settings.mcp.state.disabled": "已禁用",
|
||||||
|
"settings.mcp.form.type.label": "连接类型",
|
||||||
|
"settings.mcp.form.name.label": "服务器名称",
|
||||||
|
"settings.mcp.form.name.placeholder": "my-mcp-server",
|
||||||
|
"settings.mcp.form.url.label": "远程 URL",
|
||||||
|
"settings.mcp.form.url.placeholder": "https://mcp.example.com/mcp",
|
||||||
|
"settings.mcp.form.command.label": "命令",
|
||||||
|
"settings.mcp.form.command.placeholder": "npx -y @modelcontextprotocol/server-memory",
|
||||||
|
"settings.mcp.form.headers.label": "请求头",
|
||||||
|
"settings.mcp.form.headers.description": "可选。每行添加一个请求头,格式为 KEY: value。",
|
||||||
|
"settings.mcp.form.environment.label": "环境变量",
|
||||||
|
"settings.mcp.form.environment.description": "可选。每行添加一个变量,格式为 KEY=value。",
|
||||||
|
"settings.mcp.form.timeout.label": "超时 (ms)",
|
||||||
|
"settings.mcp.form.timeout.placeholder": "5000",
|
||||||
|
"settings.mcp.toast.added.title": "MCP 服务器已添加",
|
||||||
|
"settings.mcp.toast.added.description": "{{name}} 已保存到您的 MCP 设置中。",
|
||||||
|
"settings.mcp.toast.updated.title": "MCP 服务器已更新",
|
||||||
|
"settings.mcp.toast.updated.description": "{{name}} 现已{{state}}。",
|
||||||
|
"settings.mcp.toast.removed.title": "MCP 服务器已移除",
|
||||||
|
"settings.mcp.toast.removed.description": "{{name}} 已从您的 MCP 设置中移除。",
|
||||||
|
"settings.mcp.validation.name": "保存前请输入服务器名称。",
|
||||||
|
"settings.mcp.validation.duplicate": "{{name}} 已配置。",
|
||||||
|
"settings.mcp.validation.url": "请输入远程 MCP 服务器 URL。",
|
||||||
|
"settings.mcp.validation.command": "请输入用于启动本地 MCP 服务器的命令。",
|
||||||
|
"settings.mcp.validation.timeout": "超时必须为正整数。",
|
||||||
|
"settings.mcp.validation.headers": "无法解析请求头行:{{line}}",
|
||||||
|
"settings.mcp.validation.environment": "无法解析环境行:{{line}}",
|
||||||
|
"settings.mcp.status.needs_client_registration": "需要客户端注册",
|
||||||
|
|
||||||
"settings.permissions.title": "权限",
|
"settings.permissions.title": "权限",
|
||||||
"settings.permissions.description": "控制服务器默认可以使用哪些工具。",
|
"settings.permissions.description": "控制服务器默认可以使用哪些工具。",
|
||||||
|
|||||||
@@ -507,7 +507,6 @@ export const dict = {
|
|||||||
"session.todo.collapse": "折疊",
|
"session.todo.collapse": "折疊",
|
||||||
"session.todo.expand": "展開",
|
"session.todo.expand": "展開",
|
||||||
|
|
||||||
"session.new.title": "建構任何東西",
|
|
||||||
"session.new.worktree.main": "主分支",
|
"session.new.worktree.main": "主分支",
|
||||||
"session.new.worktree.mainWithBranch": "主分支 ({{branch}})",
|
"session.new.worktree.mainWithBranch": "主分支 ({{branch}})",
|
||||||
"session.new.worktree.create": "建立新的 worktree",
|
"session.new.worktree.create": "建立新的 worktree",
|
||||||
@@ -732,6 +731,46 @@ export const dict = {
|
|||||||
"settings.commands.description": "命令設定將在此處可設定。",
|
"settings.commands.description": "命令設定將在此處可設定。",
|
||||||
"settings.mcp.title": "MCP",
|
"settings.mcp.title": "MCP",
|
||||||
"settings.mcp.description": "MCP 設定將在此處可設定。",
|
"settings.mcp.description": "MCP 設定將在此處可設定。",
|
||||||
|
"settings.mcp.section.featured": "精選",
|
||||||
|
"settings.mcp.section.featured.description": "一鍵新增熱門 MCP 伺服器的精選預設設定。",
|
||||||
|
"settings.mcp.section.configured": "已設定的伺服器",
|
||||||
|
"settings.mcp.section.configured.description": "查看已安裝的 MCP 伺服器及其連線方式,並移除不再需要的伺服器。",
|
||||||
|
"settings.mcp.section.add": "新增伺服器",
|
||||||
|
"settings.mcp.section.add.description": "建立您自己的本機或遠端 MCP 伺服器設定。",
|
||||||
|
"settings.mcp.type.local": "本機",
|
||||||
|
"settings.mcp.type.remote": "遠端",
|
||||||
|
"settings.mcp.featured.added": "已新增",
|
||||||
|
"settings.mcp.action.add": "新增伺服器",
|
||||||
|
"settings.mcp.action.remove": "移除",
|
||||||
|
"settings.mcp.state.enabled": "已啟用",
|
||||||
|
"settings.mcp.state.disabled": "已停用",
|
||||||
|
"settings.mcp.form.type.label": "連線類型",
|
||||||
|
"settings.mcp.form.name.label": "伺服器名稱",
|
||||||
|
"settings.mcp.form.name.placeholder": "my-mcp-server",
|
||||||
|
"settings.mcp.form.url.label": "遠端 URL",
|
||||||
|
"settings.mcp.form.url.placeholder": "https://mcp.example.com/mcp",
|
||||||
|
"settings.mcp.form.command.label": "指令",
|
||||||
|
"settings.mcp.form.command.placeholder": "npx -y @modelcontextprotocol/server-memory",
|
||||||
|
"settings.mcp.form.headers.label": "標頭",
|
||||||
|
"settings.mcp.form.headers.description": "選用。每行新增一個標頭,格式為 KEY: value。",
|
||||||
|
"settings.mcp.form.environment.label": "環境變數",
|
||||||
|
"settings.mcp.form.environment.description": "選用。每行新增一個變數,格式為 KEY=value。",
|
||||||
|
"settings.mcp.form.timeout.label": "逾時 (ms)",
|
||||||
|
"settings.mcp.form.timeout.placeholder": "5000",
|
||||||
|
"settings.mcp.toast.added.title": "已新增 MCP 伺服器",
|
||||||
|
"settings.mcp.toast.added.description": "{{name}} 已儲存至您的 MCP 設定。",
|
||||||
|
"settings.mcp.toast.updated.title": "MCP 伺服器已更新",
|
||||||
|
"settings.mcp.toast.updated.description": "{{name}} 現已{{state}}。",
|
||||||
|
"settings.mcp.toast.removed.title": "已移除 MCP 伺服器",
|
||||||
|
"settings.mcp.toast.removed.description": "{{name}} 已從您的 MCP 設定中移除。",
|
||||||
|
"settings.mcp.validation.name": "儲存前請輸入伺服器名稱。",
|
||||||
|
"settings.mcp.validation.duplicate": "{{name}} 已經設定過了。",
|
||||||
|
"settings.mcp.validation.url": "請輸入遠端 MCP 伺服器 URL。",
|
||||||
|
"settings.mcp.validation.command": "請輸入用於啟動本機 MCP 伺服器的指令。",
|
||||||
|
"settings.mcp.validation.timeout": "逾時必須是正整數。",
|
||||||
|
"settings.mcp.validation.headers": "無法解析標頭行:{{line}}",
|
||||||
|
"settings.mcp.validation.environment": "無法解析環境變數行:{{line}}",
|
||||||
|
"settings.mcp.status.needs_client_registration": "需要用戶端註冊",
|
||||||
|
|
||||||
"settings.permissions.title": "權限",
|
"settings.permissions.title": "權限",
|
||||||
"settings.permissions.description": "控制伺服器預設可以使用哪些工具。",
|
"settings.permissions.description": "控制伺服器預設可以使用哪些工具。",
|
||||||
|
|||||||
@@ -1,29 +1 @@
|
|||||||
@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,27 +1,26 @@
|
|||||||
import { batch, createEffect, createMemo, Show, type ParentProps } from "solid-js"
|
import { createEffect, createMemo, Show, type ParentProps } from "solid-js"
|
||||||
import { createStore } from "solid-js/store"
|
import { createStore } from "solid-js/store"
|
||||||
import { useLocation, useNavigate, useParams } from "@solidjs/router"
|
import { 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(`/${slug()}/session/${sessionID}`)}
|
onNavigateToSession={(sessionID: string) => navigate(`/${params.dir}/session/${sessionID}`)}
|
||||||
onSessionHref={(sessionID: string) => `/${slug()}/session/${sessionID}`}
|
onSessionHref={(sessionID: string) => `/${params.dir}/session/${sessionID}`}
|
||||||
>
|
>
|
||||||
<LocalProvider>{props.children}</LocalProvider>
|
<LocalProvider>{props.children}</LocalProvider>
|
||||||
</DataProvider>
|
</DataProvider>
|
||||||
@@ -31,63 +30,31 @@ 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 globalSDK = useGlobalSDK()
|
const [store, setStore] = createStore({ invalid: "" })
|
||||||
const directory = createMemo(() => decode64(params.dir) ?? "")
|
const directory = createMemo(() => {
|
||||||
const [state, setState] = createStore({ invalid: "", resolved: "" })
|
return decode64(params.dir) ?? ""
|
||||||
|
})
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
if (!params.dir) return
|
if (!params.dir) return
|
||||||
const raw = directory()
|
if (directory()) return
|
||||||
if (!raw) {
|
if (store.invalid === params.dir) return
|
||||||
if (state.invalid === params.dir) return
|
setStore("invalid", params.dir)
|
||||||
setState("invalid", params.dir)
|
|
||||||
showToast({
|
showToast({
|
||||||
variant: "error",
|
variant: "error",
|
||||||
title: language.t("common.requestFailed"),
|
title: language.t("common.requestFailed"),
|
||||||
description: language.t("directory.error.invalidUrl"),
|
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={state.resolved}>
|
<Show when={directory()}>
|
||||||
{(resolved) => (
|
<SDKProvider directory={directory}>
|
||||||
<SDKProvider directory={resolved}>
|
|
||||||
<SyncProvider>
|
<SyncProvider>
|
||||||
<DirectoryDataProvider directory={resolved()}>{props.children}</DirectoryDataProvider>
|
<DirectoryDataProvider directory={directory()}>{props.children}</DirectoryDataProvider>
|
||||||
</SyncProvider>
|
</SyncProvider>
|
||||||
</SDKProvider>
|
</SDKProvider>
|
||||||
)}
|
|
||||||
</Show>
|
</Show>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,8 +10,9 @@ import {
|
|||||||
ParentProps,
|
ParentProps,
|
||||||
Show,
|
Show,
|
||||||
untrack,
|
untrack,
|
||||||
|
type JSX,
|
||||||
} from "solid-js"
|
} from "solid-js"
|
||||||
import { useNavigate, useParams } from "@solidjs/router"
|
import { A, 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"
|
||||||
@@ -19,8 +20,9 @@ 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 } from "@opencode-ai/ui/tooltip"
|
import { Tooltip, TooltipKeybind } 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"
|
||||||
@@ -34,7 +36,6 @@ import { useProviders } from "@/hooks/use-providers"
|
|||||||
import { showToast, Toast, toaster } from "@opencode-ai/ui/toast"
|
import { showToast, Toast, toaster } from "@opencode-ai/ui/toast"
|
||||||
import { useGlobalSDK } from "@/context/global-sdk"
|
import { useGlobalSDK } from "@/context/global-sdk"
|
||||||
import { clearWorkspaceTerminals } from "@/context/terminal"
|
import { clearWorkspaceTerminals } from "@/context/terminal"
|
||||||
import { dropSessionCaches, pickSessionCacheEvictions } from "@/context/global-sync/session-cache"
|
|
||||||
import { useNotification } from "@/context/notification"
|
import { useNotification } from "@/context/notification"
|
||||||
import { usePermission } from "@/context/permission"
|
import { usePermission } from "@/context/permission"
|
||||||
import { Binary } from "@opencode-ai/util/binary"
|
import { Binary } from "@opencode-ai/util/binary"
|
||||||
@@ -58,6 +59,7 @@ 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,
|
||||||
@@ -94,7 +96,6 @@ 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,
|
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -156,8 +157,6 @@ 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(
|
||||||
() => {
|
() => {
|
||||||
@@ -170,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?.querySelector<HTMLElement>("[data-component='sidebar-rail']") ?? state.nav,
|
el: () => state.nav,
|
||||||
onActivate: (directory) => {
|
onActivate: (directory) => {
|
||||||
globalSync.child(directory)
|
globalSync.child(directory)
|
||||||
setState("hoverProject", directory)
|
setState("hoverProject", directory)
|
||||||
@@ -182,23 +181,9 @@ 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) => {
|
||||||
@@ -209,54 +194,12 @@ 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)
|
||||||
@@ -424,17 +367,6 @@ export default function Layout(props: ParentProps) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
|
||||||
e.details?.type === "question.replied" ||
|
|
||||||
e.details?.type === "question.rejected" ||
|
|
||||||
e.details?.type === "permission.replied"
|
|
||||||
) {
|
|
||||||
const props = e.details.properties as { sessionID: string }
|
|
||||||
const sessionKey = `${e.name}:${props.sessionID}`
|
|
||||||
dismissSessionAlert(sessionKey)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (e.details?.type !== "permission.asked" && e.details?.type !== "question.asked") return
|
if (e.details?.type !== "permission.asked" && e.details?.type !== "question.asked") return
|
||||||
const title =
|
const title =
|
||||||
e.details.type === "permission.asked"
|
e.details.type === "permission.asked"
|
||||||
@@ -669,24 +601,25 @@ export default function Layout(props: ParentProps) {
|
|||||||
const prefetchQueues = new Map<string, PrefetchQueue>()
|
const prefetchQueues = new Map<string, PrefetchQueue>()
|
||||||
|
|
||||||
const PREFETCH_MAX_SESSIONS_PER_DIR = 10
|
const PREFETCH_MAX_SESSIONS_PER_DIR = 10
|
||||||
const prefetchedByDir = new Map<string, Set<string>>()
|
const prefetchedByDir = new Map<string, Map<string, true>>()
|
||||||
|
|
||||||
const lruFor = (directory: string) => {
|
const lruFor = (directory: string) => {
|
||||||
const existing = prefetchedByDir.get(directory)
|
const existing = prefetchedByDir.get(directory)
|
||||||
if (existing) return existing
|
if (existing) return existing
|
||||||
const created = new Set<string>()
|
const created = new Map<string, true>()
|
||||||
prefetchedByDir.set(directory, created)
|
prefetchedByDir.set(directory, created)
|
||||||
return created
|
return created
|
||||||
}
|
}
|
||||||
|
|
||||||
const markPrefetched = (directory: string, sessionID: string) => {
|
const markPrefetched = (directory: string, sessionID: string) => {
|
||||||
const lru = lruFor(directory)
|
const lru = lruFor(directory)
|
||||||
return pickSessionCacheEvictions({
|
if (lru.has(sessionID)) lru.delete(sessionID)
|
||||||
seen: lru,
|
lru.set(sessionID, true)
|
||||||
keep: sessionID,
|
while (lru.size > PREFETCH_MAX_SESSIONS_PER_DIR) {
|
||||||
limit: PREFETCH_MAX_SESSIONS_PER_DIR,
|
const oldest = lru.keys().next().value as string | undefined
|
||||||
preserve: directory === params.dir && params.id ? [params.id] : undefined,
|
if (!oldest) return
|
||||||
})
|
lru.delete(oldest)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
@@ -735,7 +668,6 @@ export default function Layout(props: ParentProps) {
|
|||||||
return retry(() => globalSDK.client.session.messages({ directory, sessionID, limit: prefetchChunk }))
|
return retry(() => globalSDK.client.session.messages({ directory, sessionID, limit: prefetchChunk }))
|
||||||
.then((messages) => {
|
.then((messages) => {
|
||||||
if (prefetchToken.value !== token) return
|
if (prefetchToken.value !== token) return
|
||||||
if (!lruFor(directory).has(sessionID)) return
|
|
||||||
|
|
||||||
const items = (messages.data ?? []).filter((x) => !!x?.info?.id)
|
const items = (messages.data ?? []).filter((x) => !!x?.info?.id)
|
||||||
const next = items.map((x) => x.info).filter((m): m is Message => !!m?.id)
|
const next = items.map((x) => x.info).filter((m): m is Message => !!m?.id)
|
||||||
@@ -799,18 +731,7 @@ export default function Layout(props: ParentProps) {
|
|||||||
const lru = lruFor(directory)
|
const lru = lruFor(directory)
|
||||||
const known = lru.has(session.id)
|
const known = lru.has(session.id)
|
||||||
if (!known && lru.size >= PREFETCH_MAX_SESSIONS_PER_DIR && priority !== "high") return
|
if (!known && lru.size >= PREFETCH_MAX_SESSIONS_PER_DIR && priority !== "high") return
|
||||||
const stale = markPrefetched(directory, session.id)
|
markPrefetched(directory, session.id)
|
||||||
if (stale.length > 0) {
|
|
||||||
const [, setStore] = globalSync.child(directory, { bootstrap: false })
|
|
||||||
for (const id of stale) {
|
|
||||||
globalSync.todo.set(id, undefined)
|
|
||||||
}
|
|
||||||
setStore(
|
|
||||||
produce((draft) => {
|
|
||||||
dropSessionCaches(draft, stale)
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (priority === "high") q.pending.unshift(session.id)
|
if (priority === "high") q.pending.unshift(session.id)
|
||||||
if (priority !== "high") q.pending.push(session.id)
|
if (priority !== "high") q.pending.push(session.id)
|
||||||
@@ -1204,12 +1125,6 @@ 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)
|
||||||
@@ -1900,9 +1815,7 @@ export default function Layout(props: ParentProps) {
|
|||||||
setHoverSession,
|
setHoverSession,
|
||||||
}
|
}
|
||||||
|
|
||||||
const SidebarPanel = (panelProps: { project: LocalProject | undefined; mobile?: boolean; merged?: boolean }) => {
|
const SidebarPanel = (panelProps: { project: LocalProject | undefined; mobile?: boolean }) => {
|
||||||
const merged = createMemo(() => panelProps.mobile || (panelProps.merged ?? layout.sidebar.opened()))
|
|
||||||
const hover = createMemo(() => !panelProps.mobile && panelProps.merged === false && !layout.sidebar.opened())
|
|
||||||
const projectName = createMemo(() => {
|
const projectName = createMemo(() => {
|
||||||
const project = panelProps.project
|
const project = panelProps.project
|
||||||
if (!project) return ""
|
if (!project) return ""
|
||||||
@@ -1928,19 +1841,12 @@ export default function Layout(props: ParentProps) {
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
classList={{
|
classList={{
|
||||||
"flex flex-col min-h-0 min-w-0 box-border rounded-tl-[12px] px-2": true,
|
"flex flex-col min-h-0 bg-background-stronger border border-b-0 border-border-weak-base rounded-tl-[12px]": true,
|
||||||
"border border-b-0 border-border-weak-base": !merged(),
|
|
||||||
"border-l border-t border-border-weaker-base": merged(),
|
|
||||||
"bg-background-base": merged() || hover(),
|
|
||||||
"bg-background-stronger": !merged() && !hover(),
|
|
||||||
"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}>
|
<Show when={panelProps.project} keyed>
|
||||||
{(p) => (
|
{(p) => (
|
||||||
<>
|
<>
|
||||||
<div class="shrink-0 px-2 py-1">
|
<div class="shrink-0 px-2 py-1">
|
||||||
@@ -1949,7 +1855,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
|
||||||
@@ -1958,7 +1864,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",
|
||||||
@@ -1966,7 +1872,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>
|
||||||
@@ -1977,33 +1883,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>
|
<DropdownMenu.Portal mount={!panelProps.mobile ? state.nav : undefined}>
|
||||||
<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}
|
||||||
>
|
>
|
||||||
@@ -2014,8 +1920,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>
|
||||||
@@ -2031,19 +1937,25 @@ 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
|
||||||
|
title={language.t("command.session.new")}
|
||||||
|
keybind={command.keybind("session.new")}
|
||||||
|
placement="top"
|
||||||
|
>
|
||||||
<Button
|
<Button
|
||||||
size="large"
|
size="large"
|
||||||
icon="plus-small"
|
icon="plus-small"
|
||||||
class="w-full"
|
class="w-full"
|
||||||
onClick={() => navigateWithSidebarReset(`/${base64Encode(p().worktree)}/session`)}
|
onClick={() => navigateWithSidebarReset(`/${base64Encode(p.worktree)}/session`)}
|
||||||
>
|
>
|
||||||
{language.t("command.session.new")}
|
{language.t("command.session.new")}
|
||||||
</Button>
|
</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}
|
||||||
/>
|
/>
|
||||||
@@ -2053,9 +1965,15 @@ export default function Layout(props: ParentProps) {
|
|||||||
>
|
>
|
||||||
<>
|
<>
|
||||||
<div class="shrink-0 py-4 px-3">
|
<div class="shrink-0 py-4 px-3">
|
||||||
<Button size="large" icon="plus-small" class="w-full" onClick={() => createWorkspace(p())}>
|
<TooltipKeybind
|
||||||
|
title={language.t("workspace.new")}
|
||||||
|
keybind={command.keybind("workspace.new")}
|
||||||
|
placement="top"
|
||||||
|
>
|
||||||
|
<Button size="large" icon="plus-small" class="w-full" onClick={() => createWorkspace(p)}>
|
||||||
{language.t("workspace.new")}
|
{language.t("workspace.new")}
|
||||||
</Button>
|
</Button>
|
||||||
|
</TooltipKeybind>
|
||||||
</div>
|
</div>
|
||||||
<div class="relative flex-1 min-h-0">
|
<div class="relative flex-1 min-h-0">
|
||||||
<DragDropProvider
|
<DragDropProvider
|
||||||
@@ -2078,7 +1996,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}
|
||||||
/>
|
/>
|
||||||
@@ -2103,31 +2021,25 @@ export default function Layout(props: ParentProps) {
|
|||||||
</Show>
|
</Show>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
class="shrink-0 px-3 py-3"
|
class="shrink-0 px-2 py-3 border-t border-border-weak-base"
|
||||||
classList={{
|
classList={{
|
||||||
hidden: store.gettingStartedDismissed || !(providers.all().length > 0 && providers.paid().length === 0),
|
hidden: !(providers.all().length > 0 && providers.paid().length === 0),
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div class="rounded-xl bg-background-base shadow-xs-border-base" data-component="getting-started">
|
<div class="rounded-md bg-background-base shadow-xs-border-base">
|
||||||
<div class="p-3 flex flex-col gap-6">
|
<div class="p-3 flex flex-col gap-2">
|
||||||
<div class="flex flex-col gap-2">
|
<div class="text-12-medium text-text-strong">{language.t("sidebar.gettingStarted.title")}</div>
|
||||||
<div class="text-14-medium text-text-strong">{language.t("sidebar.gettingStarted.title")}</div>
|
<div class="text-text-base">{language.t("sidebar.gettingStarted.line1")}</div>
|
||||||
<div class="text-14-regular text-text-base" style={{ "line-height": "var(--line-height-normal)" }}>
|
<div class="text-text-base">{language.t("sidebar.gettingStarted.line2")}</div>
|
||||||
{language.t("sidebar.gettingStarted.line1")}
|
|
||||||
</div>
|
</div>
|
||||||
<div class="text-14-regular text-text-base" style={{ "line-height": "var(--line-height-normal)" }}>
|
<Button
|
||||||
{language.t("sidebar.gettingStarted.line2")}
|
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"
|
||||||
</div>
|
size="large"
|
||||||
</div>
|
icon="plus"
|
||||||
<div data-component="getting-started-actions">
|
onClick={connectProvider}
|
||||||
<Button size="large" icon="plus-small" onClick={connectProvider}>
|
>
|
||||||
{language.t("command.provider.connect")}
|
{language.t("command.provider.connect")}
|
||||||
</Button>
|
</Button>
|
||||||
<Button size="large" variant="ghost" onClick={() => setStore("gettingStartedDismissed", true)}>
|
|
||||||
Not yet
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -2137,27 +2049,33 @@ 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 relative overflow-x-hidden">
|
<div class="flex-1 min-h-0 flex">
|
||||||
<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,
|
||||||
"absolute inset-y-0 left-0": true,
|
"relative shrink-0": true,
|
||||||
"z-10": true,
|
|
||||||
}}
|
}}
|
||||||
style={{ width: `${Math.max(layout.sidebar.width(), 244)}px` }}
|
style={{ width: layout.sidebar.opened() ? `${Math.max(layout.sidebar.width(), 244)}px` : "64px" }}
|
||||||
ref={(el) => {
|
ref={(el) => {
|
||||||
setState("nav", el)
|
setState("nav", el)
|
||||||
}}
|
}}
|
||||||
onMouseEnter={() => {
|
onMouseEnter={() => {
|
||||||
disarm()
|
if (navLeave.current === undefined) return
|
||||||
|
clearTimeout(navLeave.current)
|
||||||
|
navLeave.current = undefined
|
||||||
}}
|
}}
|
||||||
onMouseLeave={() => {
|
onMouseLeave={() => {
|
||||||
aim.reset()
|
aim.reset()
|
||||||
if (!sidebarHovering()) return
|
if (!sidebarHovering()) return
|
||||||
|
|
||||||
arm()
|
if (navLeave.current !== undefined) clearTimeout(navLeave.current)
|
||||||
|
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">
|
||||||
@@ -2184,36 +2102,30 @@ 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} merged />}
|
{(project) => <SidebarPanel project={project} />}
|
||||||
</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()}>
|
||||||
<div onPointerDown={() => setSizing(true)}>
|
|
||||||
<ResizeHandle
|
<ResizeHandle
|
||||||
direction="horizontal"
|
direction="horizontal"
|
||||||
size={layout.sidebar.width()}
|
size={layout.sidebar.width()}
|
||||||
min={244}
|
min={244}
|
||||||
max={typeof window === "undefined" ? 1000 : window.innerWidth * 0.3 + 64}
|
max={typeof window === "undefined" ? 1000 : window.innerWidth * 0.3 + 64}
|
||||||
collapseThreshold={244}
|
collapseThreshold={244}
|
||||||
onResize={(w) => {
|
onResize={layout.sidebar.resize}
|
||||||
setSizing(true)
|
|
||||||
if (sizet !== undefined) clearTimeout(sizet)
|
|
||||||
sizet = window.setTimeout(() => setSizing(false), 120)
|
|
||||||
layout.sidebar.resize(w)
|
|
||||||
}}
|
|
||||||
onCollapse={layout.sidebar.close}
|
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={{
|
||||||
@@ -2229,7 +2141,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-full max-w-[400px] overflow-hidden border-r border-border-weaker-base bg-background-base transition-transform duration-200 ease-out": true,
|
"@container fixed top-10 bottom-0 left-0 z-50 w-72 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(),
|
||||||
}}
|
}}
|
||||||
@@ -2262,21 +2174,10 @@ export default function Layout(props: ParentProps) {
|
|||||||
</nav>
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
|
||||||
classList={{
|
|
||||||
"absolute inset-0": true,
|
|
||||||
"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",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<main
|
<main
|
||||||
classList={{
|
classList={{
|
||||||
"size-full overflow-x-hidden flex flex-col items-start contain-strict border-t border-border-weak-base bg-background-base xl:border-l xl:rounded-tl-[12px]": true,
|
"size-full overflow-x-hidden flex flex-col items-start contain-strict border-t border-border-weak-base": true,
|
||||||
|
"xl:border-l xl:rounded-tl-[12px]": !layout.sidebar.opened(),
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Show when={!autoselecting()} fallback={<div class="size-full" />}>
|
<Show when={!autoselecting()} fallback={<div class="size-full" />}>
|
||||||
@@ -2284,45 +2185,6 @@ export default function Layout(props: ParentProps) {
|
|||||||
</Show>
|
</Show>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</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>
|
|
||||||
</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>
|
|
||||||
<Toast.Region />
|
<Toast.Region />
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user