mirror of
https://github.com/esengine/DeepSeek-Reasonix.git
synced 2026-11-07 10:45:59 -10:00
Compare commits
229
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
573d660e1e | ||
|
|
183264fa10 | ||
|
|
7bdaae2025 | ||
|
|
db267ac05c | ||
|
|
9ef965a9d6 | ||
|
|
620aede3d8 | ||
|
|
d3f2a601c3 | ||
|
|
91c13b306c | ||
|
|
001473b7a8 | ||
|
|
1f11055c1f | ||
|
|
415445ffbc | ||
|
|
0e0b79b8c5 | ||
|
|
0d9792e8b0 | ||
|
|
b33fe75d7e | ||
|
|
d1dddb53f3 | ||
|
|
d2a8d0b190 | ||
|
|
efd0a4430a | ||
|
|
b6f7e2e8e8 | ||
|
|
81d1c2e709 | ||
|
|
6271a7e264 | ||
|
|
afb73be003 | ||
|
|
694d4d79c8 | ||
|
|
77990ed9eb | ||
|
|
f4766d1369 | ||
|
|
7358ceb085 | ||
|
|
d65ae94a50 | ||
|
|
54ef19b12a | ||
|
|
ca96e644b4 | ||
|
|
5d7a599ffd | ||
|
|
c0d4b5a582 | ||
|
|
a2deb8cf51 | ||
|
|
41f3c1e6dd | ||
|
|
ead1e05b4e | ||
|
|
c2b55b9824 | ||
|
|
faf26a3c81 | ||
|
|
6b9c38c2a0 | ||
|
|
790fdfca4b | ||
|
|
7a8dfa5499 | ||
|
|
4e76141a41 | ||
|
|
921e7bdb95 | ||
|
|
77a30b1c9e | ||
|
|
6565ce0c85 | ||
|
|
7a70fa4bf8 | ||
|
|
86eeae3d6d | ||
|
|
932736c19e | ||
|
|
7c2890aeff | ||
|
|
a5984a25bb | ||
|
|
b66b2c34ff | ||
|
|
51163562f1 | ||
|
|
6749731ea3 | ||
|
|
1ace88e63d | ||
|
|
db0c478900 | ||
|
|
18043ab882 | ||
|
|
9c435af496 | ||
|
|
7c03770b1a | ||
|
|
78c884b1f2 | ||
|
|
0f48be263d | ||
|
|
0bced53fe4 | ||
|
|
b52bb7d4c3 | ||
|
|
b69954c912 | ||
|
|
754dee6725 | ||
|
|
d03684b080 | ||
|
|
b0578fca34 | ||
|
|
8f9c7806c6 | ||
|
|
23f44c5171 | ||
|
|
28383e9f92 | ||
|
|
0a4d0493ee | ||
|
|
765bdfc872 | ||
|
|
13247c20e3 | ||
|
|
b30ff93cac | ||
|
|
e309ee2200 | ||
|
|
782070a0df | ||
|
|
9a4d3dfaa7 | ||
|
|
852b5e65aa | ||
|
|
9393e594f2 | ||
|
|
871687fee7 | ||
|
|
b12683f48b | ||
|
|
88ca5ae82b | ||
|
|
a6f744d414 | ||
|
|
580339980f | ||
|
|
daec47c72a | ||
|
|
1eb7516562 | ||
|
|
f392dd2fa7 | ||
|
|
c0a7ebe10d | ||
|
|
63da41c794 | ||
|
|
1651a909f0 | ||
|
|
8ea3bec3e2 | ||
|
|
eba925da41 | ||
|
|
d85fe68a17 | ||
|
|
73aca5b612 | ||
|
|
195ee6dcf3 | ||
|
|
a7e6771ae5 | ||
|
|
0c88b9d0b4 | ||
|
|
e8ac15d52a | ||
|
|
e4dd0ad1b4 | ||
|
|
404af94b9e | ||
|
|
8de4c91010 | ||
|
|
b94c4aa944 | ||
|
|
ef1a339ca9 | ||
|
|
6d679edf67 | ||
|
|
29c033cfd5 | ||
|
|
3eda0b98e4 | ||
|
|
b5485b1649 | ||
|
|
5e1ea240fb | ||
|
|
c046c5281f | ||
|
|
7ad19b82a5 | ||
|
|
7b34dd738d | ||
|
|
d77138966a | ||
|
|
4642a4cbee | ||
|
|
2df63d9f9a | ||
|
|
99e466f2cc | ||
|
|
c145598d47 | ||
|
|
5ff8944e9f | ||
|
|
e080b2b972 | ||
|
|
614f9d3831 | ||
|
|
9d53a3aad7 | ||
|
|
8d306bd0e6 | ||
|
|
a9cae776f5 | ||
|
|
13c1820de5 | ||
|
|
5d33e047f4 | ||
|
|
ce860db141 | ||
|
|
688658585b | ||
|
|
eb6ba928be | ||
|
|
ac7836f048 | ||
|
|
ac7d7be97d | ||
|
|
a2e20b8a2f | ||
|
|
0be8aaf783 | ||
|
|
1c12f7c174 | ||
|
|
07b2453403 | ||
|
|
b619b3dbfe | ||
|
|
4cdd4bde93 | ||
|
|
8ac91c3399 | ||
|
|
9fc7f5022c | ||
|
|
7d6687d03b | ||
|
|
af710d8784 | ||
|
|
723b2b8ef2 | ||
|
|
3afb4dea49 | ||
|
|
79b0547997 | ||
|
|
e6d0d4aebe | ||
|
|
7b7e09fdcd | ||
|
|
631bd6b111 | ||
|
|
7ac760e44d | ||
|
|
9dcfdf2e7c | ||
|
|
f86eb89b74 | ||
|
|
6292d66e90 | ||
|
|
3bb9dce90a | ||
|
|
5d3a70950c | ||
|
|
a27298c462 | ||
|
|
f84126a76f | ||
|
|
e8c7feeeef | ||
|
|
f836dc6a3a | ||
|
|
69cb060066 | ||
|
|
6328dfa0b5 | ||
|
|
9bb0aa28fd | ||
|
|
3a754cebdb | ||
|
|
6694a1b81a | ||
|
|
003000be21 | ||
|
|
92a1677fda | ||
|
|
e4f1f80a76 | ||
|
|
9ced3d1577 | ||
|
|
5caa03eca1 | ||
|
|
309ea9aa0f | ||
|
|
15155c3a67 | ||
|
|
7f7a063139 | ||
|
|
8c608c8bba | ||
|
|
e9a863c36d | ||
|
|
0d30c68641 | ||
|
|
dc8f746f5e | ||
|
|
f6d948c60b | ||
|
|
b2f36909eb | ||
|
|
b5f2f0c025 | ||
|
|
c9daeddf3e | ||
|
|
8dd9294495 | ||
|
|
f2dc940a29 | ||
|
|
9d930f67f4 | ||
|
|
c0766fbe59 | ||
|
|
73e2fb91af | ||
|
|
4df81041b1 | ||
|
|
4bcda5691d | ||
|
|
383e6369ac | ||
|
|
6bb625fcf7 | ||
|
|
8d963086ea | ||
|
|
85df91d4ef | ||
|
|
af2dbb252d | ||
|
|
8de4b4851c | ||
|
|
fb3d9d7433 | ||
|
|
66029c7dec | ||
|
|
43b210573b | ||
|
|
422b47b3a0 | ||
|
|
d80c290d89 | ||
|
|
594cbf664a | ||
|
|
7bb8031e85 | ||
|
|
188df0245b | ||
|
|
c2df8e3ed6 | ||
|
|
104d01ba07 | ||
|
|
e977923203 | ||
|
|
5450ed38e9 | ||
|
|
3fcce85ca7 | ||
|
|
d6cc51fe76 | ||
|
|
abca1a62c5 | ||
|
|
b70b86e7a7 | ||
|
|
754166fc32 | ||
|
|
0aeb204660 | ||
|
|
1fa7030ced | ||
|
|
43661cdec1 | ||
|
|
e07bc5faa8 | ||
|
|
a968fd8881 | ||
|
|
420a9231c5 | ||
|
|
11b84e5891 | ||
|
|
0495d1f294 | ||
|
|
0040dec2a0 | ||
|
|
4b94a02c27 | ||
|
|
28a9010d0e | ||
|
|
71fb27901b | ||
|
|
223c55b95f | ||
|
|
d84543d259 | ||
|
|
0247623274 | ||
|
|
8fa67e544e | ||
|
|
fe850bc160 | ||
|
|
f527fa023d | ||
|
|
33199377da | ||
|
|
1f1c469b16 | ||
|
|
7fe57fc25a | ||
|
|
e5a801c603 | ||
|
|
824f95613a | ||
|
|
03d5346869 | ||
|
|
a2a429e3aa | ||
|
|
e1f30114d2 | ||
|
|
23ade961ba |
No files matched your search
@@ -6,3 +6,9 @@ contact_links:
|
||||
- name: Questions & discussions
|
||||
url: https://github.com/esengine/DeepSeek-Reasonix/discussions
|
||||
about: For usage questions, ideas, and general discussion — please use Discussions instead of opening an issue.
|
||||
- name: Discord
|
||||
url: https://discord.gg/XF78rEME2D
|
||||
about: Bilingual community chat for setup help, workflow showcases, and feature ideas.
|
||||
- name: QQ group (DeepSeek-Reasonix官方群, 1093562660)
|
||||
url: https://qm.qq.com/q/i59b0z2R8s
|
||||
about: Chinese-language community chat for setup help and everyday questions.
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 138 KiB |
@@ -80,7 +80,7 @@ jobs:
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
|
||||
with:
|
||||
node-version: "22"
|
||||
- run: node --test scripts/release-credits.test.mjs scripts/markdown-refs.test.mjs scripts/studio-release-notes.test.mjs scripts/publish-cli-pointer.test.mjs scripts/feedback-sync.test.mjs npm/publish.test.mjs
|
||||
- run: node --test scripts/release-credits.test.mjs scripts/update-contributors.test.mjs scripts/markdown-refs.test.mjs scripts/studio-release-notes.test.mjs scripts/publish-cli-pointer.test.mjs scripts/feedback-sync.test.mjs npm/publish.test.mjs
|
||||
|
||||
# The ruleset requires the per-OS check names (test (ubuntu-latest) etc.).
|
||||
# A matrix job skipped at job level reports no per-leg checks at all, so
|
||||
@@ -175,12 +175,12 @@ jobs:
|
||||
# budget above normal 7-9 minute runner variance; each package test binary
|
||||
# remains independently bounded by Go's per-package timeout below.
|
||||
#
|
||||
# That timeout is 8m, not 3m: across four consecutive runs of the same
|
||||
# That timeout is 12m, not 3m: across four consecutive runs of the same
|
||||
# code, internal/assembly/boot measured 133s, 146s, 162s and then hit 180s, and
|
||||
# internal/runtime/agent 82s, 92s, 144s and 180s — a 2.2x spread from runner
|
||||
# variance alone, with four heavy packages sharing four vCPUs. At 3m the
|
||||
# step failed on slow runners without a code change; a hang still trips
|
||||
# the 15 minute job budget above.
|
||||
# variance alone, with four heavy packages sharing four vCPUs. The boot package
|
||||
# has since grown to 422s and then ran past 480s on two consecutive runs of the
|
||||
# same code, so 8m no longer leaves room. A hang still trips the job budget.
|
||||
#
|
||||
# Every root-module `go test` that runs on Windows passes -count=1. With
|
||||
# the result cache on, go test records each file and environment access
|
||||
@@ -191,11 +191,11 @@ jobs:
|
||||
# internal/base/testenv holds every such step to it.
|
||||
- name: test (Windows smoke)
|
||||
if: env.RUN_STEPS == 'true' && runner.os == 'Windows' && github.event_name == 'pull_request'
|
||||
timeout-minutes: 15
|
||||
timeout-minutes: 20
|
||||
env:
|
||||
REASONIX_RELEASE_CACHE_GUARD: "1"
|
||||
WINDOWS_SANDBOX_WAIT_MS: "20000"
|
||||
run: go test -count=1 -p 4 -timeout=8m ./internal/runtime/agent/... ./internal/platform/appidentity/... ./internal/assembly/boot/... ./internal/state/checkpoint/... ./internal/frontend/cli/... ./internal/session/control/... ./internal/base/filelock/... ./internal/base/fileutil/... ./internal/ext/hook/... ./internal/state/instruction/... ./internal/ext/mcplaunch/... ./internal/platform/notify/... ./internal/base/proc/... ./internal/platform/remote/... ./internal/platform/repair/... ./internal/safety/sandbox/... ./internal/base/sysproxy/... ./internal/state/workspacelease/... ./cmd/...
|
||||
run: go test -count=1 -p 4 -timeout=12m ./internal/runtime/agent/... ./internal/platform/appidentity/... ./internal/assembly/boot/... ./internal/state/checkpoint/... ./internal/frontend/cli/... ./internal/session/control/... ./internal/base/filelock/... ./internal/base/fileutil/... ./internal/ext/hook/... ./internal/state/instruction/... ./internal/ext/mcplaunch/... ./internal/platform/notify/... ./internal/base/proc/... ./internal/platform/remote/... ./internal/platform/repair/... ./internal/safety/sandbox/... ./internal/base/sysproxy/... ./internal/state/workspacelease/... ./cmd/...
|
||||
|
||||
# The general Windows PR smoke list intentionally omits internal/contract/tool.
|
||||
# Keep the session-temp portability contract covered without widening the
|
||||
@@ -214,7 +214,7 @@ jobs:
|
||||
timeout-minutes: 5
|
||||
run: go test -count=1 -timeout=3m -run '^TestBashPowerShellExecuteDetailedContract$|^TestBashPowerShell51PreflightRejectsAndAndDetailed$|^TestBashPowerShellOutputIsUTF8$|^TestBashPowerShellSurfacesNonZeroExit$|^TestBashPowerShellRejectsChaining$' ./internal/tools/builtin
|
||||
|
||||
# Full push suite still uses the same 8m package budget as Windows smoke:
|
||||
# Full push suite still uses the same 12m package budget as Windows smoke:
|
||||
# agent/boot/control already hit 180s package timeouts under runner load,
|
||||
# so 3m fails on slow machines without a code regression. It runs with
|
||||
# -count=1 for the reason given at the smoke step. A hang still dies here
|
||||
@@ -230,7 +230,7 @@ jobs:
|
||||
# Bound sandbox helper children in Windows tests so a failed OS-level
|
||||
# launch cannot pin the Actions step after Go's package timeout fires.
|
||||
WINDOWS_SANDBOX_WAIT_MS: "20000"
|
||||
run: go test -count=1 -p 4 -timeout=8m ./...
|
||||
run: go test -count=1 -p 4 -timeout=12m ./...
|
||||
|
||||
race:
|
||||
needs: changes
|
||||
|
||||
@@ -18,6 +18,8 @@
|
||||
<a href="https://esengine.github.io/DeepSeek-Reasonix/">Website</a>
|
||||
·
|
||||
<strong><a href="https://discord.gg/XF78rEME2D">Discord</a></strong>
|
||||
·
|
||||
<strong><a href="https://qm.qq.com/q/i59b0z2R8s">QQ</a></strong>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
@@ -31,6 +33,7 @@
|
||||
<a href="https://github.com/esengine/DeepSeek-Reasonix/graphs/contributors"><img src="https://img.shields.io/github/contributors/esengine/DeepSeek-Reasonix.svg?style=flat-square&color=bc8cff&labelColor=161b22&logo=github&logoColor=white" alt="contributors"/></a>
|
||||
<a href="https://github.com/esengine/DeepSeek-Reasonix/discussions"><img src="https://img.shields.io/github/discussions/esengine/DeepSeek-Reasonix.svg?style=flat-square&color=58a6ff&labelColor=161b22&logo=github&logoColor=white" alt="Discussions"/></a>
|
||||
<a href="https://discord.gg/XF78rEME2D"><img src="https://img.shields.io/badge/discord-join-5865F2.svg?style=flat-square&labelColor=161b22&logo=discord&logoColor=white" alt="Discord"/></a>
|
||||
<a href="https://qm.qq.com/q/i59b0z2R8s"><img src="https://img.shields.io/badge/QQ%20group-1093562660-12B7F5.svg?style=flat-square&labelColor=161b22&logo=tencentqq&logoColor=white" alt="QQ group 1093562660"/></a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
@@ -41,12 +44,16 @@
|
||||
<br/>
|
||||
|
||||
<p align="center"><strong>Open source · MIT · a single Go binary</strong></p>
|
||||
<h3 align="center">A coding agent you can leave running.</h3>
|
||||
<h3 align="center">A reliable coding agent for complex software engineering tasks.</h3>
|
||||
<p align="center">One local engine, four ways in — terminal, desktop app, browser, or your editor over ACP. Plan mode, permissions, a workspace sandbox and per-turn checkpoints keep a long autonomous run something you can still read and undo.</p>
|
||||
<p align="center">Maintained by <strong>Huahui Yu</strong>.</p>
|
||||
|
||||
> [!IMPORTANT]
|
||||
> **Community · 加入社区** — bilingual Discord for setup help (`#help` / `#求助`), workflow showcases, and feature ideas. → **<https://discord.gg/XF78rEME2D>**
|
||||
>
|
||||
> QQ group **DeepSeek-Reasonix官方群** (`1093562660`) → **<https://qm.qq.com/q/i59b0z2R8s>** ([QR code](./docs/assets/qq-group.svg))
|
||||
|
||||
> Douyin: **做游戏的小鱼** (Douyin ID `22703872788`) · [QR code](./docs/assets/douyin.png)
|
||||
|
||||
<br/>
|
||||
|
||||
@@ -265,8 +272,9 @@ coffee, not a contract — donations don't buy feature priority or change how
|
||||
issues get triaged.
|
||||
|
||||
- **International** — PayPal: [paypal.me/yuhuahui](https://paypal.me/yuhuahui)
|
||||
- **国内** — 微信支付(扫码)
|
||||
- **国内** — 微信支付 / 支付宝(扫码)
|
||||
|
||||
<p align="center">
|
||||
<img src=".github/sponsor/wechat-pay.jpg" alt="WeChat Pay QR code" width="180"/>
|
||||
<img src=".github/sponsor/alipay.jpg" alt="Alipay QR code" width="180"/>
|
||||
</p>
|
||||
+10
-2
@@ -18,6 +18,8 @@
|
||||
<a href="https://esengine.github.io/DeepSeek-Reasonix/">官方网站</a>
|
||||
·
|
||||
<strong><a href="https://discord.gg/XF78rEME2D">Discord</a></strong>
|
||||
·
|
||||
<strong><a href="https://qm.qq.com/q/i59b0z2R8s">QQ</a></strong>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
@@ -31,6 +33,7 @@
|
||||
<a href="https://github.com/esengine/DeepSeek-Reasonix/graphs/contributors"><img src="https://img.shields.io/github/contributors/esengine/DeepSeek-Reasonix.svg?style=flat-square&color=bc8cff&labelColor=161b22&logo=github&logoColor=white" alt="contributors"/></a>
|
||||
<a href="https://github.com/esengine/DeepSeek-Reasonix/discussions"><img src="https://img.shields.io/github/discussions/esengine/DeepSeek-Reasonix.svg?style=flat-square&color=58a6ff&labelColor=161b22&logo=github&logoColor=white" alt="Discussions"/></a>
|
||||
<a href="https://discord.gg/XF78rEME2D"><img src="https://img.shields.io/badge/discord-join-5865F2.svg?style=flat-square&labelColor=161b22&logo=discord&logoColor=white" alt="Discord"/></a>
|
||||
<a href="https://qm.qq.com/q/i59b0z2R8s"><img src="https://img.shields.io/badge/QQ%20group-1093562660-12B7F5.svg?style=flat-square&labelColor=161b22&logo=tencentqq&logoColor=white" alt="QQ group 1093562660"/></a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
@@ -41,12 +44,16 @@
|
||||
<br/>
|
||||
|
||||
<p align="center"><strong>开源 · MIT · 单个 Go 二进制</strong></p>
|
||||
<h3 align="center">可以一直开着跑的编码 Agent。</h3>
|
||||
<h3 align="center">面向复杂软件工程任务的可靠编码 Agent。</h3>
|
||||
<p align="center">一套本地引擎,四个入口——终端、桌面端、浏览器,或通过 ACP 接入你的编辑器。计划模式、权限、工作区沙箱与逐轮 checkpoint,让长时间自治运行始终可读、可撤销。</p>
|
||||
<p align="center">维护者:<strong>Huahui Yu</strong>。</p>
|
||||
|
||||
> [!IMPORTANT]
|
||||
> **加入社区 · Community** — 双语 Discord,提供安装答疑(`#help` / `#求助`)、工作流展示与功能想法。→ **<https://discord.gg/XF78rEME2D>**
|
||||
>
|
||||
> QQ 群 **DeepSeek-Reasonix官方群**(群号 `1093562660`)→ **<https://qm.qq.com/q/i59b0z2R8s>**([二维码](./docs/assets/qq-group.svg))
|
||||
|
||||
> 抖音:**做游戏的小鱼**(抖音号 `22703872788`)· [二维码](./docs/assets/douyin.png)
|
||||
|
||||
## 版本
|
||||
|
||||
@@ -245,9 +252,10 @@ CLI 进阶用法和详细配置见 **[CLI 命令参考](./docs/CLI.zh-CN.md)**
|
||||
如果 Reasonix 帮你省了时间或 token,欢迎请杯咖啡。捐助不会换来 feature
|
||||
优先级,也不会影响 issue 的处理顺序——就是「谢谢」。
|
||||
|
||||
- **国内** — 微信支付(扫下方二维码)
|
||||
- **国内** — 微信支付 / 支付宝(扫下方二维码)
|
||||
- **海外** — PayPal: [paypal.me/yuhuahui](https://paypal.me/yuhuahui)
|
||||
|
||||
<p align="center">
|
||||
<img src=".github/sponsor/wechat-pay.jpg" alt="微信支付收款码" width="180"/>
|
||||
<img src=".github/sponsor/alipay.jpg" alt="支付宝收款码" width="180"/>
|
||||
</p>
|
||||
@@ -15,7 +15,7 @@ import (
|
||||
// the app's own folder in the sidebar again after every removal.
|
||||
func launchWorkspace(appExe string) string {
|
||||
home, _ := os.UserHomeDir()
|
||||
return pickLaunchWorkspace(boot.ResolveWorkspaceRoot(""), appExe, serve.Workspaces(), home)
|
||||
return pickLaunchWorkspace(boot.ResolveWorkspaceRoot(""), appExe, serve.LaunchWorkspaces(), home)
|
||||
}
|
||||
|
||||
func pickLaunchWorkspace(cwd, appExe string, remembered []string, home string) string {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
@@ -61,3 +62,31 @@ func TestPickLaunchWorkspaceFallbackOrder(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestLaunchWorkspaceKeepsLaunchProjectAfterSidebarReorder(t *testing.T) {
|
||||
base := t.TempDir()
|
||||
home := filepath.Join(base, "state")
|
||||
app := filepath.Join(base, "app")
|
||||
a, b := filepath.Join(base, "a"), filepath.Join(base, "b")
|
||||
for _, dir := range []string{home, app, a, b} {
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
t.Setenv("REASONIX_HOME", home)
|
||||
t.Chdir(app)
|
||||
data, _ := json.Marshal(map[string]any{"paths": []string{b, a}, "launch": a})
|
||||
if err := os.WriteFile(filepath.Join(home, "serve-workspaces.json"), data, 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
exe := filepath.Join(app, "Reasonix Studio")
|
||||
if got := launchWorkspace(exe); got != a {
|
||||
t.Fatalf("launchWorkspace = %q, want the pre-reorder launch project %q", got, a)
|
||||
}
|
||||
if err := os.Remove(a); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := launchWorkspace(exe); got != b {
|
||||
t.Fatalf("missing launch project = %q, want next remembered folder %q", got, b)
|
||||
}
|
||||
}
|
||||
@@ -321,6 +321,10 @@ func assemble(ctx context.Context, logs, handshakeTo io.Writer, shell shellIdent
|
||||
Stderr: logs,
|
||||
StatsSource: surface.Desktop,
|
||||
FeedbackSurface: feedback.SurfaceStudio,
|
||||
// A stale default_model must not keep the window from opening to fix it.
|
||||
OpenOnFallbackModel: true,
|
||||
|
||||
CleanupPendingReconciler: serve.BackgroundCleanupReconciler,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
||||
@@ -71,3 +71,40 @@ func TestTheHostPointsTheKernelsCatalogueAtTheConfiguredLanguage(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Sweeping a session directory costs a handful of file operations per
|
||||
// transcript, and the window cannot open until the handshake. A build that
|
||||
// sweeps inline makes the first launch after the cache went cold wait on
|
||||
// history it never displays.
|
||||
func TestTheHostsFirstBuildSweepsSessionsInTheBackground(t *testing.T) {
|
||||
file, err := parser.ParseFile(token.NewFileSet(), "main.go", nil, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("parse main.go: %v", err)
|
||||
}
|
||||
found := false
|
||||
ast.Inspect(file, func(n ast.Node) bool {
|
||||
lit, ok := n.(*ast.CompositeLit)
|
||||
if !ok {
|
||||
return true
|
||||
}
|
||||
sel, ok := lit.Type.(*ast.SelectorExpr)
|
||||
if !ok || sel.Sel.Name != "Options" {
|
||||
return true
|
||||
}
|
||||
for _, elt := range lit.Elts {
|
||||
kv, ok := elt.(*ast.KeyValueExpr)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if key, ok := kv.Key.(*ast.Ident); ok && key.Name == "CleanupPendingReconciler" {
|
||||
if v, ok := kv.Value.(*ast.SelectorExpr); ok && v.Sel.Name == "BackgroundCleanupReconciler" {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
}
|
||||
return true
|
||||
})
|
||||
if !found {
|
||||
t.Fatal("the host's boot.Options does not set CleanupPendingReconciler to serve.BackgroundCleanupReconciler")
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 1.3 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 1.8 KiB |
@@ -7,9 +7,10 @@ const { chromium } = require("playwright");
|
||||
|
||||
const ASSETS = path.join(__dirname, "..", "assets");
|
||||
const ICO_SIZES = [16, 20, 24, 32, 40, 48, 64, 256];
|
||||
// The tray is drawn at 16 logical pixels; nativeImage picks the @Nx file that
|
||||
// matches the display, so a scaled screen never enlarges the 16px one.
|
||||
const TRAY_SCALES = [[1, ""], [1.25, "@1.25x"], [1.5, "@1.5x"], [2, "@2x"], [3, "@3x"]];
|
||||
// The tray is drawn at 16 logical pixels. Windows asks for exactly 16*scale
|
||||
// pixels and resizes whatever it is handed, so every Windows scale has its own
|
||||
// file (src/trayimage.js picks it).
|
||||
const TRAY_SCALES = [[1, ""], [1.25, "@1.25x"], [1.5, "@1.5x"], [1.75, "@1.75x"], [2, "@2x"], [2.5, "@2.5x"], [3, "@3x"]];
|
||||
// The app icon's margin is the platform's to keep; a tray slot has none to spare.
|
||||
const TRAY_VIEWBOX = 'viewBox="103 103 818 818"';
|
||||
// PNG payloads for every type modern macOS reads; ic11–ic14 are the @2x slots.
|
||||
|
||||
@@ -1,21 +1,131 @@
|
||||
"use strict";
|
||||
|
||||
// The context menu's shape, kept apart from the platform so it can be checked
|
||||
// without one. editFlags is the page's own account of what is possible at the
|
||||
// click: roles do the clipboard work, this only decides what to offer.
|
||||
function contextTemplate(params) {
|
||||
if (!params.isEditable && !params.selectionText) return [];
|
||||
const can = params.editFlags ?? {};
|
||||
// Role labels come from the platform's own catalogue, which follows the
|
||||
// system locale rather than the language the interface was set to, so they are
|
||||
// stated here for the two languages the interface draws.
|
||||
const WORDS = {
|
||||
zh: {
|
||||
edit: "编辑",
|
||||
undo: "撤销",
|
||||
redo: "重做",
|
||||
cut: "剪切",
|
||||
copy: "复制",
|
||||
paste: "粘贴",
|
||||
selectAll: "全选",
|
||||
pasteAndMatchStyle: "粘贴并匹配样式",
|
||||
delete: "删除",
|
||||
substitutions: "替换",
|
||||
showSubstitutions: "显示替换",
|
||||
smartQuotes: "智能引号",
|
||||
smartDashes: "智能破折号",
|
||||
textReplacement: "文本替换",
|
||||
speech: "语音",
|
||||
startSpeaking: "开始朗读",
|
||||
stopSpeaking: "停止朗读",
|
||||
},
|
||||
en: {
|
||||
edit: "Edit",
|
||||
undo: "Undo",
|
||||
redo: "Redo",
|
||||
cut: "Cut",
|
||||
copy: "Copy",
|
||||
paste: "Paste",
|
||||
selectAll: "Select All",
|
||||
pasteAndMatchStyle: "Paste and Match Style",
|
||||
delete: "Delete",
|
||||
substitutions: "Substitutions",
|
||||
showSubstitutions: "Show Substitutions",
|
||||
smartQuotes: "Smart Quotes",
|
||||
smartDashes: "Smart Dashes",
|
||||
textReplacement: "Text Replacement",
|
||||
speech: "Speech",
|
||||
startSpeaking: "Start Speaking",
|
||||
stopSpeaking: "Stop Speaking",
|
||||
},
|
||||
};
|
||||
|
||||
function wordsFor(lang) {
|
||||
return WORDS[lang] ?? WORDS.en;
|
||||
}
|
||||
|
||||
// The edit commands as roles carrying explicit labels: roles do the clipboard
|
||||
// work, the labels follow the interface language.
|
||||
function editItems(lang, can) {
|
||||
const w = wordsFor(lang);
|
||||
const on = (flag) => (can ? { enabled: !!can[flag] } : {});
|
||||
return [
|
||||
{ role: "undo", enabled: !!can.canUndo },
|
||||
{ role: "redo", enabled: !!can.canRedo },
|
||||
{ role: "undo", label: w.undo, ...on("canUndo") },
|
||||
{ role: "redo", label: w.redo, ...on("canRedo") },
|
||||
{ type: "separator" },
|
||||
{ role: "cut", enabled: !!can.canCut },
|
||||
{ role: "copy", enabled: !!can.canCopy },
|
||||
{ role: "paste", enabled: !!can.canPaste },
|
||||
{ role: "cut", label: w.cut, ...on("canCut") },
|
||||
{ role: "copy", label: w.copy, ...on("canCopy") },
|
||||
{ role: "paste", label: w.paste, ...on("canPaste") },
|
||||
{ type: "separator" },
|
||||
{ role: "selectAll", enabled: !!can.canSelectAll },
|
||||
{ role: "selectAll", label: w.selectAll, ...on("canSelectAll") },
|
||||
];
|
||||
}
|
||||
|
||||
module.exports = { contextTemplate };
|
||||
// The context menu's shape, kept apart from the platform so it can be checked
|
||||
// without one. editFlags is the page's own account of what is possible at the
|
||||
// click: this only decides what to offer.
|
||||
function contextTemplate(params, lang) {
|
||||
if (!params.isEditable && !params.selectionText) return [];
|
||||
return editItems(lang, params.editFlags ?? {});
|
||||
}
|
||||
|
||||
// Everything the platform's own Edit menu carries, with the same roles, so
|
||||
// only the words change.
|
||||
function editMenuTemplate(lang) {
|
||||
const w = wordsFor(lang);
|
||||
const items = editItems(lang);
|
||||
const at = items.findIndex((i) => i.role === "selectAll");
|
||||
items.splice(at - 1, 1);
|
||||
items.splice(at - 1, 0,
|
||||
{ role: "pasteAndMatchStyle", label: w.pasteAndMatchStyle },
|
||||
{ role: "delete", label: w.delete });
|
||||
items.push(
|
||||
{ type: "separator" },
|
||||
{
|
||||
label: w.substitutions,
|
||||
submenu: [
|
||||
{ role: "showSubstitutions", label: w.showSubstitutions },
|
||||
{ type: "separator" },
|
||||
{ role: "toggleSmartQuotes", label: w.smartQuotes },
|
||||
{ role: "toggleSmartDashes", label: w.smartDashes },
|
||||
{ role: "toggleTextReplacement", label: w.textReplacement },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: w.speech,
|
||||
submenu: [
|
||||
{ role: "startSpeaking", label: w.startSpeaking },
|
||||
{ role: "stopSpeaking", label: w.stopSpeaking },
|
||||
],
|
||||
},
|
||||
);
|
||||
return { label: w.edit, submenu: items };
|
||||
}
|
||||
|
||||
function applicationMenuTemplate(lang) {
|
||||
return [{ role: "appMenu" }, editMenuTemplate(lang), { role: "windowMenu" }];
|
||||
}
|
||||
|
||||
// Only the Edit menu follows the interface language; the application and window
|
||||
// menus are roles and follow the OS. Menu is injected so this runs without Electron.
|
||||
function menuInstaller(Menu, platform = process.platform) {
|
||||
let installedLang = null;
|
||||
return function installApplicationMenu(languageOf = () => "en") {
|
||||
if (platform !== "darwin") {
|
||||
Menu.setApplicationMenu(null);
|
||||
return null;
|
||||
}
|
||||
const lang = languageOf();
|
||||
if (lang === installedLang) return Menu.getApplicationMenu();
|
||||
installedLang = lang;
|
||||
const menu = Menu.buildFromTemplate(applicationMenuTemplate(lang));
|
||||
Menu.setApplicationMenu(menu);
|
||||
return menu;
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = { contextTemplate, editMenuTemplate, applicationMenuTemplate, menuInstaller };
|
||||
@@ -13,10 +13,11 @@ const { StudioHost } = require("./hostclient");
|
||||
const { installTray } = require("./tray");
|
||||
const { instanceID, profileFor } = require("./instance");
|
||||
const { installApplicationMenu, installContextMenu } = require("./menu");
|
||||
const { uiLanguage } = require("./uilang");
|
||||
const { installFullScreenKey } = require("./fullscreen");
|
||||
const { installReload } = require("./reload");
|
||||
const { externalTarget } = require("./links");
|
||||
const { reveal } = require("./reveal");
|
||||
const { reveal, revealWorkspace } = require("./reveal");
|
||||
const { appIcon } = require("./appicon");
|
||||
const layout = require("./layout");
|
||||
const { offerCleanup } = require("./legacy");
|
||||
@@ -24,7 +25,7 @@ const { stripPackageGrants, unpaintedWindowCause } = require("./packagegrants");
|
||||
const { BrowserProtocol } = require("./browserprotocol");
|
||||
const { BrowserViews } = require("./browserviews");
|
||||
const { startBrowserRelay } = require("./browserrelay");
|
||||
const { prefsFile, registerPrefs } = require("./prefs");
|
||||
const { loadPrefs, prefsFile, registerPrefs } = require("./prefs");
|
||||
const { openLogs, redactArgv, failStartup } = require("./shelllog");
|
||||
|
||||
// A page in a minimized or fully covered window counts as hidden, and a hidden
|
||||
@@ -166,7 +167,7 @@ async function boot() {
|
||||
const cause = unpaintedWindowCause(grants, app.getLocale());
|
||||
if (cause) dialog.showErrorBox(cause.title, cause.detail);
|
||||
});
|
||||
installContextMenu(win.webContents, win);
|
||||
installContextMenu(win.webContents, win, uiLang);
|
||||
installFullScreenKey(win.webContents, win);
|
||||
reload = installReload(win.webContents, win);
|
||||
win.once("ready-to-show", () => win.show());
|
||||
@@ -314,7 +315,10 @@ function fromWindow(event) {
|
||||
return win && !win.isDestroyed() && event.sender === win.webContents ? win : null;
|
||||
}
|
||||
|
||||
registerPrefs(ipcMain, () => prefsFile(app.getPath("userData")), fromWindow);
|
||||
// The page reads navigator.languages[0], which is the first preferred system
|
||||
// language; app.getLocale() is the locale Chromium resolved, which can differ.
|
||||
const uiLang = () => uiLanguage(loadPrefs(prefsFile(app.getPath("userData"))), app.getPreferredSystemLanguages()[0] ?? app.getLocale());
|
||||
registerPrefs(ipcMain, () => prefsFile(app.getPath("userData")), fromWindow, () => installApplicationMenu(uiLang));
|
||||
|
||||
ipcMain.handle("window:minimise", (event) => {
|
||||
fromWindow(event)?.minimize();
|
||||
@@ -351,8 +355,11 @@ ipcMain.handle("browser:login-answer", (event, id, username, password) => {
|
||||
ipcMain.handle("shell:open-external", (event, raw) => {
|
||||
if (!fromWindow(event)) return;
|
||||
const target = externalTarget(raw);
|
||||
if (target) void shell.openExternal(target);
|
||||
if (target) return shell.openExternal(target);
|
||||
});
|
||||
ipcMain.handle("shell:reveal-workspace", (event, root) =>
|
||||
fromWindow(event) && client ? revealWorkspace(client, shell, String(root)) : { code: "", error: "no window" },
|
||||
);
|
||||
ipcMain.handle("shell:reveal", (event, base, rel) =>
|
||||
fromWindow(event) && client ? reveal(client, shell, String(base), String(rel)) : { code: "", error: "no window" },
|
||||
);
|
||||
@@ -428,7 +435,7 @@ if (!primary) {
|
||||
|
||||
app.whenReady().then(() => {
|
||||
if (!primary) return;
|
||||
installApplicationMenu();
|
||||
installApplicationMenu(uiLang);
|
||||
boot().catch((err) => {
|
||||
console.error("reasonix-studio:", err.message);
|
||||
if (quitting) {
|
||||
|
||||
@@ -1,31 +1,19 @@
|
||||
"use strict";
|
||||
const { Menu } = require("electron");
|
||||
const { contextTemplate } = require("./editmenu");
|
||||
const { contextTemplate, menuInstaller } = require("./editmenu");
|
||||
|
||||
// Without an application menu a WebContents has no copy, paste or undo at all:
|
||||
// macOS routes those shortcuts through it, and a window with none reads as a
|
||||
// broken text editor. Elsewhere the bar would render inside the window, and
|
||||
// those platforms bind the shortcuts themselves.
|
||||
function installApplicationMenu() {
|
||||
if (process.platform !== "darwin") {
|
||||
Menu.setApplicationMenu(null);
|
||||
return null;
|
||||
}
|
||||
const menu = Menu.buildFromTemplate([
|
||||
{ role: "appMenu" },
|
||||
{ role: "editMenu" },
|
||||
{ role: "windowMenu" },
|
||||
]);
|
||||
Menu.setApplicationMenu(menu);
|
||||
return menu;
|
||||
}
|
||||
const installApplicationMenu = menuInstaller(Menu);
|
||||
|
||||
// A production build ships without one, so a right-click in a text field offered
|
||||
// nothing at all. Roles rather than handlers: the clipboard work belongs to the
|
||||
// platform, and editFlags is the page's own account of what is possible here.
|
||||
function installContextMenu(contents, window) {
|
||||
function installContextMenu(contents, window, languageOf = () => "en") {
|
||||
contents.on("context-menu", (_event, params) => {
|
||||
const template = contextTemplate(params);
|
||||
const template = contextTemplate(params, languageOf());
|
||||
if (!template.length) return;
|
||||
Menu.buildFromTemplate(template).popup({ window });
|
||||
});
|
||||
|
||||
@@ -44,7 +44,7 @@ function savePrefs(file, prefs) {
|
||||
// in the same process and must not reach this file.
|
||||
// fileOf is asked on each use: the profile directory is settled during boot,
|
||||
// after these handlers exist.
|
||||
function registerPrefs(ipcMain, fileOf, fromWindow) {
|
||||
function registerPrefs(ipcMain, fileOf, fromWindow, onSave = () => {}) {
|
||||
ipcMain.on("prefs:load", (event) => {
|
||||
event.returnValue = fromWindow(event) ? loadPrefs(fileOf()) : {};
|
||||
});
|
||||
@@ -52,6 +52,7 @@ function registerPrefs(ipcMain, fileOf, fromWindow) {
|
||||
if (fromWindow(event)) {
|
||||
try {
|
||||
savePrefs(fileOf(), prefs);
|
||||
onSave();
|
||||
} catch {
|
||||
// A preference that did not save is one relaunch of lost state, not a
|
||||
// reason to fail the window.
|
||||
|
||||
@@ -28,6 +28,8 @@ contextBridge.exposeInMainWorld("reasonixHost", {
|
||||
openExternal: (url) => ipcRenderer.invoke("shell:open-external", String(url)),
|
||||
// A pane and a path inside its workspace; main asks the kernel where that is.
|
||||
revealPath: (base, rel) => ipcRenderer.invoke("shell:reveal", String(base), String(rel)),
|
||||
// A project the sidebar lists, which may have no pane at all.
|
||||
revealWorkspace: (root) => ipcRenderer.invoke("shell:reveal-workspace", String(root)),
|
||||
// Where a dropped file lives. Resolved here rather than in the page: the
|
||||
// renderer is handed a File and never a path, and a turn that has to work on
|
||||
// the file itself cannot do it on a copy of the bytes.
|
||||
|
||||
@@ -23,9 +23,13 @@ function local(target, platform) {
|
||||
async function locate(client, base, rel, platform) {
|
||||
if (!PANE_BASE.test(base)) return { refusal: refused("not a pane this window publishes") };
|
||||
const query = rel ? `?path=${encodeURIComponent(rel)}` : "";
|
||||
return ask(client, `${base}/workspace/locate${query}`, platform);
|
||||
}
|
||||
|
||||
async function ask(client, url, platform) {
|
||||
let res;
|
||||
try {
|
||||
res = await client.request("GET", `${base}/workspace/locate${query}`);
|
||||
res = await client.request("GET", url);
|
||||
} catch (err) {
|
||||
return { refusal: refused(err.message) };
|
||||
}
|
||||
@@ -54,4 +58,13 @@ async function reveal(client, shell, base, rel, platform = process.platform) {
|
||||
return null;
|
||||
}
|
||||
|
||||
module.exports = { reveal };
|
||||
// A project the sidebar lists needs no pane: the hub answers for the folders
|
||||
// it lists and refuses any other, so the page still names no location itself.
|
||||
async function revealWorkspace(client, shell, root, platform = process.platform) {
|
||||
const found = await ask(client, `/host/workspaces/locate?root=${encodeURIComponent(String(root))}`, platform);
|
||||
if (found.refusal) return found.refusal;
|
||||
shell.showItemInFolder(found.target);
|
||||
return null;
|
||||
}
|
||||
|
||||
module.exports = { reveal, revealWorkspace };
|
||||
@@ -1,18 +1,17 @@
|
||||
"use strict";
|
||||
const path = require("node:path");
|
||||
const { Menu, Tray, nativeImage } = require("electron");
|
||||
const { Menu, Tray, nativeImage, screen } = require("electron");
|
||||
const { trayImage } = require("./trayimage.js");
|
||||
|
||||
// How often the fold is re-read. It is a projection the kernel recomputes on
|
||||
// demand, so a missed read costs nothing the next one does not restore — which
|
||||
// is why it is pulled on a timer rather than carried on the stream.
|
||||
const FOLD_INTERVAL_MS = 5000;
|
||||
const ICON = path.join(__dirname, "..", "assets", "tray.png");
|
||||
|
||||
// installTray brings the icon up and reports whether there is one. A shell with
|
||||
// no icon must never background its window: hiding it would leave a running
|
||||
// session with nothing left to bring it back.
|
||||
function installTray(host, { onOpen, onQuit }) {
|
||||
const image = nativeImage.createFromPath(ICON);
|
||||
const image = trayImage(nativeImage, screen.getPrimaryDisplay().scaleFactor);
|
||||
if (image.isEmpty()) return null;
|
||||
const icon = new Tray(image);
|
||||
const state = { fold: null, prefs: null };
|
||||
@@ -47,6 +46,9 @@ function installTray(host, { onOpen, onQuit }) {
|
||||
);
|
||||
};
|
||||
|
||||
const refit = () => icon.setImage(trayImage(nativeImage, screen.getPrimaryDisplay().scaleFactor));
|
||||
if (process.platform === "win32") screen.on("display-metrics-changed", refit);
|
||||
|
||||
icon.on("click", onOpen);
|
||||
const timer = setInterval(() => void refresh(), FOLD_INTERVAL_MS);
|
||||
|
||||
@@ -68,6 +70,7 @@ function installTray(host, { onOpen, onQuit }) {
|
||||
refresh,
|
||||
close() {
|
||||
clearInterval(timer);
|
||||
screen.removeListener("display-metrics-changed", refit);
|
||||
icon.destroy();
|
||||
},
|
||||
};
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
"use strict";
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
|
||||
const ASSETS = path.join(__dirname, "..", "assets");
|
||||
const SCALES = [
|
||||
[1, ""],
|
||||
[1.25, "@1.25x"],
|
||||
[1.5, "@1.5x"],
|
||||
[1.75, "@1.75x"],
|
||||
[2, "@2x"],
|
||||
[2.5, "@2.5x"],
|
||||
[3, "@3x"],
|
||||
];
|
||||
|
||||
function trayAsset(scaleFactor) {
|
||||
const want = Number.isFinite(scaleFactor) && scaleFactor > 0 ? scaleFactor : 1;
|
||||
const hit = SCALES.find(([scale]) => scale >= want - 1e-6) ?? SCALES[SCALES.length - 1];
|
||||
return { file: path.join(ASSETS, `tray${hit[1]}.png`), pixels: Math.round(16 * hit[0]) };
|
||||
}
|
||||
|
||||
// Windows rebuilds the tray icon from the 1x bitmap, resizing it to the small
|
||||
// icon metric, so the file is loaded as 1x pixels of exactly that size. Other
|
||||
// platforms choose among the @Nx files themselves.
|
||||
function trayImage(nativeImage, scaleFactor) {
|
||||
if (process.platform !== "win32") return nativeImage.createFromPath(path.join(ASSETS, "tray.png"));
|
||||
return nativeImage.createFromBuffer(fs.readFileSync(trayAsset(scaleFactor).file));
|
||||
}
|
||||
|
||||
module.exports = { trayAsset, trayImage, SCALES };
|
||||
@@ -0,0 +1,17 @@
|
||||
"use strict";
|
||||
|
||||
// The interface language the page will draw in, decided from the same two
|
||||
// facts it uses: its saved choice (rx-lang) and, for anything but an explicit
|
||||
// zh or en, the machine's first locale.
|
||||
function isChinese(tag) {
|
||||
const s = String(tag ?? "").toLowerCase();
|
||||
return s.startsWith("zh") || s.startsWith("yue") || s.startsWith("cmn") || s.includes("hans") || s.includes("hant");
|
||||
}
|
||||
|
||||
function uiLanguage(prefs, machineLocale) {
|
||||
const want = String(prefs?.["rx-lang"] ?? "").trim().toLowerCase();
|
||||
if (want === "zh" || want === "en") return want;
|
||||
return isChinese(machineLocale) ? "zh" : "en";
|
||||
}
|
||||
|
||||
module.exports = { uiLanguage };
|
||||
@@ -303,11 +303,11 @@ async function menuChecks(win, js) {
|
||||
const menu = Menu.getApplicationMenu();
|
||||
const roles = (menu?.items ?? []).map((i) => String(i.role).toLowerCase());
|
||||
check("the application menu is installed", !!menu);
|
||||
check("it carries the three menus macOS routes through", ["appmenu", "editmenu", "windowmenu"].every((r) => roles.includes(r)), roles);
|
||||
check("it carries the three menus macOS routes through", ["appmenu", "windowmenu"].every((r) => roles.includes(r)) && menu.items.length === 3, roles);
|
||||
|
||||
const edit = (menu?.items ?? []).find((i) => String(i.role).toLowerCase() === "editmenu");
|
||||
const edit = (menu?.items ?? [])[1];
|
||||
const editRoles = (edit?.submenu?.items ?? []).map((i) => String(i.role).toLowerCase());
|
||||
check("the edit menu carries every editing command", ["undo", "redo", "cut", "copy", "paste", "selectall"].every((r) => editRoles.includes(r)), editRoles);
|
||||
check("the edit menu carries every editing command", ["undo", "redo", "cut", "copy", "paste", "pasteandmatchstyle", "delete", "selectall"].every((r) => editRoles.includes(r)), editRoles);
|
||||
|
||||
app.focus({ steal: true });
|
||||
win.focus();
|
||||
|
||||
@@ -7,7 +7,8 @@ import fs from "node:fs";
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
const { parse, readActs } = require("../src/host.js");
|
||||
const { contextTemplate } = require("../src/editmenu.js");
|
||||
const { contextTemplate, editMenuTemplate, applicationMenuTemplate, menuInstaller } = require("../src/editmenu.js");
|
||||
const { uiLanguage } = require("../src/uilang.js");
|
||||
const { externalTarget } = require("../src/links.js");
|
||||
const { offerCleanup, ownBundle } = require("../src/legacy.js");
|
||||
const { stripPackageGrants, readReport, unpaintedWindowCause } = require("../src/packagegrants.js");
|
||||
@@ -63,6 +64,68 @@ test("the context menu mirrors what the page says is possible", () => {
|
||||
});
|
||||
});
|
||||
|
||||
test("the edit menu's labels follow the interface language, not the system's", () => {
|
||||
const params = { isEditable: true, selectionText: "", editFlags: {} };
|
||||
const labels = (lang) => contextTemplate(params, lang).filter((i) => i.role).map((i) => i.label);
|
||||
assert.deepEqual(labels("zh"), ["撤销", "重做", "剪切", "复制", "粘贴", "全选"]);
|
||||
assert.deepEqual(labels("en"), ["Undo", "Redo", "Cut", "Copy", "Paste", "Select All"]);
|
||||
assert.equal(editMenuTemplate("zh").label, "编辑");
|
||||
});
|
||||
|
||||
test("the edit menu keeps everything the platform's own one carries", () => {
|
||||
const roles = (items) => items.flatMap((i) => [i.role, ...(i.submenu ? roles(i.submenu) : [])]).filter(Boolean);
|
||||
const want = ["undo", "redo", "cut", "copy", "paste", "pasteAndMatchStyle", "delete", "selectAll",
|
||||
"showSubstitutions", "toggleSmartQuotes", "toggleSmartDashes", "toggleTextReplacement", "startSpeaking", "stopSpeaking"];
|
||||
for (const lang of ["zh", "en"]) {
|
||||
assert.deepEqual(roles(editMenuTemplate(lang).submenu), want);
|
||||
const labels = (items) => items.flatMap((i) => [i.label, ...(i.submenu ? labels(i.submenu) : [])]).filter((l) => l !== undefined);
|
||||
assert.ok(labels(editMenuTemplate(lang).submenu).every((l) => l.length > 0));
|
||||
}
|
||||
assert.deepEqual(editMenuTemplate("en").submenu.map((i) => i.role ?? i.type ?? i.label),
|
||||
["undo", "redo", "separator", "cut", "copy", "paste", "pasteAndMatchStyle", "delete", "selectAll", "separator", "Substitutions", "Speech"]);
|
||||
assert.deepEqual(applicationMenuTemplate("zh").map((i) => i.role ?? "edit"), ["appMenu", "edit", "windowMenu"]);
|
||||
});
|
||||
|
||||
test("the application menu is rebuilt when the language changes and not otherwise", () => {
|
||||
const built = [];
|
||||
const Menu = {
|
||||
buildFromTemplate: (t) => (built.push(t), { template: t }),
|
||||
setApplicationMenu: () => {},
|
||||
getApplicationMenu: () => null,
|
||||
};
|
||||
const install = menuInstaller(Menu, "darwin");
|
||||
let lang = "en";
|
||||
install(() => lang);
|
||||
install(() => lang);
|
||||
assert.equal(built.length, 1);
|
||||
lang = "zh";
|
||||
install(() => lang);
|
||||
assert.equal(built.length, 2);
|
||||
assert.equal(built[1][1].label, "编辑");
|
||||
let cleared = 0;
|
||||
menuInstaller({ ...Menu, setApplicationMenu: () => cleared++ }, "linux")(() => "zh");
|
||||
assert.equal(cleared, 1);
|
||||
assert.equal(built.length, 2);
|
||||
});
|
||||
|
||||
test("the interface language is the page's own choice, else the machine's", () => {
|
||||
assert.equal(uiLanguage({ "rx-lang": "zh" }, "en-US"), "zh");
|
||||
assert.equal(uiLanguage({ "rx-lang": "en" }, "zh-CN"), "en");
|
||||
assert.equal(uiLanguage({ "rx-lang": "" }, "zh-Hans-CN"), "zh");
|
||||
assert.equal(uiLanguage({}, "fr-FR"), "en");
|
||||
assert.equal(uiLanguage(undefined, undefined), "en");
|
||||
});
|
||||
|
||||
test("a saved preference tells the shell so it can follow it", () => {
|
||||
const handlers = {};
|
||||
const ipc = { on: (name, fn) => { handlers[name] = fn; } };
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rx-prefs-"));
|
||||
let saved = 0;
|
||||
registerPrefs(ipc, () => path.join(dir, "p.json"), () => true, () => { saved++; });
|
||||
handlers["prefs:save"]({ returnValue: null }, { "rx-lang": "zh" });
|
||||
assert.equal(saved, 1);
|
||||
});
|
||||
|
||||
test("only http and https ever reach the platform opener", () => {
|
||||
assert.equal(externalTarget("https://example.test/a"), "https://example.test/a");
|
||||
assert.equal(externalTarget("http://example.test/"), "http://example.test/");
|
||||
@@ -124,7 +187,7 @@ test("an unreachable kernel is an answer, not a crash", async () => {
|
||||
assert.equal(await dead.trayState(), null);
|
||||
});
|
||||
|
||||
const { reveal } = require("../src/reveal.js");
|
||||
const { reveal, revealWorkspace } = require("../src/reveal.js");
|
||||
|
||||
// A kernel that answers /workspace/locate as the test says, and a shell that
|
||||
// only records what it was asked to open.
|
||||
@@ -143,7 +206,7 @@ async function revealRig(answer, platform = process.platform) {
|
||||
openPath: async (p) => (opened.push(["open", p]), ""),
|
||||
showItemInFolder: (p) => opened.push(["select", p]),
|
||||
};
|
||||
return { asked, opened, run: (base, rel) => reveal(client, shell, base, rel, platform), close: () => server.close() };
|
||||
return { asked, opened, run: (base, rel) => reveal(client, shell, base, rel, platform), workspace: (root) => revealWorkspace(client, shell, root, platform), close: () => server.close() };
|
||||
}
|
||||
|
||||
const ROOT = path.resolve(os.tmpdir(), "rx-workspace");
|
||||
@@ -194,6 +257,22 @@ test("the page cannot steer reveal to a location the kernel did not name", async
|
||||
}
|
||||
});
|
||||
|
||||
test("a listed project is shown through the hub and only on its answer", async () => {
|
||||
const rig = await revealRig((url) =>
|
||||
url.pathname === "/host/workspaces/locate" && url.searchParams.get("root") === "/work/a b"
|
||||
? [200, { path: path.join(ROOT, "a b"), dir: true }]
|
||||
: [404, { code: "workspace.not_listed", error: "not listed" }],
|
||||
);
|
||||
try {
|
||||
assert.equal(await rig.workspace("/work/a b"), null);
|
||||
assert.equal((await rig.workspace("/etc")).code, "workspace.not_listed");
|
||||
assert.deepEqual(rig.opened, [["select", path.join(ROOT, "a b")]]);
|
||||
assert.equal(rig.asked[0], "/host/workspaces/locate?root=%2Fwork%2Fa%20b");
|
||||
} finally {
|
||||
rig.close();
|
||||
}
|
||||
});
|
||||
|
||||
// A root that is an .app bundle, or a link to one, is a directory to stat and
|
||||
// an application to `open`: handing it to openPath would launch it.
|
||||
test("the workspace root is selected, never opened", async () => {
|
||||
@@ -1177,6 +1256,7 @@ function loadShell({ lock, host }) {
|
||||
if (key === "getPath") return () => userData;
|
||||
if (key === "getVersion") return () => "9.9.9";
|
||||
if (key === "getLocale") return () => "en-US";
|
||||
if (key === "getPreferredSystemLanguages") return () => ["en-US"];
|
||||
if (key === "isPackaged") return false;
|
||||
if (key === "quit") return () => { calls.push(["quit"]); quit(); };
|
||||
return inert;
|
||||
@@ -1245,3 +1325,20 @@ test("a host that exits before its handshake is logged and shown, not swallowed"
|
||||
shell.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("the tray has a file for every Windows scale, at exactly 16 * scale pixels", () => {
|
||||
const { trayAsset, SCALES } = require("../src/trayimage.js");
|
||||
const dim = (f) => {
|
||||
const b = fs.readFileSync(f);
|
||||
return [b.readUInt32BE(16), b.readUInt32BE(20)];
|
||||
};
|
||||
for (const [scale] of SCALES) {
|
||||
const { file, pixels } = trayAsset(scale);
|
||||
assert.deepEqual(dim(file), [pixels, pixels], file);
|
||||
assert.equal(pixels, Math.round(16 * scale));
|
||||
}
|
||||
assert.equal(trayAsset(1.75).pixels, 28);
|
||||
assert.equal(trayAsset(1.8).pixels, 32);
|
||||
assert.equal(trayAsset(5).pixels, 48);
|
||||
assert.equal(trayAsset(NaN).pixels, 16);
|
||||
});
|
||||
@@ -46,6 +46,7 @@ node perf/pick.mjs # 补全菜单:/ 和 @ 的选中态,深浅两色下
|
||||
node perf/models.mjs # 连接面板:网关报出一百多个模型时,这一屏还能不能用
|
||||
node perf/side.mjs # 右栏:端点自己写的一句话有多长,都不该改变栏宽
|
||||
node perf/queue.mjs # 待送达:正文读不回来时,「改」不许开出一个空编辑器
|
||||
node perf/onboarding.mjs # First-run setup: zoom-aware card bounds, resize, scrolling, focus and completion
|
||||
node perf/running.mjs # 执行过程:在跑的步骤自己在动、收起后由标题接着动,步骤行没有空栏
|
||||
node perf/lang.mjs # 双语验证:英文启动、界面译文到位、切回中文
|
||||
node perf/locale.mjs # 跟随系统语言:中文各写法都归中文,其余归英文
|
||||
|
||||
@@ -45,11 +45,12 @@ class BenchPort extends MockPort {
|
||||
}
|
||||
|
||||
async providerSetup() {
|
||||
return null;
|
||||
return ONBOARDING ? { required: true, provider: "", model: "" } : null;
|
||||
}
|
||||
|
||||
async appearance() {
|
||||
const look = await super.appearance();
|
||||
let look = await super.appearance();
|
||||
if (query.has("zoom")) look = { ...look, zoom: Number(query.get("zoom")) };
|
||||
return PREF === null ? look : { ...look, language: PREF };
|
||||
}
|
||||
|
||||
@@ -149,6 +150,7 @@ const WORKSPACES = Number(query.get("ws") ?? 0);
|
||||
// ?pref= 是「内核记着的语言」。真机上它来自 config;这里由地址给,好让
|
||||
// 一次验证能把两侧摆成同一个值——否则 adopt 会认为本地缓存过期。
|
||||
const PREF = query.get("pref");
|
||||
const ONBOARDING = query.has("onboarding");
|
||||
const SESSIONS = Number(query.get("sess") ?? 0);
|
||||
// ?turns= is how long the conversation being opened already is.
|
||||
const TURNS = Number(query.get("turns") ?? 0);
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
// The first-run "start" button must be reachable at every window height.
|
||||
// The body clips, so a card taller than the window is only reachable if the
|
||||
// stage itself scrolls. Focus is no evidence: the browser scrolls the clipped
|
||||
// body to a focused element, so the wheel is what gets asserted.
|
||||
import { chromium } from "playwright";
|
||||
import { mkdirSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
const PAGE = process.env.PERF_URL ?? "http://localhost:4399/perf.html?onboarding=1";
|
||||
const SHOTS = process.env.ONBOARDING_SHOTS;
|
||||
const SIZES = [
|
||||
[1920, 700], [1600, 600], [2560, 560], [1366, 600], [1280, 540], [1440, 900], [1600, 760, 1.25],
|
||||
[760, 480], [761, 480], [760, 480, 0.8], [761, 480, 1.8], [800, 480, 1.8], [840, 560, 1.8],
|
||||
[840, 560, 1.5], [1280, 720, 1.8], [1600, 900, 1.8, [840, 560]], [840, 560, 1.8, [1600, 900]],
|
||||
];
|
||||
if (SHOTS) mkdirSync(SHOTS, { recursive: true });
|
||||
const fails = [];
|
||||
const check = (name, ok, detail = "") => {
|
||||
console.log(`${ok ? " ok" : "FAIL"} ${name}${detail ? " — " + detail : ""}`);
|
||||
if (!ok) fails.push(name);
|
||||
};
|
||||
|
||||
const browser = await chromium.launch();
|
||||
for (const scheme of ["light", "dark"]) {
|
||||
for (const [width, height, zoom = 1, resize] of SIZES) {
|
||||
const ctx = await browser.newContext({ locale: "zh-CN", viewport: { width, height }, colorScheme: scheme });
|
||||
const page = await ctx.newPage();
|
||||
page.on("pageerror", (e) => fails.push("page error: " + e.message));
|
||||
const url = new URL(PAGE);
|
||||
url.searchParams.set("zoom", String(zoom));
|
||||
await page.goto(url.href, { waitUntil: "domcontentloaded" });
|
||||
await page.waitForSelector(".onb-card");
|
||||
await page.waitForFunction((z) => Number(getComputedStyle(document.documentElement).zoom) === z, zoom);
|
||||
const tag = `${scheme} ${width}x${height}${zoom !== 1 ? ` zoom${zoom}` : ""}${resize ? ` to ${resize.join("x")}` : ""}`;
|
||||
|
||||
const fitsCard = async (label) => {
|
||||
const layout = await page.evaluate(() => {
|
||||
const stage = document.querySelector(".onb-stage");
|
||||
const shell = document.querySelector(".onb-shell");
|
||||
const r = shell.getBoundingClientRect();
|
||||
const fields = ["#onb-url", "#onb-key", ".onb-go", ".onb-note", ".onb-protocols", ".onb-pick"];
|
||||
const clipped = fields.flatMap((selector) => {
|
||||
const el = document.querySelector(selector);
|
||||
if (!el) return [];
|
||||
const box = el.getBoundingClientRect();
|
||||
return box.left < Math.max(0, r.left) - 0.5 || box.right > Math.min(innerWidth, r.right) + 0.5
|
||||
? [{ selector, left: box.left, right: box.right, shellRight: r.right }] : [];
|
||||
});
|
||||
return { clipped, columns: getComputedStyle(shell).gridTemplateColumns.split(" ").length,
|
||||
expectedColumns: stage.clientWidth <= 760 ? 1 : 2, width: stage.scrollWidth, client: stage.clientWidth };
|
||||
});
|
||||
check(`${tag} ${label}: form stays within the card and viewport`, layout.clipped.length === 0, JSON.stringify(layout.clipped));
|
||||
check(`${tag} ${label}: columns follow zoom-adjusted room`, layout.columns === layout.expectedColumns, JSON.stringify(layout));
|
||||
check(`${tag} ${label}: stage does not scroll sideways`, layout.width <= layout.client + 1);
|
||||
};
|
||||
|
||||
const reachable = async (label, enabled) => {
|
||||
const viewport = page.viewportSize();
|
||||
const go = page.locator(".onb-go");
|
||||
await page.evaluate(() => document.activeElement?.blur());
|
||||
await page.mouse.move(viewport.width / 2, viewport.height / 2);
|
||||
await page.mouse.wheel(0, 4000);
|
||||
await page.waitForTimeout(250);
|
||||
const box = await go.boundingBox();
|
||||
check(`${tag} ${label}: after wheeling to the bottom the button is in the viewport`, !!box && box.y >= 0 && box.y + box.height <= viewport.height + 0.5, JSON.stringify(box));
|
||||
const bar = await page.locator(".onb-brandbar").boundingBox();
|
||||
check(`${tag} ${label}: the title bar stays in the viewport`, !!bar && bar.y >= -0.5 && bar.y + bar.height <= viewport.height + 0.5, JSON.stringify(bar));
|
||||
if (!enabled) return;
|
||||
const hit = await page.evaluate(() => {
|
||||
const r = document.querySelector(".onb-go").getBoundingClientRect();
|
||||
const el = document.elementFromPoint(r.x + r.width / 2, r.y + r.height / 2);
|
||||
return !!el?.closest(".onb-go");
|
||||
});
|
||||
check(`${tag} ${label}: the point under its centre hits it`, hit);
|
||||
if (SHOTS) await page.screenshot({ path: join(SHOTS, `${tag.replaceAll(" ", "-")}-${label.replaceAll(" ", "-")}.png`) });
|
||||
const first = page.locator(".onb-chip").first();
|
||||
await page.evaluate(() => document.activeElement?.blur());
|
||||
await first.focus();
|
||||
await page.waitForTimeout(250);
|
||||
const [fb, bb] = [await first.boundingBox(), await page.locator(".onb-brandbar").boundingBox()];
|
||||
check(`${tag} ${label}: a focused top-of-card element sits below the title bar`, !!fb && !!bb && fb.y >= bb.y + bb.height - 0.5, JSON.stringify({ fb, bb }));
|
||||
};
|
||||
|
||||
await fitsCard("before connect");
|
||||
await reachable("before connect", false);
|
||||
await page.fill('input[placeholder="sk-…"]', "sk-test");
|
||||
await page.locator(".onb-go").click();
|
||||
await page.waitForSelector(".onb-found");
|
||||
await fitsCard("after connect");
|
||||
await reachable("after connect", true);
|
||||
if (resize) {
|
||||
await page.setViewportSize({ width: resize[0], height: resize[1] });
|
||||
await page.waitForFunction(() => {
|
||||
const stage = document.querySelector(".onb-stage");
|
||||
return Math.abs(stage.getBoundingClientRect().width - innerWidth) < 1;
|
||||
});
|
||||
await fitsCard("after resize");
|
||||
await reachable("after resize", true);
|
||||
}
|
||||
await page.locator(".onb-go").click();
|
||||
await page.waitForSelector(".app .chrome");
|
||||
check(`${tag}: Start finishes setup`, await page.locator(".onb-stage").count() === 0);
|
||||
await ctx.close();
|
||||
}
|
||||
}
|
||||
await browser.close();
|
||||
console.log(fails.length ? `\n${fails.length} failed` : "\nall passed");
|
||||
process.exit(fails.length ? 1 : 0);
|
||||
@@ -95,6 +95,10 @@ export const ACTIONS: UIAction[] = [
|
||||
|
||||
{ id: "tool.image-open", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "tool.image-close", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "tool.show-all", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "agent.open", kind: "view", target: "entity", proof: "interaction" },
|
||||
{ id: "agent.focus-trap", kind: "navigation", target: "none", proof: "interaction" },
|
||||
{ id: "agent.close", kind: "view", target: "none", proof: "interaction" },
|
||||
|
||||
// ── The turn ─────────────────────────────────────────────────────────────
|
||||
// Send and stop remain two actions even when they sit together: during a
|
||||
@@ -151,6 +155,14 @@ export const ACTIONS: UIAction[] = [
|
||||
{ id: "account.reload", kind: "repeatable", target: "none", proof: "interaction" },
|
||||
{ id: "account.sign-in", kind: "kernel-mutation", target: "none", proof: "interaction" },
|
||||
{ id: "account.sign-out", kind: "kernel-mutation", target: "none", proof: "interaction" },
|
||||
{ id: "community.join", kind: "shell-native", target: "none", proof: "interaction" },
|
||||
{ id: "community.discord", kind: "shell-native", target: "none", proof: "interaction" },
|
||||
{ id: "community.issues", kind: "shell-native", target: "none", proof: "interaction" },
|
||||
{ id: "community.contributors", kind: "shell-native", target: "none", proof: "interaction" },
|
||||
{ id: "community.profile", kind: "shell-native", target: "entity", proof: "interaction" },
|
||||
{ id: "community.douyin-qr", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "community.author", kind: "shell-native", target: "none", proof: "interaction" },
|
||||
{ id: "community.more", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "feedback.open", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "feedback.tab", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "feedback.category", kind: "view", target: "none", proof: "interaction" },
|
||||
@@ -244,6 +256,7 @@ export const ACTIONS: UIAction[] = [
|
||||
{ id: "market.mine-retry", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "market.submit", kind: "kernel-mutation", target: "none", proof: "interaction" },
|
||||
{ id: "market.vote", kind: "kernel-mutation", target: "none", proof: "interaction" },
|
||||
{ id: "market.vote-retry", kind: "view", target: "none", proof: "interaction" },
|
||||
// Taking back the innermost open thing: a popover, an inline form. One
|
||||
// intent, reached by pressing away and by Escape.
|
||||
{ id: "layer.dismiss", kind: "navigation", target: "none", proof: "interaction" },
|
||||
@@ -281,6 +294,7 @@ export const ACTIONS: UIAction[] = [
|
||||
// state — which is why it is one id and why it is not a mutation.
|
||||
{ id: "external.open", kind: "shell-native", target: "none", proof: "interaction" },
|
||||
{ id: "remote-host.remove", kind: "destructive", target: "none", proof: "authority-effect" },
|
||||
{ id: "workspace.reveal", kind: "shell-native", target: "entity", proof: "interaction" },
|
||||
{ id: "workspace.remove", kind: "destructive", target: "none", proof: "authority-effect" },
|
||||
{ id: "extensions.invoke", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
|
||||
|
||||
@@ -295,6 +309,8 @@ export const ACTIONS: UIAction[] = [
|
||||
{ id: "remote.probe", kind: "repeatable", target: "entity", proof: "authority-effect" },
|
||||
{ id: "remote.read", kind: "repeatable", target: "entity", proof: "authority-effect" },
|
||||
{ id: "workspace.add", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
|
||||
{ id: "workspace.add-cancel", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "workspace.move", kind: "kernel-mutation", target: "entity", proof: "authority-effect" },
|
||||
|
||||
// ── Taking a change back ─────────────────────────────────────────────────
|
||||
// Planning a revert and performing one are two contracts: the first asks the
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 137 KiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="296" height="296" class="segno"><g transform="scale(8)"><path fill="#fff" d="M0 0h37v37h-37z"/><path class="qrline" stroke="#000" d="M4 4.5h7m2 0h1m1 0h1m2 0h1m2 0h1m1 0h2m1 0h7m-29 1h1m5 0h1m5 0h1m3 0h2m2 0h1m1 0h1m5 0h1m-29 1h1m1 0h3m1 0h1m2 0h4m1 0h2m1 0h1m2 0h1m1 0h1m1 0h3m1 0h1m-29 1h1m1 0h3m1 0h1m6 0h1m2 0h1m1 0h3m1 0h1m1 0h3m1 0h1m-29 1h1m1 0h3m1 0h1m11 0h1m1 0h1m1 0h1m1 0h3m1 0h1m-29 1h1m5 0h1m1 0h5m2 0h3m1 0h1m2 0h1m5 0h1m-29 1h7m1 0h1m1 0h1m1 0h1m1 0h1m1 0h1m1 0h1m1 0h1m1 0h7m-20 1h1m1 0h3m1 0h3m-18 1h1m2 0h1m1 0h2m1 0h1m1 0h2m2 0h1m1 0h1m1 0h2m1 0h1m1 0h1m-21 1h1m1 0h1m1 0h2m1 0h1m2 0h1m1 0h4m1 0h1m1 0h1m2 0h1m2 0h1m-29 1h4m1 0h4m1 0h2m1 0h3m2 0h2m3 0h5m-28 1h4m4 0h1m3 0h2m2 0h1m9 0h2m-28 1h2m2 0h1m1 0h1m1 0h5m1 0h2m1 0h1m1 0h2m1 0h2m1 0h1m1 0h2m-28 1h1m1 0h2m3 0h1m2 0h1m1 0h4m1 0h2m1 0h1m-22 1h2m1 0h2m1 0h1m1 0h1m1 0h1m1 0h3m3 0h1m1 0h2m1 0h1m1 0h4m-28 1h2m1 0h2m2 0h1m1 0h1m4 0h1m1 0h1m1 0h1m1 0h2m1 0h2m1 0h1m-28 1h4m2 0h2m1 0h3m1 0h1m3 0h3m1 0h1m5 0h1m-27 1h2m2 0h1m1 0h1m2 0h1m1 0h5m1 0h1m2 0h2m2 0h1m2 0h1m-29 1h1m1 0h2m1 0h6m3 0h5m3 0h3m2 0h2m-27 1h2m4 0h1m1 0h2m2 0h1m2 0h1m1 0h2m3 0h1m2 0h2m-29 1h1m2 0h1m2 0h1m2 0h1m5 0h3m1 0h6m1 0h1m-19 1h2m1 0h2m1 0h2m1 0h4m3 0h1m1 0h3m-29 1h7m2 0h1m2 0h1m4 0h2m1 0h1m1 0h1m1 0h1m2 0h1m-28 1h1m5 0h1m1 0h2m1 0h1m2 0h2m3 0h2m3 0h5m-29 1h1m1 0h3m1 0h1m2 0h2m1 0h1m3 0h3m1 0h5m2 0h1m-28 1h1m1 0h3m1 0h1m1 0h2m2 0h2m1 0h2m1 0h2m2 0h5m1 0h1m-29 1h1m1 0h3m1 0h1m2 0h2m1 0h1m1 0h1m2 0h4m3 0h3m1 0h1m-29 1h1m5 0h1m2 0h1m2 0h1m1 0h1m2 0h1m1 0h2m1 0h1m4 0h1m-28 1h7m1 0h1m3 0h2m2 0h2m2 0h3m1 0h1m2 0h1"/></g></svg>
|
||||
|
After Width: | Height: | Size: 1.6 KiB |
@@ -0,0 +1,141 @@
|
||||
[
|
||||
"SivanCola",
|
||||
"KHG420",
|
||||
"ttmouse",
|
||||
"lifu963",
|
||||
"HUQIANTAO",
|
||||
"GTC2080",
|
||||
"mchenziyi",
|
||||
"Li-Charles-One",
|
||||
"light-front-theory",
|
||||
"eghrhegpe",
|
||||
"Bernardxu123",
|
||||
"HaoyueQin",
|
||||
"BuGlessRB",
|
||||
"chuiwenwei",
|
||||
"CVEngineer66",
|
||||
"JesonChou",
|
||||
"lanshi17",
|
||||
"SuMuxi66",
|
||||
"paradoxSCH",
|
||||
"CnsMaple",
|
||||
"cyq1017",
|
||||
"Guan0923",
|
||||
"ipanta",
|
||||
"XTLine",
|
||||
"YoungDan-hero",
|
||||
"erphenheimer",
|
||||
"SauronSkywalker",
|
||||
"ashishexee",
|
||||
"lizhengwu",
|
||||
"par73e",
|
||||
"yizeyi18",
|
||||
"Nath-Vikky",
|
||||
"PMKang",
|
||||
"qiaone",
|
||||
"ferstar",
|
||||
"Harbor404",
|
||||
"liaoyl830",
|
||||
"Lxiny-zy",
|
||||
"zhangsanouo",
|
||||
"AtmoOmen",
|
||||
"codex",
|
||||
"Diarica",
|
||||
"ffff2004",
|
||||
"ikelvingo",
|
||||
"kekexunxun",
|
||||
"lightfront",
|
||||
"maoxin1234",
|
||||
"nostalgia296",
|
||||
"onionviolet",
|
||||
"PaulDing98",
|
||||
"scrappylabsai",
|
||||
"skyzhao1223",
|
||||
"Tatlatat",
|
||||
"yekern",
|
||||
"assoyuan",
|
||||
"billycat",
|
||||
"boscocp",
|
||||
"closss",
|
||||
"drafish",
|
||||
"HBLADEH",
|
||||
"heshuimu",
|
||||
"iysun",
|
||||
"jackie-cqz",
|
||||
"LVGG",
|
||||
"nanami7777777",
|
||||
"qiukeren",
|
||||
"sudoviber",
|
||||
"warvyvr",
|
||||
"whale-fall-ouo",
|
||||
"wqshan-cn",
|
||||
"xianyu-sheng",
|
||||
"Xio-Shark",
|
||||
"xxoingr",
|
||||
"yhemeo613",
|
||||
"zdjmrq",
|
||||
"Abysstsuki",
|
||||
"ahmedalbusaidy",
|
||||
"Akashmdshetty",
|
||||
"alxbnct",
|
||||
"c020627",
|
||||
"chengoak",
|
||||
"CooperSheroy",
|
||||
"dcs02280653-collab",
|
||||
"dengbushi",
|
||||
"EMEEEEMMMM",
|
||||
"ERroooooR",
|
||||
"gcoder1991",
|
||||
"h4rk8s",
|
||||
"HHHEEEWWW",
|
||||
"hobostay",
|
||||
"HorusJiang",
|
||||
"i1j",
|
||||
"innocarpe",
|
||||
"j2bap",
|
||||
"jackijianxa",
|
||||
"jbts6",
|
||||
"ji-tz",
|
||||
"JimmyDaddy",
|
||||
"kabaka9527",
|
||||
"kate1128",
|
||||
"LegGasai",
|
||||
"leslie3747",
|
||||
"LouF666",
|
||||
"MagnumGoYB",
|
||||
"MaitreyeeDeshmukh",
|
||||
"myipanta",
|
||||
"nanami-0713",
|
||||
"Ninot1Quyi",
|
||||
"NkAntony777",
|
||||
"no-name-touse",
|
||||
"Nontee22",
|
||||
"onenewcode",
|
||||
"Oxygen56",
|
||||
"PorunC",
|
||||
"Q1hangL",
|
||||
"QingLong-C",
|
||||
"Ray-lyy",
|
||||
"rixzkiye",
|
||||
"Robertoarce",
|
||||
"ryukeilee",
|
||||
"seankhliao",
|
||||
"ShiroKSH",
|
||||
"siwenyu666-beep",
|
||||
"SprayHank",
|
||||
"Starry0214",
|
||||
"Surfire",
|
||||
"taibai233",
|
||||
"themarchrain",
|
||||
"tic-top",
|
||||
"weikengchen",
|
||||
"wudiandy",
|
||||
"xiaoshuai1024",
|
||||
"xiaotaoya2022-cell",
|
||||
"xinrenH",
|
||||
"ying-hua",
|
||||
"yuanchenglu",
|
||||
"zhuguang-ZFG",
|
||||
"Zhuocheng-Lang",
|
||||
"zlwu"
|
||||
]
|
||||
@@ -17,11 +17,13 @@ import { EN_USAGE } from "./en_usage";
|
||||
import { EN_COMPOSER } from "./en_composer";
|
||||
import { EN_BACKUP } from "./en_backup";
|
||||
import { EN_FEEDBACK } from "./en_feedback";
|
||||
import { EN_COMMUNITY } from "./en_community";
|
||||
|
||||
export const EN: Record<string, string> = {
|
||||
"工作台": "Workbench",
|
||||
"从右侧选择文件,或打开浏览器": "Choose a file on the right, or open a browser",
|
||||
...EN_SETTINGS,
|
||||
...EN_COMMUNITY,
|
||||
...EN_METRICS,
|
||||
...EN_STORAGE,
|
||||
...EN_USAGE,
|
||||
@@ -69,6 +71,7 @@ export const EN: Record<string, string> = {
|
||||
"复制": "Copy",
|
||||
"已复制": "Copied",
|
||||
"复制不了": "Copy blocked",
|
||||
"复制失败,请重试": "Copy failed. Try again.",
|
||||
"复制这段回答": "Copy this answer",
|
||||
"复制这段代码": "Copy this code",
|
||||
"复制路径": "Copy path",
|
||||
@@ -278,6 +281,10 @@ export const EN: Record<string, string> = {
|
||||
"尚无文件夹": "No folders yet",
|
||||
"正在读取文件夹…": "Reading folders…",
|
||||
"打开项目…": "Open a project…",
|
||||
"添加工作区": "Add a workspace",
|
||||
"无法打开文件夹选择器。请输入内核所在机器上的路径。": "The folder picker cannot open. Enter a path on the machine running the kernel.",
|
||||
"工作区路径": "Workspace path",
|
||||
"此内核不支持添加工作区。": "This kernel does not support adding workspaces.",
|
||||
"先打开一个项目": "Open a project first",
|
||||
"读取代码、运行测试与修改文件均只在你选定的文件夹内进行。": "Reading code, running tests and editing files all happen inside the folder you choose.",
|
||||
"使用当前位置": "Stay where I am",
|
||||
@@ -333,6 +340,11 @@ export const EN: Record<string, string> = {
|
||||
"正在运行,暂停后才能关闭": "Running; pause it before closing",
|
||||
"受阻": "blocked",
|
||||
"状态不明": "uncertain",
|
||||
"这条插话在本轮结束前没来得及送达,没有发给模型": "This guidance was not delivered before the turn ended, so the model never saw it",
|
||||
"本轮已结束,但对话记录没能保存": "The turn ended, but the transcript could not be saved",
|
||||
"本轮已结束,但队列没能确认这一条": "The turn ended, but the queue could not acknowledge this item",
|
||||
"运行这一条的会话已不在了": "The session that was running this item is gone",
|
||||
"队列记录损坏,这一条是从残留文件里找回的": "The queue record was corrupt; this item was salvaged from leftover files",
|
||||
"冻结 {n} 文件": "{n} frozen",
|
||||
"无法读取该条的正文,未打开编辑:{why}":
|
||||
"Could not read this entry back, so it was not opened for editing: {why}",
|
||||
@@ -1092,6 +1104,8 @@ export const EN: Record<string, string> = {
|
||||
"项目操作:{name}": "Project actions: {name}",
|
||||
"项目操作": "Project actions",
|
||||
"不删除文件": "Keeps files",
|
||||
"在文件管理器中显示": "Show in file manager",
|
||||
"文件夹已不在磁盘上": "Folder is gone",
|
||||
"删除会话:{title}": "Delete session: {title}",
|
||||
"会话在文件夹里打开,先添加一个": "A session opens in a folder; add one first",
|
||||
"添加文件夹": "Add folder",
|
||||
@@ -1215,5 +1229,7 @@ export const EN: Record<string, string> = {
|
||||
"同一段内容被逐字重复": "The same block is being repeated verbatim",
|
||||
"任务已按设置暂停:模型卡在重复输出同一段文字": "Paused by your setting: the model is stuck repeating the same text",
|
||||
"等待你的输入": "Waiting for you",
|
||||
"引导没有生效:这一轮在处理它之前就结束了。如果仍然需要,请再发送一次:": "Guidance was not applied because the turn ended before it could be processed. Send it again if it is still needed:",
|
||||
"上下文已用到压缩阈值的 {percent}%,已告知模型约剩 {remaining} 个词元的空间。": "Context at {percent}% of the compaction threshold — the model was told it has about {remaining} tokens of room left.",
|
||||
"模型卡在重复输出同一段文字,这一轮已停止;可以重试、补充引导,或换一个供应商/模型": "The model is stuck repeating the same text; this turn was stopped. Try again, add guidance, or switch provider/model.",
|
||||
};
|
||||
@@ -0,0 +1,20 @@
|
||||
export const EN_COMMUNITY: Record<string, string> = {
|
||||
"社区与贡献者": "Community and contributors",
|
||||
"遇到问题、想交流用法或想参与进来,可以加入社区;下面是为 Reasonix 提交过代码的人。": "Join the community for help, tips and ideas. Below are the people who have contributed code to Reasonix.",
|
||||
"QQ 群二维码": "QQ group QR code",
|
||||
"群号": "Group number",
|
||||
"复制群号": "Copy group number",
|
||||
"用 QQ 扫描二维码,或点击下方按钮加入。": "Scan the QR code with QQ, or use the button below to join.",
|
||||
"加入 QQ 群": "Join the QQ group",
|
||||
"贡献者": "Contributors",
|
||||
"按提交数排序,名单随版本更新": "Sorted by commits; the list is refreshed with each release",
|
||||
"查看全部贡献者": "View all contributors",
|
||||
"显示全部 {n} 位": "Show all {n}",
|
||||
"抖音": "Douyin",
|
||||
"抖音号": "Douyin ID",
|
||||
"复制抖音号": "Copy Douyin ID",
|
||||
"显示二维码": "Show QR code",
|
||||
"收起二维码": "Hide QR code",
|
||||
"抖音二维码": "Douyin QR code",
|
||||
"作者与维护者": "Author and maintainer",
|
||||
};
|
||||
@@ -21,6 +21,7 @@ export const EN_COMPOSER: Record<string, string> = {
|
||||
"正在加载命令与技能…": "Loading commands and skills…",
|
||||
"引用文件": "Reference files",
|
||||
"Enter 插话 · Shift+Enter 换行": "Enter to steer · Shift+Enter for a new line",
|
||||
"点按插话 · 回车换行": "Tap Steer · Enter for a new line",
|
||||
"{n} 字 · {lines} 行": "{n} characters · {lines} lines",
|
||||
"知道了": "Got it",
|
||||
"是否信任此文件夹": "Trust this folder",
|
||||
@@ -66,6 +67,7 @@ export const EN_COMPOSER: Record<string, string> = {
|
||||
"Nobody has said whether this model reads images · treated as no for now; tick it under Connection to send them straight through",
|
||||
"自动": "Auto",
|
||||
"Enter 发送 · Shift+Enter 换行": "Enter to send · Shift+Enter for a new line",
|
||||
"点按发送 · 回车换行": "Tap Send · Enter for a new line",
|
||||
"不询问": "Do not ask",
|
||||
"全部放行": "Allow all",
|
||||
"均衡": "Balanced",
|
||||
|
||||
@@ -97,7 +97,8 @@ export const EN_FEEDBACK: Record<string, string> = {
|
||||
"有一张截图没能清除其中的隐藏信息。请换一张,或重新导出后再添加。": "A screenshot could not be cleaned of its hidden metadata. Pick another one, or re-export it and add it again.",
|
||||
"反馈通道暂时关闭。你可以直接到 GitHub 提交问题。": "The feedback channel is switched off for now. You can file an issue on GitHub directly.",
|
||||
"反馈服务没有认出这台电脑,请稍后重试。": "The feedback service did not recognise this computer. Please try again later.",
|
||||
"反馈服务暂时出了问题,不是你的操作有误。稍后再试即可。": "The feedback service is having trouble; this is not something you did. Try again later.",
|
||||
"反馈服务暂时出了问题。已填的内容都还在,请稍等片刻再试,也可以直接到 GitHub 提交问题。": "The feedback service is having trouble. What you typed is kept. Try again in a moment, or file an issue on GitHub directly.",
|
||||
"反馈服务暂时出了问题。你写的回复还在,请稍等片刻;重发前先刷新列表确认是否送达,也可以直接到 GitHub 提交问题。": "The feedback service is having trouble. Your reply is kept. Wait a moment, then refresh the list to check whether it arrived before resending, or file an issue on GitHub directly.",
|
||||
"本机保存反馈记录时出错,请重试。": "Saving the feedback record on this machine failed. Please try again.",
|
||||
"反馈没有发出去,请稍后重试。": "The feedback was not sent. Please try again later.",
|
||||
"需要补充信息": "Needs more info",
|
||||
|
||||
@@ -133,7 +133,7 @@ export const EN_KERNEL: Record<string, string> = {
|
||||
"任务正在运行,请先停止再重载扩展": "A task is running — stop it before reloading extensions",
|
||||
"该来源正在使用中,请先切换模型再删除": "This source is in use — switch models before deleting it",
|
||||
"请为该来源填写名称": "Give this source a name",
|
||||
"名称只能包含字母、数字、点、连字符和下划线": "A name may use letters, digits, dot, dash and underscore only",
|
||||
"名称只能用字母、数字、点、连字符和下划线,以字母或数字开头,最长 64 个字符": "Use only letters, digits, dots, hyphens and underscores, starting with a letter or digit, up to 64 characters",
|
||||
"已经有名为「{name}」的连接了,换一个名称": "A connection named “{name}” already exists; pick another name",
|
||||
"读不到配置文件,没法安全地选择密钥存放位置,请检查配置后重试": "The configuration could not be read, so there is no safe place to store the key; check the config and try again",
|
||||
"请填写接口地址": "Enter an endpoint address",
|
||||
@@ -163,6 +163,8 @@ export const EN_KERNEL: Record<string, string> = {
|
||||
"无法在 {ip} 上开启监听:{error}": "Could not listen on {ip}: {error}",
|
||||
"没有这台已配对的设备,可能已被移除。": "No paired device has that id; it may already have been removed.",
|
||||
"这个内核没有窗口,打不开本机的编辑器。": "This kernel has no window, so it cannot open an editor on this machine.",
|
||||
"这个文件夹不在当前窗口的项目列表里。": "That folder is not a project in this window.",
|
||||
"项目文件夹已不在磁盘上。": "The project folder is no longer on disk.",
|
||||
"这个内核不在本机,没法在系统文件管理器中显示它的文件。": "This kernel is not on this machine, so its files cannot be shown in the system file manager.",
|
||||
"这个系统没有可用的文件夹选择框,请直接填写路径。": "This system has no folder picker available; type the path instead.",
|
||||
"打不开文件夹选择框:{error}": "The folder picker could not be opened: {error}",
|
||||
@@ -172,6 +174,16 @@ export const EN_KERNEL: Record<string, string> = {
|
||||
"该 key 长度异常,可能粘贴了错误内容": "That key is too long — something else was probably pasted",
|
||||
"已连接,无需重复配置": "Already connected — no need to set it up again",
|
||||
"远端配置未完成,请稍后重试": "The remote setup did not complete; try again shortly",
|
||||
"没有这个模型连接": "There is no such model connection",
|
||||
"该连接没有可存放密钥的变量,请先在配置中为它指定 api_key_env": "This connection has no variable to store a key in — set its api_key_env in the configuration first",
|
||||
"该 key 超长或含换行,请重新复制": "That key is too long or spans several lines — copy it again",
|
||||
"已保存的密钥在此期间被改动,请重新打开配置再试": "The stored keys changed in the meantime — reopen the setup and try again",
|
||||
"密钥已保存,但连接尚未生效,请重试": "The key was saved but the connection is not active yet — try again",
|
||||
"服务商拒绝了该 key,请检查是否复制完整": "The provider rejected that key — check it was copied in full",
|
||||
"服务商没有及时应答,请稍后重试": "The provider did not answer in time — try again shortly",
|
||||
"连不上服务商,请检查网络或服务地址": "Cannot reach the provider — check the network or the service address",
|
||||
"服务商返回了错误,请稍后重试": "The provider returned an error — try again shortly",
|
||||
"连接测试未通过": "The connection test did not pass",
|
||||
"这台服务器已关闭会话切换": "This server has session switching turned off",
|
||||
"该会话正在清理,请稍后再打开": "This session is being cleaned up; open it again in a moment",
|
||||
"该会话正被其他位置占用,请先关闭该处": "This session is held elsewhere — close it there first",
|
||||
@@ -242,6 +254,7 @@ export const EN_KERNEL: Record<string, string> = {
|
||||
"这份备份已不存在": "That backup no longer exists",
|
||||
"备份数量已达上限,请先删除一份旧备份": "The backup limit is reached; delete an older backup first",
|
||||
"有会在本机运行程序或改变密钥去向的项目,需要逐项确认后才能恢复": "Some items run programs here or change where a key is sent; confirm each one before restoring",
|
||||
"项目列表已满(32 个),请先移除一个项目再添加": "The project list is full (32 projects); remove one before adding another",
|
||||
"这台服务器不支持切换工作区": "This server cannot switch workspaces",
|
||||
"不存在该预设": "There is no such preset",
|
||||
"本次拖入 {count} 个,最多允许 {limit} 个": "{count} dropped at once — the limit is {limit}",
|
||||
|
||||
@@ -346,6 +346,7 @@ export const EN_SETTINGS: Record<string, string> = {
|
||||
"Use this after editing extension code, or installing, removing, enabling or disabling a package. The current turn is unaffected; the next one uses the new configuration.",
|
||||
"正在重启常驻进程,重新扫描技能、命令和钩子…": "Restarting resident processes and rescanning skills, commands and hooks…",
|
||||
"已生效,下一轮开始用新的扩展": "Live — the next turn runs the new extensions",
|
||||
"更改已保存,运行时未重载:{reason}。请用「重载运行时」重试。": "Changes saved, but the runtime did not reload: {reason}. Use Reload runtime to retry.",
|
||||
"插件包": "Plugin packages",
|
||||
"一个包可以同时提供技能、命令、自动化钩子和外部服务。安装与导入是同一个操作:提供一个仓库地址,或本机的一个文件夹。":
|
||||
"One package can bring skills, commands, automation hooks and external services at once. Installing and importing are the same action: give it a repository address or a folder on this machine.",
|
||||
@@ -431,6 +432,14 @@ export const EN_SETTINGS: Record<string, string> = {
|
||||
"上次填写未通过:{note}": "The last answer was not accepted: {note}",
|
||||
"填写这一项": "Fill this in",
|
||||
"已提交给 {name}": "Sent to {name}",
|
||||
"MCP 服务器 {name} 请求外部操作": "MCP server {name} is asking for an external interaction",
|
||||
"请在自己的浏览器中完成操作,完成后再继续。打开页面不会自动确认,也不会把浏览器内容交给 agent。": "Complete the interaction in your own browser, then continue. Opening the page does not confirm completion or share its contents with the agent.",
|
||||
"此链接指向本机或私有网络。打开前请确认你信任该服务器和目标地址。": "This link targets this device or a private network. Open it only if you trust the server and the destination.",
|
||||
"请核对完整地址:此链接使用 HTTP 或国际化域名。": "Check the full address: this link uses HTTP or an internationalized domain.",
|
||||
"无法打开浏览器。请重试,或手动打开上面的地址。": "Could not open the browser. Retry, or open the address above manually.",
|
||||
"已确认完成": "Completion confirmed",
|
||||
"打开浏览器": "Open browser",
|
||||
"已完成,继续": "Completed, continue",
|
||||
"已拒绝提供": "Declined",
|
||||
"拒绝提供": "Decline",
|
||||
"(补写过一次)": " (repaired once)",
|
||||
@@ -473,6 +482,7 @@ export const EN_SETTINGS: Record<string, string> = {
|
||||
" · 缺 key": " · no key",
|
||||
"安装这个版本": "Install this version",
|
||||
"保存中…": "Saving…",
|
||||
"无法保存": "Could not save",
|
||||
"不发送": "Do not send",
|
||||
"当前版本": "Current version",
|
||||
"读取中…": "Reading…",
|
||||
@@ -617,8 +627,11 @@ export const EN_SETTINGS: Record<string, string> = {
|
||||
"在浏览器里打开的页面输入这串代码。没自动打开就手动访问 {uri}":
|
||||
"Type this code into the page your browser opened. If none opened, go to {uri}",
|
||||
"装好了,下一轮就能用": "Installed — the next turn can use it",
|
||||
"装好了,但这一轮还在跑:等它结束或新建会话后生效":
|
||||
"Installed, but this turn is still running: it takes effect when the turn ends, or in a new session",
|
||||
"装好了,但运行时未重载:{reason}。请用「重载运行时」重试。":
|
||||
"Installed, but the runtime did not reload: {reason}. Use Reload runtime to retry.",
|
||||
"运行时未重载:{reason}。请用「重载运行时」重试。":
|
||||
"The runtime did not reload: {reason}. Use Reload runtime to retry.",
|
||||
"有项目未安装成功,原因见下方。": "Not all items were installed. See the failures below.",
|
||||
"没装上": "Not installed",
|
||||
"这个地址上已经有「{name}」了。留空 key": "This address already holds “{name}”. Leave the key blank",
|
||||
"已就位 · {n} 个工具,下一轮就能用": "In place · {n} tools, ready next turn",
|
||||
|
||||
@@ -21,8 +21,8 @@ export const EN_STORAGE: Record<string, string> = {
|
||||
"目录过大,已统计到时间上限,实际占用不小于此数": "This folder is too large to finish measuring in time; the real size is at least this much.",
|
||||
占用: "Space used",
|
||||
原位置仍有残留数据: "Data remains in the previous location",
|
||||
"以下内容仍位于 {dir}:{names}。迁移存储位置时未一并迁移,因此本机的壁纸、主题包或更新回滚备份可能显示为缺失。手动将这些目录复制到当前位置即可恢复。":
|
||||
"These are still in {dir}: {names}. A move left them behind, so a wallpaper, a theme pack or the backups an update rolls back to can look gone on this machine. Copying those folders into the current location restores them.",
|
||||
"以下内容仍位于 {dir}:{names}。迁移存储位置时未一并迁移,因此项目列表、全局指令与记忆、计划任务、壁纸、主题包或更新回滚备份可能显示为缺失。下次启动会自动并入当前位置;若仍显示,请退出后把这些文件或目录复制到当前位置,已有的文件不要覆盖。":
|
||||
"These are still in {dir}: {names}. A move left them behind, so the project list, global instructions and memory, scheduled runs, a wallpaper, a theme pack or the backups an update rolls back to can look gone on this machine. The next launch merges them into the current location; if they are still listed, quit and copy them there without overwriting files that already exist.",
|
||||
位置: "Locations",
|
||||
迁移: "Migrate",
|
||||
"迁移未能启动。": "The move could not be started.",
|
||||
|
||||
@@ -14,6 +14,12 @@ export const EN_WINDOW: Record<string, string> = {
|
||||
"运行详情": "Runtime details",
|
||||
"查看全部指标": "View all metrics",
|
||||
"子代理": "Subagents",
|
||||
"查看完整记录": "Open full transcript",
|
||||
"查看完整记录:{name}": "Open full transcript: {name}",
|
||||
"子代理完整记录:{name}": "Full transcript of subagent: {name}",
|
||||
"还没有记录到步骤": "No steps recorded yet",
|
||||
"显示全部": "Show all",
|
||||
"输出过长,仅显示前 200000 个字符": "Output too long, showing the first 200000 characters",
|
||||
"成本构成": "Cost breakdown",
|
||||
"计划待确认": "The plan is yours to decide",
|
||||
"计划已生成,如何执行由你决定。": "The plan is written. What happens next is your call.",
|
||||
|
||||
@@ -31,6 +31,14 @@ describe("what a reader is told a refusal was", () => {
|
||||
// The gate the codes were for. The kernel rewords its own English whenever a
|
||||
// sentence reads better in a log; if that moves what a reader sees, wording
|
||||
// authority never actually left the kernel.
|
||||
it("explains the project limit in Chinese and English", () => {
|
||||
const error = coded("storage limit", "workspace.limit_reached");
|
||||
expect(reason(error)).toBe("项目列表已满(32 个),请先移除一个项目再添加");
|
||||
localStorage.setItem(STORAGE, "en");
|
||||
boot();
|
||||
expect(reason(error)).toBe("The project list is full (32 projects); remove one before adding another");
|
||||
});
|
||||
|
||||
it("does not move when the kernel rewords the same code", () => {
|
||||
const before = reason(coded("inbox item not found", "inbox.not_found"));
|
||||
const after = reason(coded("no such entry in this session's inbox", "inbox.not_found"));
|
||||
|
||||
@@ -42,7 +42,7 @@ const SAID: Record<string, string> = {
|
||||
|
||||
// ── 来源:填错了什么 ─────────────────────────────────────────────
|
||||
"provider.name_required": "请为该来源填写名称",
|
||||
"provider.name_invalid": "名称只能包含字母、数字、点、连字符和下划线",
|
||||
"provider.name_invalid": "名称只能用字母、数字、点、连字符和下划线,以字母或数字开头,最长 64 个字符",
|
||||
"provider.name_taken": "已经有名为「{name}」的连接了,换一个名称",
|
||||
"provider.config_unreadable": "读不到配置文件,没法安全地选择密钥存放位置,请检查配置后重试",
|
||||
"provider.endpoint_required": "请填写接口地址",
|
||||
@@ -104,6 +104,7 @@ const SAID: Record<string, string> = {
|
||||
"backup.not_found": "这份备份已不存在",
|
||||
"backup.limit_reached": "备份数量已达上限,请先删除一份旧备份",
|
||||
"backup.consent_required": "有会在本机运行程序或改变密钥去向的项目,需要逐项确认后才能恢复",
|
||||
"workspace.limit_reached": "项目列表已满(32 个),请先移除一个项目再添加",
|
||||
"workspace.changing_disabled": "这台服务器不支持切换工作区",
|
||||
"settings.unknown_preset": "不存在该预设",
|
||||
"drop.too_many_paths": "本次拖入 {count} 个,最多允许 {limit} 个",
|
||||
@@ -136,6 +137,8 @@ const SAID: Record<string, string> = {
|
||||
"editor.not_installed": "这台机器上没找到 VS Code、Cursor 这类编辑器。装一个,或在配置里用 [desktop] editor 指定路径。",
|
||||
"editor.launch_failed": "编辑器没能启动:{error}",
|
||||
"editor.no_window": "这个内核没有窗口,打不开本机的编辑器。",
|
||||
"workspace.not_listed": "这个文件夹不在当前窗口的项目列表里。",
|
||||
"workspace.folder_missing": "项目文件夹已不在磁盘上。",
|
||||
"workspace.locate_no_window": "这个内核不在本机,没法在系统文件管理器中显示它的文件。",
|
||||
"device.host_only": "这项操作只能在电脑上的窗口里做,已配对的手机做不了。",
|
||||
"device.host_rejected": "这个地址不是本机共享的地址,请重新扫码。",
|
||||
@@ -157,6 +160,16 @@ const SAID: Record<string, string> = {
|
||||
"provider.key_too_large": "该 key 长度异常,可能粘贴了错误内容",
|
||||
"provider.setup_done": "已连接,无需重复配置",
|
||||
"provider.setup_failed": "远端配置未完成,请稍后重试",
|
||||
"provider.unknown": "没有这个模型连接",
|
||||
"provider.no_key_slot": "该连接没有可存放密钥的变量,请先在配置中为它指定 api_key_env",
|
||||
"provider.key_invalid": "该 key 超长或含换行,请重新复制",
|
||||
"provider.credentials_changed": "已保存的密钥在此期间被改动,请重新打开配置再试",
|
||||
"provider.activation_failed": "密钥已保存,但连接尚未生效,请重试",
|
||||
"provider.test_auth": "服务商拒绝了该 key,请检查是否复制完整",
|
||||
"provider.test_timeout": "服务商没有及时应答,请稍后重试",
|
||||
"provider.test_unreachable": "连不上服务商,请检查网络或服务地址",
|
||||
"provider.test_upstream": "服务商返回了错误,请稍后重试",
|
||||
"provider.test_failed": "连接测试未通过",
|
||||
|
||||
"memory.unavailable": "该会话未启用记忆",
|
||||
|
||||
|
||||
@@ -16,6 +16,9 @@ export const NOTICE_TEXT: Record<string, string> = {
|
||||
project_program_changed: "这个项目的程序在批准后被改动过,本次没有运行,需要重新批准",
|
||||
suspected_injection: "一条外部内容看起来在向智能体下指令,已提醒它只当资料看待",
|
||||
await_user: "等待你的输入",
|
||||
default_model_unavailable: "配置里保存的默认模型已不在已配置的供应商中,本次改用第一个可用的模型;在设置里重新选择默认模型即可替换,配置文件未被改动",
|
||||
approval_mode_unrecognized: "配置里的默认审批档位本版本不认识,已按每次询问处理;在界面里选一个档位即可替换",
|
||||
unapplied_steer: "引导没有生效:这一轮在处理它之前就结束了。如果仍然需要,请再发送一次:",
|
||||
context_budget: "上下文已用到压缩阈值的 {percent}%,已告知模型约剩 {remaining} 个词元的空间。",
|
||||
perseveration_loop: "模型卡在重复输出同一段文字,这一轮已停止;可以重试、补充引导,或换一个供应商/模型",
|
||||
};
|
||||
@@ -0,0 +1,9 @@
|
||||
// Why a held queue entry stopped, keyed by the kernel's block code. A code with
|
||||
// no entry shows the kernel's own diagnostic.
|
||||
export const BLOCK_WHY: Record<string, string> = {
|
||||
steer_unapplied: "这条插话在本轮结束前没来得及送达,没有发给模型",
|
||||
turn_snapshot_failed: "本轮已结束,但对话记录没能保存",
|
||||
turn_ack_failed: "本轮已结束,但队列没能确认这一条",
|
||||
owner_inactive: "运行这一条的会话已不在了",
|
||||
manifest_salvaged: "队列记录损坏,这一条是从残留文件里找回的",
|
||||
};
|
||||
@@ -7,6 +7,7 @@ import "./styles/tokens.css";
|
||||
import "./styles/app.css";
|
||||
import "./styles/studio.css";
|
||||
import "./styles/feedback.css";
|
||||
import "./styles/community.css";
|
||||
import { App } from "./ui/App";
|
||||
import { SseHub } from "./port/hub";
|
||||
import { HttpError } from "./port/http_error";
|
||||
|
||||
@@ -19,7 +19,7 @@ export interface HostPort {
|
||||
toggleMaximiseWindow(): void;
|
||||
isWindowMaximised(): Promise<boolean>;
|
||||
closeWindow(): void;
|
||||
openExternal(url: string): void;
|
||||
openExternal(url: string): void | Promise<void>;
|
||||
/** Where dropped files live. Empty where the shell cannot say — a browser
|
||||
* tab never learns a path. */
|
||||
pathsForFiles(files: File[]): string[];
|
||||
@@ -38,6 +38,9 @@ export interface HostPort {
|
||||
* root. The shell asks the kernel where that is and selects only the answer;
|
||||
* null once shown, otherwise why it was not. */
|
||||
revealPath(base: string, path: string): Promise<Refusal | null>;
|
||||
/** Show a project the sidebar lists, which needs no pane. The hub answers
|
||||
* where it is and refuses any folder it does not list. */
|
||||
revealWorkspace(root: string): Promise<Refusal | null>;
|
||||
/** Whether this shell draws the agent's browser pages inside the window. */
|
||||
drawsBrowserViews(): boolean;
|
||||
/** Draw one of the agent's pages over rect, in on-screen coordinates, and
|
||||
@@ -127,6 +130,7 @@ interface ElectronBridge {
|
||||
saveBytes(name: string, bytes: Uint8Array): Promise<string>;
|
||||
pickFolder(startIn: string): Promise<string>;
|
||||
revealPath?(base: string, path: string): Promise<Refusal | null>;
|
||||
revealWorkspace?(root: string): Promise<Refusal | null>;
|
||||
showBrowserView?(target: string, rect: ViewRect): Promise<void>;
|
||||
hideBrowserView?(): Promise<void>;
|
||||
freezeBrowserView?(): Promise<string>;
|
||||
@@ -169,7 +173,7 @@ class ElectronHost implements HostPort {
|
||||
void this.api.closeWindow();
|
||||
}
|
||||
openExternal(url: string) {
|
||||
void this.api.openExternal(url);
|
||||
return this.api.openExternal(url);
|
||||
}
|
||||
pathsForFiles(files: File[]) {
|
||||
// Resolved one at a time because that is the shape the platform offers;
|
||||
@@ -186,11 +190,14 @@ class ElectronHost implements HostPort {
|
||||
return this.api.pickFolder(startIn);
|
||||
}
|
||||
revealsFiles() {
|
||||
return typeof this.api.revealPath === "function";
|
||||
return typeof this.api.revealPath === "function" && typeof this.api.revealWorkspace === "function";
|
||||
}
|
||||
revealPath(base: string, path: string) {
|
||||
return this.api.revealPath?.(base, path) ?? Promise.resolve({ error: "this shell cannot show files" });
|
||||
}
|
||||
revealWorkspace(root: string) {
|
||||
return this.api.revealWorkspace?.(root) ?? Promise.resolve({ error: "this shell cannot show files" });
|
||||
}
|
||||
// A shell older than the verbs has no views to draw, and says so by lacking them.
|
||||
drawsBrowserViews() {
|
||||
return typeof this.api.showBrowserView === "function";
|
||||
@@ -257,6 +264,9 @@ class BrowserHost implements HostPort {
|
||||
revealPath() {
|
||||
return Promise.resolve({ error: "a browser tab cannot show files" });
|
||||
}
|
||||
revealWorkspace() {
|
||||
return Promise.resolve({ error: "a browser tab cannot show files" });
|
||||
}
|
||||
drawsBrowserViews() {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import { afterEach, expect, it, vi } from "vitest";
|
||||
|
||||
async function hostOver(bridge: Record<string, unknown>) {
|
||||
vi.resetModules();
|
||||
vi.stubGlobal("window", { reasonixHost: { shell: "electron", platform: "darwin", titleBar: false, ...bridge } });
|
||||
return (await import("./host")).host();
|
||||
}
|
||||
|
||||
afterEach(() => vi.unstubAllGlobals());
|
||||
|
||||
it("offers reveal only when the shell can show both a pane's file and a project", async () => {
|
||||
expect((await hostOver({ revealPath: async () => null, revealWorkspace: async () => null })).revealsFiles()).toBe(true);
|
||||
expect((await hostOver({ revealPath: async () => null })).revealsFiles()).toBe(false);
|
||||
});
|
||||
@@ -1,3 +1,4 @@
|
||||
// @vitest-environment jsdom
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { SseHub } from "./hub";
|
||||
import { HttpError } from "./port";
|
||||
@@ -47,3 +48,29 @@ describe("a failed hub call", () => {
|
||||
expect(err.detailed).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
function jsonAnswer(status: number, body: unknown) {
|
||||
return {
|
||||
ok: status >= 200 && status < 300,
|
||||
status,
|
||||
text: async () => JSON.stringify(body),
|
||||
json: async () => body,
|
||||
headers: new Map([["content-type", "application/json"]]),
|
||||
};
|
||||
}
|
||||
|
||||
describe("host capabilities", () => {
|
||||
it("does not ask a kernel picker the capability probe says is absent", async () => {
|
||||
const calls: Array<[string, string]> = [];
|
||||
vi.stubGlobal("fetch", async (path: string, init?: RequestInit) => {
|
||||
calls.push([init?.method ?? "GET", path]);
|
||||
if (path === "/host/capabilities") {
|
||||
return jsonAnswer(200, { pickFolder: false, addWorkspace: true });
|
||||
}
|
||||
return answer(501, JSON.stringify({ code: "picker.unsupported", error: "native folder picker is unavailable" }));
|
||||
});
|
||||
|
||||
expect(await new SseHub().hostCapabilities()).toEqual({ pickFolder: false, addWorkspace: true });
|
||||
expect(calls).toEqual([["GET", "/host/capabilities"]]);
|
||||
});
|
||||
});
|
||||
@@ -2,6 +2,7 @@ import { HttpError, type AgentPort } from "./port";
|
||||
import type { RemoteAsk, RemoteHost, RemoteHostEdit, RemoteListing, RemoteProbe } from "./remote";
|
||||
import type { CloudShareOffer, DeviceSelf, ShareOffer, SharePort, ShareStatus } from "./share";
|
||||
import { SsePort } from "./sse";
|
||||
import { host } from "./host";
|
||||
|
||||
// How often a client waiting on a dial looks for the question it might be
|
||||
// stopped by. A person answers this one, so half a second is not the cost —
|
||||
@@ -48,6 +49,15 @@ export interface TreeSession {
|
||||
versions?: TreeSession[];
|
||||
}
|
||||
|
||||
export interface HostCapabilities {
|
||||
// The kernel can open a picker on its own machine. A browser on a headless
|
||||
// server gets false and must offer the path API instead.
|
||||
pickFolder: boolean;
|
||||
// The kernel exposes POST /tree/workspaces. This is what makes the fallback
|
||||
// meaningful rather than sending the reader back to the desktop shell.
|
||||
addWorkspace: boolean;
|
||||
}
|
||||
|
||||
export interface TreeWorkspace {
|
||||
root: string;
|
||||
name: string;
|
||||
@@ -71,6 +81,9 @@ export interface HubPort extends SharePort {
|
||||
tree(): Promise<TreeWorkspace[]>;
|
||||
addWorkspace(path: string): Promise<TreeWorkspace>;
|
||||
removeWorkspace(path: string): Promise<void>;
|
||||
/** Show a listed project's folder in the system file manager. */
|
||||
revealWorkspace(path: string): Promise<void>;
|
||||
moveWorkspace(path: string, direction: -1 | 1): Promise<void>;
|
||||
removeSession(path: string): Promise<void>;
|
||||
archiveSession(path: string, archived: boolean): Promise<void>;
|
||||
renameSession(path: string, title: string): Promise<void>;
|
||||
@@ -111,6 +124,10 @@ export interface HubPort extends SharePort {
|
||||
// kernel that would ask is the one this build cannot reach.
|
||||
onRemoteAsk(cb: (ask: RemoteAsk) => void): () => void;
|
||||
answerRemote(id: string, ok: boolean, text: string): void;
|
||||
// What the kernel can do independently of the desktop window. It is a
|
||||
// separate read so the page can choose a usable fallback before invoking a
|
||||
// host-only action.
|
||||
hostCapabilities(): Promise<HostCapabilities>;
|
||||
pickFolder(): Promise<string | null>;
|
||||
// Absolute paths of every file dropped anywhere on the window. It belongs
|
||||
// here rather than on a pane because the window has one of it: the shell
|
||||
@@ -200,10 +217,19 @@ export class SseHub implements HubPort {
|
||||
return this.post<TreeWorkspace>("/tree/workspaces", { path });
|
||||
}
|
||||
|
||||
async revealWorkspace(path: string) {
|
||||
const why = await host().revealWorkspace(path);
|
||||
if (why) throw new HttpError(0, why.error || "not shown", why, !!(why.code || why.error));
|
||||
}
|
||||
|
||||
async removeWorkspace(path: string) {
|
||||
await this.post<void>("/tree/workspaces/remove", { path });
|
||||
}
|
||||
|
||||
async moveWorkspace(path: string, direction: -1 | 1) {
|
||||
await this.post<void>("/tree/workspaces/move", { path, direction });
|
||||
}
|
||||
|
||||
async removeSession(path: string) {
|
||||
await this.post<void>("/tree/sessions/remove", { path });
|
||||
}
|
||||
@@ -346,6 +372,30 @@ export class SseHub implements HubPort {
|
||||
}
|
||||
}
|
||||
|
||||
async hostCapabilities() {
|
||||
const shellCanPick = await host()
|
||||
.describe()
|
||||
.then((info) => info.shell === "electron")
|
||||
.catch(() => false);
|
||||
|
||||
const res = await fetch("/host/capabilities", { credentials: "same-origin" });
|
||||
// A kernel predating this probe has no route and keeps the old picker
|
||||
// attempt; the caller already treats null as "no picker" safely.
|
||||
if (res.status === 404 || res.status === 501 || res.status === 503) {
|
||||
return { pickFolder: true, addWorkspace: true };
|
||||
}
|
||||
if (!res.ok) await SseHub.fail("/host/capabilities", res);
|
||||
|
||||
const body = (await res.json().catch(() => null)) as Partial<HostCapabilities> | null;
|
||||
if (typeof body?.pickFolder !== "boolean" || typeof body.addWorkspace !== "boolean") {
|
||||
return { pickFolder: true, addWorkspace: true };
|
||||
}
|
||||
return {
|
||||
pickFolder: shellCanPick || body.pickFolder,
|
||||
addWorkspace: body.addWorkspace,
|
||||
};
|
||||
}
|
||||
|
||||
async pickFolder() {
|
||||
const shellPath = await this.shell.pickFolder();
|
||||
if (shellPath !== null) return shellPath;
|
||||
|
||||
@@ -670,7 +670,6 @@ export class MockPort extends MockFeedback implements AgentPort {
|
||||
async retryQueued(itemId: string) {
|
||||
void itemId;
|
||||
}
|
||||
|
||||
async refreshQueued(itemId: string) {
|
||||
void itemId;
|
||||
}
|
||||
@@ -691,6 +690,7 @@ export class MockPort extends MockFeedback implements AgentPort {
|
||||
if (at === undefined) throw new HttpError(409, "already applied", { code: "steer.already_applied" });
|
||||
window.clearTimeout(at);
|
||||
this.queued.delete(itemId);
|
||||
this.dropQueued(itemId);
|
||||
}
|
||||
|
||||
async submit(text: string) {
|
||||
|
||||
@@ -182,12 +182,23 @@ export class MockHub implements HubPort {
|
||||
return Promise.resolve<TreeWorkspace>({ root: path, name: path.split("/").pop() ?? path, remembered: true, sessions: [] });
|
||||
}
|
||||
|
||||
async revealWorkspace() {}
|
||||
|
||||
removeWorkspace(path: string) {
|
||||
const at = this.roots.indexOf(path);
|
||||
if (at >= 0) this.roots.splice(at, 1);
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
moveWorkspace(path: string, direction: -1 | 1) {
|
||||
const at = this.roots.indexOf(path);
|
||||
const to = at + direction;
|
||||
if (at >= 0 && to >= 0 && to < this.roots.length) {
|
||||
[this.roots[at], this.roots[to]] = [this.roots[to], this.roots[at]];
|
||||
}
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
removeSession(_path: string) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
@@ -213,6 +224,10 @@ export class MockHub implements HubPort {
|
||||
return Promise.resolve({ summary: "mock migration complete", imported: 0, warnings: 0 });
|
||||
}
|
||||
|
||||
hostCapabilities() {
|
||||
return Promise.resolve({ pickFolder: true, addWorkspace: true });
|
||||
}
|
||||
|
||||
pickFolder() {
|
||||
return Promise.resolve<string | null>("~/projects/mock-workspace");
|
||||
}
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
// @vitest-environment jsdom
|
||||
import { afterEach, expect, it, vi } from "vitest";
|
||||
import { SsePort } from "./sse";
|
||||
import { SseExtensions } from "./sse_ext";
|
||||
import { HttpError } from "./http_error";
|
||||
import { host } from "./host";
|
||||
import { boot, STORAGE } from "../i18n";
|
||||
import { reason } from "../i18n/kernel";
|
||||
|
||||
afterEach(() => { vi.restoreAllMocks(); vi.unstubAllGlobals(); localStorage.clear(); });
|
||||
|
||||
const root = "/rt/project";
|
||||
const path = "/plugins/notes-kit/export";
|
||||
const callers = [
|
||||
["Studio", () => new SsePort(root)],
|
||||
["extension port", () => new SseExtensions(root)],
|
||||
] as const;
|
||||
const replies = [
|
||||
{ name: "export refusal", status: 422, body: { code: "plugin.export_failed", error: "archive cannot be written", params: { detail: "installed folder missing" } } },
|
||||
{ name: "unknown package", status: 404, body: { code: "plugin.not_installed", error: "that plugin is not installed" } },
|
||||
{ name: "plain text", status: 503, body: "export service unavailable" },
|
||||
{ name: "empty response", status: 502, body: "" },
|
||||
] as const;
|
||||
|
||||
it.each(callers.flatMap(([caller, make]) => replies.map((reply) => ({ caller, make, ...reply }))))(
|
||||
"keeps a $name from the $caller export endpoint", async ({ make, status, body }) => {
|
||||
const raw = typeof body === "string" ? body : JSON.stringify(body);
|
||||
const response = new Response(raw, { status });
|
||||
const fetch = vi.fn().mockResolvedValue(response);
|
||||
vi.stubGlobal("fetch", fetch);
|
||||
const save = vi.spyOn(host(), "saveBytes");
|
||||
const readArchive = vi.spyOn(response, "blob");
|
||||
const error = await make().exportPlugin("notes-kit").catch((e: unknown) => e);
|
||||
expect(error).toBeInstanceOf(HttpError);
|
||||
const typed = error as HttpError;
|
||||
expect(typed.status).toBe(status);
|
||||
expect(typed.message).toBe(typeof body === "string" ? body || path + ": " + status : body.error);
|
||||
expect(typed.reason).toEqual(typeof body === "string" ? undefined : body);
|
||||
expect(typed.detailed).toBe(raw !== "");
|
||||
expect(fetch).toHaveBeenCalledExactlyOnceWith(root + path, { credentials: "same-origin" });
|
||||
expect(readArchive).not.toHaveBeenCalled();
|
||||
expect(save).not.toHaveBeenCalled();
|
||||
expect(response.bodyUsed).toBe(true);
|
||||
},
|
||||
);
|
||||
|
||||
it.each(callers)("renders the %s export refusal with the kernel's detail", async (_caller, make) => {
|
||||
localStorage.setItem(STORAGE, "zh");
|
||||
boot();
|
||||
vi.stubGlobal("fetch", vi.fn().mockResolvedValue(new Response(JSON.stringify(replies[0].body), { status: 422 })));
|
||||
const error = await make().exportPlugin("notes-kit").catch((e: unknown) => e);
|
||||
expect(reason(error)).toBe("未能导出该插件:installed folder missing");
|
||||
});
|
||||
|
||||
const archive = new Uint8Array([80, 75, 3, 4]);
|
||||
|
||||
function exportResponse() {
|
||||
return new Response(archive, { headers: { "content-type": "application/zip", "X-Reasonix-Required-Env": "NOTES_TOKEN,DOCS_KEY" } });
|
||||
}
|
||||
|
||||
it.each(["/exports/notes-kit.zip", ""])("keeps the native archive save answer %j", async (savedTo) => {
|
||||
const response = exportResponse();
|
||||
vi.stubGlobal("fetch", vi.fn().mockResolvedValue(response));
|
||||
const save = vi.spyOn(host(), "saveBytes").mockResolvedValue(savedTo);
|
||||
const click = vi.spyOn(HTMLAnchorElement.prototype, "click").mockImplementation(() => {});
|
||||
expect(await new SsePort(root).exportPlugin("notes-kit")).toEqual({
|
||||
required: ["NOTES_TOKEN", "DOCS_KEY"], savedTo: savedTo || undefined,
|
||||
});
|
||||
expect(save).toHaveBeenCalledExactlyOnceWith("notes-kit.zip", archive);
|
||||
expect(click).not.toHaveBeenCalled();
|
||||
expect(response.bodyUsed).toBe(true);
|
||||
});
|
||||
|
||||
it.each(callers)("keeps the %s browser archive download", async (_caller, make) => {
|
||||
const response = exportResponse();
|
||||
vi.stubGlobal("fetch", vi.fn().mockResolvedValue(response));
|
||||
vi.spyOn(host(), "saveBytes").mockResolvedValue(null);
|
||||
const create = vi.fn().mockReturnValue("blob:notes-archive");
|
||||
const revoke = vi.fn();
|
||||
vi.stubGlobal("URL", class extends URL {
|
||||
static createObjectURL = create;
|
||||
static revokeObjectURL = revoke;
|
||||
});
|
||||
let clicked!: HTMLAnchorElement;
|
||||
const click = vi.spyOn(HTMLAnchorElement.prototype, "click").mockImplementation(function (this: HTMLAnchorElement) { clicked = this; });
|
||||
expect(await make().exportPlugin("notes-kit")).toEqual({ required: ["NOTES_TOKEN", "DOCS_KEY"] });
|
||||
expect(create).toHaveBeenCalledTimes(1);
|
||||
expect(new Uint8Array(await create.mock.calls[0][0].arrayBuffer())).toEqual(archive);
|
||||
expect(click).toHaveBeenCalledTimes(1);
|
||||
expect(clicked.href).toBe("blob:notes-archive");
|
||||
expect(clicked.download).toBe("notes-kit.zip");
|
||||
expect(revoke).toHaveBeenCalledExactlyOnceWith("blob:notes-archive");
|
||||
});
|
||||
@@ -115,6 +115,8 @@ export interface QueueItem {
|
||||
createdAt: string;
|
||||
// Why a blocked entry stopped, from the kernel that stopped it.
|
||||
blockReason?: string;
|
||||
// The stable identity of that stop; the sentence is worded from it here.
|
||||
blockCode?: string;
|
||||
// Files this entry froze at the moment it was queued. Their presence is what
|
||||
// makes re-freezing meaningful; the entry quotes them as they were.
|
||||
refs?: { path?: string }[];
|
||||
|
||||
@@ -69,7 +69,7 @@ export class SsePort extends SseFeedback implements AgentPort {
|
||||
const res = await fetch(this.base + "/plugins/" + encodeURIComponent(name) + "/export", {
|
||||
credentials: "same-origin",
|
||||
});
|
||||
if (!res.ok) throw new Error(`/plugins/${name}/export: ${res.status}`);
|
||||
if (!res.ok) await SsePort.fail(`/plugins/${encodeURIComponent(name)}/export`, res);
|
||||
const required = (res.headers.get("X-Reasonix-Required-Env") ?? "").split(",").filter(Boolean);
|
||||
const blob = await res.blob();
|
||||
// A shell with a save dialog puts the archive where it is asked to; the
|
||||
@@ -371,9 +371,8 @@ export class SsePort extends SseFeedback implements AgentPort {
|
||||
if (why) throw new HttpError(0, why.error || "not shown", why, !!(why.code || why.error));
|
||||
}
|
||||
|
||||
openExternal(url: string): Promise<void> {
|
||||
host().openExternal(url);
|
||||
return Promise.resolve();
|
||||
async openExternal(url: string): Promise<void> {
|
||||
await host().openExternal(url);
|
||||
}
|
||||
|
||||
async pickFolder(): Promise<string | null> {
|
||||
|
||||
@@ -88,7 +88,7 @@ export class SseExtensions extends SseLook {
|
||||
const res = await fetch(this.base + "/plugins/" + encodeURIComponent(name) + "/export", {
|
||||
credentials: "same-origin",
|
||||
});
|
||||
if (!res.ok) throw new Error(`/plugins/${name}/export: ${res.status}`);
|
||||
if (!res.ok) await SseExtensions.fail(`/plugins/${encodeURIComponent(name)}/export`, res);
|
||||
const required = (res.headers.get("X-Reasonix-Required-Env") ?? "").split(",").filter(Boolean);
|
||||
const url = URL.createObjectURL(await res.blob());
|
||||
const a = document.createElement("a");
|
||||
|
||||
@@ -281,6 +281,9 @@ export interface AskOrigin {
|
||||
source: string;
|
||||
message?: string;
|
||||
note?: string;
|
||||
url?: string;
|
||||
urlHost?: string;
|
||||
urlLocal?: boolean;
|
||||
}
|
||||
|
||||
export interface Ask {
|
||||
@@ -645,6 +648,7 @@ export type ExecutionGraphRead = ExecutionGraphView & ExecutionGraphSnapshot;
|
||||
|
||||
export interface WireEvent {
|
||||
kind: Kind;
|
||||
nonPersistable?: boolean;
|
||||
text?: string;
|
||||
detail?: string;
|
||||
code?: string;
|
||||
|
||||
@@ -12,11 +12,27 @@ import { nextId } from "./ids";
|
||||
// sealTurn tell the two apart.
|
||||
const merge = (prev: Tool, next: Tool): Tool => ({ ...prev, ...next, partial: next.partial ?? false });
|
||||
|
||||
// The kernel's reserved ToolProgress channels for a child's live preview. They
|
||||
// describe the child, never the call that spawned it, so they are kept apart
|
||||
// from the call's card: folding one in renames it and reopens it.
|
||||
export const SUBAGENT_PROGRESS_PREFIX = "reasonix.subagent.";
|
||||
export const isSubagentProgress = (name: string | undefined): boolean => !!name?.startsWith(SUBAGENT_PROGRESS_PREFIX);
|
||||
const TERMINAL = new Set(["completed", "failed", "cancelled"]);
|
||||
export function notePhase(phases: Record<string, string>, tool: Tool): Record<string, string> {
|
||||
if (tool.name !== SUBAGENT_PROGRESS_PREFIX + "status" || !tool.id) return phases;
|
||||
return { ...phases, [tool.id]: tool.output ?? "" };
|
||||
}
|
||||
/** A child is working until its tracker says otherwise. */
|
||||
export const subagentLive = (phase: string | undefined): boolean => !!phase && !TERMINAL.has(phase);
|
||||
|
||||
export function foldTool(items: Item[], tool: Tool, running: boolean): Item[] {
|
||||
// A subagent's calls carry parentId; they belong inside the task that spawned
|
||||
// them, not as siblings in the main flow.
|
||||
// them, not as siblings in the main flow. A fleet worker's parent is itself a
|
||||
// child card, so the owning card is the one that holds the parent as a child.
|
||||
if (tool.parentId) {
|
||||
const at = items.findIndex((i) => i.t === "tool" && i.tool.id === tool.parentId);
|
||||
const at = items.findIndex(
|
||||
(i) => i.t === "tool" && (i.tool.id === tool.parentId || i.children.some((c) => c.id === tool.parentId)),
|
||||
);
|
||||
if (at >= 0) {
|
||||
const parent = items[at] as Extract<Item, { t: "tool" }>;
|
||||
const kids = parent.children.slice();
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { Tool } from "../port/wire";
|
||||
import { foldTool } from "./fold";
|
||||
import type { Item } from "./session_types";
|
||||
|
||||
const tool = (id: string, name: string, parentId?: string): Tool => ({ id, name, parentId, readOnly: true });
|
||||
const run = (events: Tool[]): Item[] => events.reduce<Item[]>((items, t) => foldTool(items, t, true), []);
|
||||
|
||||
describe("a delegate's calls stay inside the card that spawned them", () => {
|
||||
it("nests a task's own call under the task", () => {
|
||||
const items = run([tool("task", "use_capability"), tool("task/ls", "ls", "task")]);
|
||||
expect(items).toHaveLength(1);
|
||||
expect(items[0]).toMatchObject({ t: "tool", children: [{ id: "task/ls" }] });
|
||||
});
|
||||
|
||||
it("keeps a fleet worker's call out of the main list", () => {
|
||||
const items = run([
|
||||
tool("fl", "use_capability"),
|
||||
tool("fl/fleet-1", "task", "fl"),
|
||||
tool("fl/fleet-1/read", "read_file", "fl/fleet-1"),
|
||||
]);
|
||||
expect(items).toHaveLength(1);
|
||||
expect(items[0]).toMatchObject({ t: "tool" });
|
||||
const kids = (items[0] as Extract<Item, { t: "tool" }>).children;
|
||||
expect(kids.map((c) => c.id)).toEqual(["fl/fleet-1", "fl/fleet-1/read"]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,16 @@
|
||||
import type { SessionState } from "./session_types";
|
||||
|
||||
// The kernel answers a queued line with the id it queued it under. The row is
|
||||
// already on screen by then: this gives it a name to be taken back by.
|
||||
export function nameQueued(s: SessionState, id: string, itemId: string, queued: "steer" | "followup"): SessionState {
|
||||
// Guidance read at a tool boundary can be reported before its receipt lands
|
||||
// here. That report drew the delivered line, so this row is its duplicate and
|
||||
// naming it would re-open a line that already arrived.
|
||||
if (s.items.some((i) => i.t === "user" && i.itemId === itemId && i.id !== id)) {
|
||||
return { ...s, items: s.items.filter((i) => !(i.t === "user" && i.id === id)) };
|
||||
}
|
||||
return {
|
||||
...s,
|
||||
items: s.items.map((i) => (i.t === "user" && i.id === id ? { ...i, itemId, queued, pending: true } : i)),
|
||||
};
|
||||
}
|
||||
@@ -93,6 +93,22 @@ describe("sealing a turn", () => {
|
||||
});
|
||||
});
|
||||
|
||||
// The kernel flags a turn the user's own action ended. Its err is the sentinel's
|
||||
// wording, which says nothing the flag does not, so it is not a failure card.
|
||||
describe("a turn the user cancelled", () => {
|
||||
it("does not draw the cancellation as an error card", () => {
|
||||
const s = run([partial("c1"), { kind: "turn_done", cancelled: true, err: "context canceled" } as SessionEvent]);
|
||||
expect(s.items.filter((i) => i.t === "notice" && i.level === "error")).toHaveLength(0);
|
||||
expect(s.terminal).toEqual({ kind: "cancelled" });
|
||||
expect(s.doing).toBe("已取消");
|
||||
});
|
||||
|
||||
it("still draws a genuine failure", () => {
|
||||
const s = run([{ kind: "turn_done", err: "provider down" } as SessionEvent]);
|
||||
expect(notices(s)).toEqual(["provider down"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("rebuilding a reopened transcript", () => {
|
||||
const users = (msgs: HistoryMessage[]) =>
|
||||
fromHistory(msgs).items.filter((i): i is Extract<Item, { t: "user" }> => i.t === "user");
|
||||
@@ -568,3 +584,21 @@ describe("which model wrote a reply", () => {
|
||||
expect(says(st)[0].model).toBe("deepseek/deepseek-pro");
|
||||
});
|
||||
});
|
||||
|
||||
describe("a receipt that arrives after the delivery it is for", () => {
|
||||
const sent = { kind: "__user", text: "换个思路", pending: true, id: "row-1" } as SessionEvent;
|
||||
const delivered = { kind: "steer", text: "换个思路", itemId: "it1" } as SessionEvent;
|
||||
const receipt = { kind: "__queued", id: "row-1", itemId: "it1", queued: "steer" } as SessionEvent;
|
||||
const users = (st: SessionState) => st.items.filter((i): i is Extract<Item, { t: "user" }> => i.t === "user");
|
||||
|
||||
it("leaves one delivered line and nothing waiting", () => {
|
||||
const rows = users(run([sent, delivered, receipt]));
|
||||
expect(rows).toHaveLength(1);
|
||||
expect(rows[0]).toMatchObject({ steer: true, itemId: "it1" });
|
||||
expect(rows.some((r) => r.pending)).toBe(false);
|
||||
});
|
||||
|
||||
it("still names a line the kernel has not delivered yet", () => {
|
||||
expect(users(run([sent, receipt]))[0]).toMatchObject({ pending: true, itemId: "it1" });
|
||||
});
|
||||
});
|
||||
@@ -19,7 +19,8 @@ import { nameTurnStart, othersSteer } from "./turn_start";
|
||||
import { appendText, foldMessage, sealSay } from "./say";
|
||||
import { nextId } from "./ids";
|
||||
import { foldStall } from "./stall";
|
||||
import { dropTool, foldLastRead, foldTool, mergeReads } from "./fold";
|
||||
import { nameQueued } from "./queued";
|
||||
import { dropTool, foldLastRead, foldTool, isSubagentProgress, mergeReads, notePhase } from "./fold";
|
||||
export { quoteAmount };
|
||||
export { setShowsReceipt, showsReceipt };
|
||||
|
||||
@@ -51,7 +52,7 @@ export const initialState: SessionState = {
|
||||
waiting: {},
|
||||
running: false,
|
||||
doing: "空闲",
|
||||
steerQueue: [],
|
||||
steerQueue: [], subagentPhase: {},
|
||||
awaitingTurnStart: [],
|
||||
queueMoved: 0,
|
||||
browserTabsMoved: 0,
|
||||
@@ -107,7 +108,8 @@ function sealTurn(items: Item[], err?: string): Item[] {
|
||||
}
|
||||
if (!err) return sealed;
|
||||
// The kernel's own words: classifying them here would be this file guessing
|
||||
// at failures it cannot see.
|
||||
// at failures it cannot see. A turn it flagged as cancelled never gets here:
|
||||
// the flag is the identity, and its err only repeats the sentinel.
|
||||
return [...sealed, { t: "notice" as const, id: nextId(), level: "error", text: err }];
|
||||
}
|
||||
|
||||
@@ -232,17 +234,7 @@ function apply(s: SessionState, ev: SessionEvent): SessionState {
|
||||
items: [...s.items, { t: "user", id, text: ev.text, pending: ev.pending }],
|
||||
};
|
||||
}
|
||||
// The kernel answers a queued line with the id it queued it under. The row
|
||||
// is already on screen by then — this is what gives it a name to be taken
|
||||
// back by, and losing that name is the same as losing the button.
|
||||
if (ev.kind === "__queued") {
|
||||
return {
|
||||
...s,
|
||||
items: s.items.map((i) =>
|
||||
i.t === "user" && i.id === ev.id ? { ...i, itemId: ev.itemId, queued: ev.queued, pending: true } : i,
|
||||
),
|
||||
};
|
||||
}
|
||||
if (ev.kind === "__queued") return nameQueued(s, ev.id, ev.itemId, ev.queued);
|
||||
// A line the kernel never took is not part of what happened, so it leaves the
|
||||
// transcript rather than sitting there looking sent. Either name identifies
|
||||
// it: the row the composer minted, or the entry the kernel queued it as —
|
||||
@@ -375,6 +367,7 @@ function apply(s: SessionState, ev: SessionEvent): SessionState {
|
||||
: s;
|
||||
|
||||
case "tool_progress":
|
||||
if (ev.tool && isSubagentProgress(ev.tool.name)) return { ...s, subagentPhase: notePhase(s.subagentPhase, ev.tool) };
|
||||
return ev.tool
|
||||
? { ...s, executions: noteTool(s.executions, ev.tool, false), items: foldTool(s.items, ev.tool, true) }
|
||||
: s;
|
||||
@@ -607,10 +600,10 @@ function apply(s: SessionState, ev: SessionEvent): SessionState {
|
||||
...s,
|
||||
running: false,
|
||||
terminal: turnTerminal(ev),
|
||||
doing: ev.outcome === "no_progress" ? "已暂停" : ev.err ? "已中断" : "已完成",
|
||||
doing: ev.outcome === "no_progress" ? "已暂停" : ev.cancelled ? "已取消" : ev.err ? "已中断" : "已完成",
|
||||
waiting: {},
|
||||
plan: livePlan(s.plan),
|
||||
items: withReceipt(sealTurn(sealSay(s.items, true), ev.outcome === "no_progress" ? undefined : ev.err), ev.receipt),
|
||||
items: withReceipt(sealTurn(sealSay(s.items, true), ev.outcome === "no_progress" || ev.cancelled ? undefined : ev.err), ev.receipt),
|
||||
};
|
||||
|
||||
default:
|
||||
|
||||
@@ -243,4 +243,7 @@ export interface SessionState {
|
||||
// are kept apart from `views` because they have no place of their own: they
|
||||
// appear only where the thing they stand in for appears.
|
||||
takeovers: Record<string, ExtensionSurface>;
|
||||
// The last phase each sub-agent reported, by call id. Kept beside the cards
|
||||
// because a background child outlives the call that started it.
|
||||
subagentPhase: Record<string, string>;
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { initialState, reduce, type Item, type SessionEvent } from "./session";
|
||||
import go from "../../../../internal/contract/event/subagent_progress.go?raw";
|
||||
import { SUBAGENT_PROGRESS_PREFIX } from "./fold";
|
||||
import { railOf } from "../ui/panels/derive";
|
||||
|
||||
const prof = { name: "flash-fill" };
|
||||
const dispatch = (id: string): SessionEvent =>
|
||||
({ kind: "tool_dispatch", tool: { id, name: "use_capability", args: "{}", profile: prof } }) as SessionEvent;
|
||||
const started = (id: string): SessionEvent =>
|
||||
({ kind: "tool_result", tool: { id, name: "use_capability", output: 'Started background task "task-1"', profile: prof } }) as SessionEvent;
|
||||
const status = (id: string, phase: string, parentId?: string): SessionEvent =>
|
||||
({ kind: "tool_progress", tool: { id, parentId, name: "reasonix.subagent.status", output: phase, durationMs: phase === "completed" ? 41000 : undefined } }) as SessionEvent;
|
||||
|
||||
const after = (evs: SessionEvent[]) => evs.reduce(reduce, initialState);
|
||||
const card = (s: ReturnType<typeof after>) => s.items.find((i): i is Extract<Item, { t: "tool" }> => i.t === "tool")!;
|
||||
const liveTasks = (s: ReturnType<typeof after>) => railOf(s.items, s.executions, s.subagentPhase).tasks.filter((x) => x.running).length;
|
||||
|
||||
describe("a background sub-agent after its dispatching call has answered", () => {
|
||||
it("is still running while its last phase is not terminal", () => {
|
||||
const s = after([dispatch("c1"), started("c1"), status("c1", "running"), status("c1", "tool")]);
|
||||
expect(card(s).running).toBe(false);
|
||||
expect(liveTasks(s)).toBe(1);
|
||||
});
|
||||
|
||||
it("is done once a terminal phase arrives, and the card is not reopened", () => {
|
||||
const s = after([dispatch("c1"), started("c1"), status("c1", "running"), status("c1", "completed")]);
|
||||
expect(card(s).running).toBe(false);
|
||||
expect(liveTasks(s)).toBe(0);
|
||||
});
|
||||
|
||||
it("is done on failure and on cancellation too", () => {
|
||||
for (const phase of ["failed", "cancelled"]) {
|
||||
expect(liveTasks(after([dispatch("c1"), started("c1"), status("c1", "running"), status(("c1"), phase)]))).toBe(0);
|
||||
}
|
||||
});
|
||||
|
||||
it("never renames the call or its recorded execution", () => {
|
||||
const s = after([dispatch("c1"), started("c1"), status("c1", "completed")]);
|
||||
expect(card(s).tool.name).toBe("use_capability");
|
||||
expect(s.executions["c1"]).toMatchObject({ name: "use_capability" });
|
||||
});
|
||||
|
||||
it("keeps a call that is still open running", () => {
|
||||
expect(liveTasks(after([dispatch("c1"), status("c1", "completed")]))).toBe(1);
|
||||
});
|
||||
|
||||
it("draws no card for a preview", () => {
|
||||
expect(after([status("c1", "queued")]).items).toEqual([]);
|
||||
});
|
||||
|
||||
it("follows a fleet worker through the card that holds it", () => {
|
||||
const fleet = { kind: "tool_dispatch", tool: { id: "fl", name: "use_capability", profile: { name: "fleet", count: 2 } } } as SessionEvent;
|
||||
const worker = { kind: "tool_dispatch", tool: { id: "fl/w1", parentId: "fl", name: "task" } } as SessionEvent;
|
||||
const closed = { kind: "tool_result", tool: { id: "fl", name: "use_capability", profile: { name: "fleet", count: 2 } } } as SessionEvent;
|
||||
const mid = after([fleet, worker, closed, status("fl/w1", "running", "fl")]);
|
||||
expect(liveTasks(mid)).toBe(1);
|
||||
expect(liveTasks(after([fleet, worker, closed, status("fl/w1", "running", "fl"), status("fl/w1", "completed", "fl")]))).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("the reserved prefix", () => {
|
||||
it("is the kernel's", () => {
|
||||
expect(go.match(/SubagentProgressPrefix\s*=\s*"([^"]+)"/)?.[1]).toBe(SUBAGENT_PROGRESS_PREFIX);
|
||||
});
|
||||
});
|
||||
@@ -269,6 +269,19 @@ rules are written in.
|
||||
was read, stored, written inline and then overridden every time.
|
||||
- `.gutter-l` outranks the rail because the rail is `position: fixed` at 12: a
|
||||
seam painted under the panel it divides cannot be grabbed where it matters.
|
||||
- The rail's floor is 232px (`RAIL.min`); a narrower saved width is raised on
|
||||
read.
|
||||
- Under 232px the session filter row cannot hold four labels in either
|
||||
language.
|
||||
- The rail head is a size container, and its threshold is a content-box width,
|
||||
not a rail width.
|
||||
- The threshold is 220px: the content box of the default 264px rail, which has
|
||||
22px side padding. The default and anything wider show counts and shortcut
|
||||
hints.
|
||||
- At 219px or less counts and hints are dropped, the filter cells may shrink
|
||||
and labels ellipsize.
|
||||
- Inside a window of 860px or less the padding is 16px, so the cutoff falls at
|
||||
a 252px rail.
|
||||
- The rail lists every mounted workspace, each folding over its own sessions.
|
||||
The studio layer had hidden all but the focused one and relabelled its
|
||||
children 「最近」, so a count of six sat above a list of one. The switcher above
|
||||
@@ -404,14 +417,11 @@ rules are written in.
|
||||
- `[data-k="me"]` opens a turn. Scrolling back, it is the only anchor, and it
|
||||
used to be as light as a tool call's header. The turn number is deliberately
|
||||
absent: the rewind entry is on the card, so there is no number to match by eye.
|
||||
- The bubble sizes the card; the controls row above it does not.
|
||||
`contain: inline-size` keeps the row out of the card's width, and
|
||||
`margin-inline-start: auto` holds the bubble's right edge on the column's.
|
||||
- When the labels need more room than the bubble, the controls drop to icons
|
||||
and keep their names, hints and focus. Fit is measured (`labelfit.ts`).
|
||||
- No container-query breakpoint decides it: the labels' width moves with the
|
||||
language and the marks beside them, so any fixed width is wrong for a row.
|
||||
- Icons wider than the bubble overflow to the left, where the free space is.
|
||||
- User message controls sit below the bubble, ordered copy, edit, rewind.
|
||||
They stay icon-only with names, hints and keyboard focus. Inline-size
|
||||
containment keeps the row from widening the bubble; wider rows extend left.
|
||||
- The row appears on message hover, keyboard focus, or an open rewind menu.
|
||||
Devices without hover keep the controls visible.
|
||||
- `[data-k="host"]` is what the host did itself. A transcript has three authors —
|
||||
you, the model, the host — and `data-k` only knows tool names, so the host's
|
||||
cards landed on the default grey beside an uncategorised tool. A dashed line is
|
||||
@@ -670,9 +680,36 @@ what exists.
|
||||
|
||||
## Onboarding
|
||||
|
||||
- The stage is the named inline-size container `onboarding`. The card folds at
|
||||
760px of that container, so interface zoom changes its columns and spacing.
|
||||
- The fold follows available CSS width, not the unscaled viewport measured by
|
||||
a media query. Form controls must fit inside the shell's clipping boundary.
|
||||
- `perf/onboarding.mjs` checks card bounds, the 760/761px boundary, saved zoom
|
||||
and resizing, alongside scrolling, focus clearance and completing setup.
|
||||
- The connect card grows inside the opening scene rather than starting a second
|
||||
screen, so the introduction above it stays present and the palette follows the
|
||||
scene (a dark ground) rather than the app theme.
|
||||
- The stage is exactly the window's height and is the scroll container, because
|
||||
the body clips.
|
||||
- That height is `100dvh / var(--zoom)`, as on the body: `vh` does not scale
|
||||
with the zoom setting, so a bare `100dvh` stage would be taller than the
|
||||
zoomed window.
|
||||
- The title bar is `position: sticky; top: 0` inside the stage: it holds the
|
||||
drag region and the window buttons, which must stay on screen however far
|
||||
the card is scrolled.
|
||||
- A stage with only a `min-height` grows with its content and never overflows
|
||||
itself, so the connected state pushed the start button below a short window
|
||||
with nothing to scroll.
|
||||
- The ambient glow is `position: fixed`: absolutely positioned inside a scroll
|
||||
container it counts as scrollable overflow, and at a wide window (38vw tall)
|
||||
it let the stage scroll far past the card.
|
||||
- The shell is `flex: none`: a shrinkable shell is cut off instead of making
|
||||
the stage scroll.
|
||||
- The stage's `scroll-padding-top` equals the title bar's 64px, so focus moving
|
||||
up the form scrolls the field below the sticky bar rather than under it.
|
||||
- The title bar and footer are `flex: none` too; as shrinkable flex items they
|
||||
were squeezed to half height once the card overflowed.
|
||||
- The shell clips with `overflow: clip`, so it is not a scroll container.
|
||||
- Its labels are not uppercased: tracking pulls apart the Chinese particles in
|
||||
mixed text. The "get a key" entry sits at the label's right end with tracking
|
||||
zeroed, because the label's .06em is for Latin small labels.
|
||||
@@ -779,6 +816,14 @@ what exists.
|
||||
- Remove is the one irreversible action in a row, so it does not look like the
|
||||
other buttons and appears only on hover.
|
||||
|
||||
## Community
|
||||
|
||||
- The QR code sits on a white plate in both themes: a scanner needs dark modules
|
||||
on a light field with a quiet zone, and an inverted code in the dark theme
|
||||
does not scan. The plate is the image's own quiet zone, not a card behind it.
|
||||
- The image is drawn at 5 CSS pixels per module (185px for 37 modules) so the
|
||||
squares land on whole pixels; `image-rendering: pixelated` keeps them hard.
|
||||
|
||||
## Version list
|
||||
|
||||
- The list is one grid and each row a subgrid of it, so version, description,
|
||||
|
||||
@@ -2766,6 +2766,7 @@
|
||||
font: var(--w-reg) var(--fs-meta) var(--mono); color: var(--faint) }
|
||||
.srv .acts { flex: none }
|
||||
.srv .why { margin: 4px 0 0 15px; font: var(--w-reg) var(--fs-ui)/1.55 var(--ui); color: var(--err) }
|
||||
.srv .why[role="status"] { color: var(--muted) }
|
||||
.srv .peek { margin: 7px 0 1px 15px }
|
||||
|
||||
.srv .srv-ab { display: flex; align-items: baseline; gap: 8px; margin: 5px 0 0 15px }
|
||||
@@ -2861,6 +2862,7 @@
|
||||
background: var(--raised); color: var(--text); font: var(--w-reg) var(--fs-ui)/1.7 var(--mono); resize: vertical;
|
||||
}
|
||||
.addsrv .paste::placeholder, .addpkg .paste::placeholder { color: var(--ghost); white-space: pre-line }
|
||||
.addpkg .paste:disabled { background: var(--overlay); cursor: default }
|
||||
.addsrv .paste:focus, .addpkg .paste:focus { outline: 2px solid var(--focus); outline-offset: 1px }
|
||||
.addsrv .acts, .addpkg .acts { display: flex; align-items: center; gap: 8px; margin-top: 10px }
|
||||
.addsrv .acts .note, .addpkg .acts .note { flex: 1; font: var(--w-reg) var(--fs-meta) var(--ui); color: var(--ghost) }
|
||||
@@ -3099,6 +3101,12 @@
|
||||
.addp .fields label { display: flex; flex-direction: column; gap: 4px }
|
||||
.addp .fields label.grow { flex: 1; min-width: 150px }
|
||||
.addp .fields label.full { flex-basis: 100% }
|
||||
.addp .fields input[aria-invalid="true"] { border-color: var(--err) }
|
||||
.addp .fields .name-cell { flex: 1; min-width: 150px; display: flex; flex-direction: column; gap: 4px }
|
||||
.addp .fields .name-cell label { display: flex; flex-direction: column; gap: 4px }
|
||||
.addp .fields input[aria-invalid="true"]:focus { outline-color: var(--err) }
|
||||
.addp .fields .name-rule { margin: 0; font: var(--w-reg) var(--fs-micro)/1.45 var(--ui); color: var(--ghost) }
|
||||
.addp .fields .name-rule[data-bad] { color: var(--err) }
|
||||
.addp .fields .tip { font: var(--w-reg) var(--fs-micro)/1.45 var(--ui); font-style: normal; color: var(--ghost) }
|
||||
.addp-section { margin-top: 16px; padding-top: 14px; border-top: 1px solid var(--hair) }
|
||||
.addp-section.compact { margin-top: 12px }
|
||||
@@ -3273,6 +3281,8 @@
|
||||
}
|
||||
.mkt-fields .mono { font: var(--w-reg) var(--fs-meta)/1.5 var(--mono) }
|
||||
.mkt-fields textarea { resize: vertical }
|
||||
.mkt-pub .mkt-fields :is(input, textarea):disabled,
|
||||
.mkt-pub .mkt-seen:has(:disabled) { opacity: .5; cursor: default }
|
||||
.mkt-fields input:focus, .mkt-fields textarea:focus { outline: 2px solid var(--focus); outline-offset: 1px }
|
||||
.mkt-fields input::placeholder { color: var(--ghost) }
|
||||
.mkt-vote { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; margin: 6px 0 0 }
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
.comm { display: flex; flex-direction: column; gap: 22px; }
|
||||
.comm-join { display: flex; flex-wrap: wrap; align-items: flex-start; gap: 20px; }
|
||||
.comm-qr {
|
||||
flex: none; width: 185px; height: 185px; max-width: 100%; box-sizing: border-box;
|
||||
background: #fff; border: 1px solid var(--border); border-radius: var(--r-md);
|
||||
image-rendering: pixelated;
|
||||
}
|
||||
.comm-qq { flex: 1 1 220px; min-width: 0; display: flex; flex-direction: column; align-items: flex-start; gap: 10px; }
|
||||
.comm-name { font: var(--w-semi) var(--fs-lead)/1.4 var(--ui); color: var(--text); overflow-wrap: anywhere; }
|
||||
.comm-num { display: flex; align-items: center; flex-wrap: wrap; gap: 8px; }
|
||||
.comm-num code { font: var(--w-med) var(--fs-lead) var(--mono); color: var(--text); letter-spacing: .02em; user-select: all; }
|
||||
.comm-k { font: var(--w-reg) var(--fs-meta) var(--ui); color: var(--faint); }
|
||||
.comm-how { font: var(--w-reg) var(--fs-ui)/1.6 var(--ui); color: var(--muted); }
|
||||
.comm-acts { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 4px; }
|
||||
.comm-fail { margin: 0; font: var(--w-reg) var(--fs-meta)/1.6 var(--ui); color: var(--err-ink); overflow-wrap: anywhere; }
|
||||
.comm-who { display: flex; flex-direction: column; align-items: flex-start; gap: 10px; padding-top: 16px; border-top: 1px solid var(--hair); }
|
||||
.comm-who-hd { align-self: stretch; display: flex; flex-wrap: wrap; align-items: center; gap: 4px 10px; }
|
||||
.comm-who-hd h4 { margin: 0; font: var(--w-semi) var(--fs-ui) var(--ui); color: var(--text); }
|
||||
.comm-who-hd .btn { margin-left: auto; }
|
||||
.comm-list { align-self: stretch; margin: 0; padding: 0; list-style: none; display: flex; flex-wrap: wrap; gap: 6px; }
|
||||
.comm-list li { min-width: 0; }
|
||||
.comm-list a {
|
||||
display: block; max-width: 100%; padding: 2px 9px; border: 1px solid var(--hair); border-radius: var(--r-pill);
|
||||
font: var(--w-reg) var(--fs-meta)/1.7 var(--mono); color: var(--muted); text-decoration: none;
|
||||
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
||||
}
|
||||
.comm-list a:hover { color: var(--text); border-color: var(--faint); }
|
||||
.comm-list a:focus-visible { outline: 2px solid var(--focus); outline-offset: 1px; }
|
||||
.comm-dy { display: flex; flex-wrap: wrap; align-items: center; gap: 6px 10px; font: var(--w-reg) var(--fs-meta) var(--ui); color: var(--muted); }
|
||||
.comm-dy code { font: var(--w-reg) var(--fs-meta) var(--mono); color: var(--muted); user-select: all; }
|
||||
.comm-dy-qr { align-self: flex-start; max-width: 100%; height: auto; border: 1px solid var(--border); border-radius: var(--r-md); }
|
||||
.comm-author { display: flex; align-items: baseline; gap: 10px; padding: 0 0 14px; }
|
||||
.comm-author a { font: var(--w-med) var(--fs-ui) var(--mono); color: var(--text); text-decoration: none; }
|
||||
.comm-author a:hover { text-decoration: underline; }
|
||||
.comm-author a:focus-visible { outline: 2px solid var(--focus); outline-offset: 1px; }
|
||||
@@ -0,0 +1,45 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const CSS = import.meta.glob("./*.css", { query: "?raw", import: "default", eager: true }) as Record<string, string>;
|
||||
|
||||
const MIN_LINE_HEIGHT = 1.2;
|
||||
|
||||
interface Rule {
|
||||
file: string;
|
||||
selector: string;
|
||||
body: string;
|
||||
}
|
||||
|
||||
function rules(): Rule[] {
|
||||
const out: Rule[] = [];
|
||||
for (const [file, css] of Object.entries(CSS)) {
|
||||
for (const m of css.matchAll(/([^{}]+)\{([^{}]*)\}/g)) out.push({ file, selector: m[1].trim(), body: m[2] });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function lineHeight(body: string): number | undefined {
|
||||
const longhand = body.match(/(?:^|[;\s])line-height\s*:\s*([\d.]+)\s*(?:;|$)/);
|
||||
if (longhand) return Number(longhand[1]);
|
||||
const shorthand = body.match(/(?:^|[;\s])font\s*:[^;]*?\/\s*([\d.]+)(?=\s|;|$)/);
|
||||
return shorthand ? Number(shorthand[1]) : undefined;
|
||||
}
|
||||
|
||||
const clips = (body: string) => /overflow(?:-x)?\s*:\s*hidden|text-overflow\s*:/.test(body);
|
||||
|
||||
describe("a clipped single line keeps room for the glyphs of any UI font", () => {
|
||||
it("never pairs overflow clipping with a unitless line-height below 1.2", () => {
|
||||
const tight = rules()
|
||||
.filter((r) => clips(r.body))
|
||||
.filter((r) => (lineHeight(r.body) ?? Infinity) < MIN_LINE_HEIGHT)
|
||||
.map((r) => `${r.file} ${r.selector}`);
|
||||
expect(tight).toEqual([]);
|
||||
});
|
||||
|
||||
it("gives the composer's model name its own line-height, since it inherits a tight one from the picker", () => {
|
||||
const names = rules().filter((r) => /\.model-picker \.nm$/.test(r.selector) && clips(r.body));
|
||||
expect(names.length).toBeGreaterThan(0);
|
||||
const bare = names.filter((r) => (lineHeight(r.body) ?? 0) < MIN_LINE_HEIGHT).map((r) => `${r.file} ${r.selector}`);
|
||||
expect(bare).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,43 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const SHEET = Object.values(
|
||||
import.meta.glob("./studio.css", { query: "?raw", import: "default", eager: true }) as Record<string, string>,
|
||||
)[0];
|
||||
|
||||
interface Rule {
|
||||
selector: string;
|
||||
decls: Map<string, string>;
|
||||
}
|
||||
|
||||
function rules(css: string): Rule[] {
|
||||
const out: Rule[] = [];
|
||||
for (const m of css.replace(/\/\*[\s\S]*?\*\//g, "").matchAll(/([^{}]+)\{([^{}]*)\}/g)) {
|
||||
const decls = new Map<string, string>();
|
||||
for (const part of m[2].split(";")) {
|
||||
const at = part.indexOf(":");
|
||||
if (at > 0) decls.set(part.slice(0, at).trim(), part.slice(at + 1).trim());
|
||||
}
|
||||
out.push({ selector: m[1].trim(), decls });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
describe("status rail figures hold their width", () => {
|
||||
const rail = rules(SHEET).filter((r) => r.selector.split(",").some((s) => s.includes(".studio-meterrail")));
|
||||
|
||||
it("finds the rail rules", () => {
|
||||
expect(rail.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("keeps tabular numerals on every rule that sets the font shorthand", () => {
|
||||
const reset = rail
|
||||
.filter((r) => r.decls.has("font") && r.decls.get("font-variant-numeric") !== "tabular-nums")
|
||||
.map((r) => r.selector.replace(/\s+/g, " "));
|
||||
expect(reset, "the font shorthand resets font-variant-numeric; restate tabular-nums in the same rule").toEqual([]);
|
||||
});
|
||||
|
||||
it("reserves the width of a three-digit speed so 9.9 to 10.0 and 99.9 to 100.0 do not move its neighbours", () => {
|
||||
const speed = rail.find((r) => r.selector === ".studio-meterrail .studio-meter-speed b");
|
||||
expect(speed?.decls.get("min-width")).toBe("5ch");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,27 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const CSS = import.meta.glob("./studio.css", { query: "?raw", import: "default", eager: true }) as Record<string, string>;
|
||||
const css = Object.values(CSS)[0];
|
||||
const stage = [...css.matchAll(/\.onb-stage\s*\{([^}]*)\}/g)].map((match) => match[1]).join("\n");
|
||||
|
||||
describe("onboarding folds in the room left after interface zoom", () => {
|
||||
it("measures its own inline size", () => {
|
||||
expect(stage).toMatch(/container:\s*onboarding\s*\/\s*inline-size/);
|
||||
});
|
||||
|
||||
it("moves the complete card to one column through a container query", () => {
|
||||
const fold = css.match(/@container onboarding \(max-width: 760px\)\s*\{([\s\S]*?)\n\}/)?.[1] ?? "";
|
||||
expect(fold).toMatch(/\.onb-shell\s*\{[^}]*grid-template-columns:\s*1fr/);
|
||||
expect(fold).toContain(".onb-progress");
|
||||
expect(fold).toContain(".onb { padding: 26px 22px 24px; }");
|
||||
const media = css.match(/@media \(max-width: 760px\)\s*\{([\s\S]*?)\n\}/)?.[1] ?? "";
|
||||
expect(media).not.toContain(".onb");
|
||||
});
|
||||
|
||||
it("keeps viewport scrolling and focus clearance independent of the fold", () => {
|
||||
expect(stage).toContain("height: calc(100dvh / var(--zoom, 1))");
|
||||
expect(stage).toContain("scroll-padding-top: 64px");
|
||||
expect(stage).toContain("overflow: auto");
|
||||
expect(css).toMatch(/\.onb-brandbar\s*\{[^}]*position:\s*sticky;[^}]*top:\s*0/);
|
||||
});
|
||||
});
|
||||
@@ -326,7 +326,9 @@ body { background: var(--page); }
|
||||
|
||||
/* First-run connection */
|
||||
.onb-stage {
|
||||
min-height: 100vh;
|
||||
height: calc(100dvh / var(--zoom, 1));
|
||||
scroll-padding-top: 64px;
|
||||
container: onboarding / inline-size;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background:
|
||||
@@ -336,6 +338,7 @@ body { background: var(--page); }
|
||||
}
|
||||
.onb-brandbar {
|
||||
height: 64px;
|
||||
flex: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 9px;
|
||||
@@ -358,9 +361,10 @@ body { background: var(--page); }
|
||||
.onb-shell {
|
||||
width: min(920px, calc(100% - 48px));
|
||||
margin: auto;
|
||||
flex: none;
|
||||
display: grid;
|
||||
grid-template-columns: 230px minmax(0, 1fr);
|
||||
overflow: hidden;
|
||||
overflow: clip;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 16px;
|
||||
background: var(--surface);
|
||||
@@ -586,7 +590,7 @@ body { background: var(--page) !important; }
|
||||
stroke-linejoin: round;
|
||||
}
|
||||
.studio-icon[data-flip] { transform: rotate(180deg); }
|
||||
.studio-rail-head { padding: 23px 22px 5px; }
|
||||
.studio-rail-head { padding: 23px 22px 5px; container-type: inline-size; }
|
||||
.studio-brand { padding: 0 0 19px; gap: 9px; }
|
||||
.studio-brand-mark { width: 28px; height: 28px; display: grid; place-items: center; border-radius: 7px; background: var(--accent); color: var(--accent-fg); font-weight: 750; }
|
||||
.studio-brand-mark .studio-icon { width: 17px; height: 17px; stroke-width: 1.9; }
|
||||
@@ -638,25 +642,35 @@ body { background: var(--page) !important; }
|
||||
}
|
||||
.studio-quicknav button:hover { color: var(--text); background: var(--overlay); }
|
||||
.studio-quicknav button > span { display: grid; place-items: center; color: var(--accent); text-align: center; }
|
||||
.studio-quicknav b { font: 500 11px/1 var(--ui); }
|
||||
.studio-quicknav b { min-width: 0; overflow: hidden; font: 500 11px/1.4 var(--ui); text-overflow: ellipsis; white-space: nowrap; }
|
||||
.studio-new-task > .studio-label,
|
||||
.studio-search > .studio-label { min-width: 0; display: block; overflow: hidden; color: inherit; font: inherit; line-height: 1.4; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.studio-quicknav small { color: var(--faint); font: 500 9px/1 var(--ui); }
|
||||
.studio-session-filters {
|
||||
min-height: 31px; display: grid; grid-template-columns: minmax(0, 1fr) 26px;
|
||||
align-items: center; gap: 4px; margin: 0 0 8px; padding: 0; background: transparent;
|
||||
}
|
||||
.studio-session-segments {
|
||||
height: 28px; display: grid; grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
height: 28px; display: flex;
|
||||
align-items: center; gap: 1px; padding: 2px; border-radius: 6px; background: var(--overlay);
|
||||
}
|
||||
.studio-session-segments button {
|
||||
min-width: 0; height: 24px; display: flex; align-items: center; justify-content: center;
|
||||
gap: 2px; padding: 0 3px; border: 0; border-radius: 4px; background: transparent;
|
||||
min-width: 0; height: 24px; flex: 1 0 auto; display: flex; align-items: center; justify-content: center;
|
||||
gap: 2px; padding: 0 1px; border: 0; border-radius: 4px; background: transparent;
|
||||
color: var(--muted); font: 500 10px/1 var(--ui); white-space: nowrap; cursor: pointer;
|
||||
}
|
||||
.studio-session-segments button:hover { background: var(--raised); color: var(--text); }
|
||||
.studio-session-segments button[aria-pressed="true"] { background: var(--raised); color: var(--text); font-weight: 650; }
|
||||
.studio-session-segments b { min-width: 8px; color: var(--faint); font: 560 9px/1 var(--ui); font-variant-numeric: tabular-nums; }
|
||||
.studio-session-segments button > span { min-width: 0; overflow: hidden; line-height: 1.4; text-overflow: ellipsis; }
|
||||
.studio-session-segments b { flex: none; min-width: 8px; color: var(--faint); font: 560 9px/1 var(--ui); font-variant-numeric: tabular-nums; }
|
||||
.studio-session-segments button[aria-pressed="true"] b { color: var(--muted); }
|
||||
@container (max-width: 219px) {
|
||||
.studio-new-task kbd,
|
||||
.studio-search kbd,
|
||||
.studio-quicknav small,
|
||||
.studio-session-segments b { display: none; }
|
||||
.studio-session-segments button { flex-shrink: 1; }
|
||||
}
|
||||
.studio-session-filters .studio-filter-search {
|
||||
width: 26px; height: 26px; display: grid; place-items: center; margin: 0; padding: 0;
|
||||
border: 0; border-radius: 5px; background: transparent; color: var(--faint); cursor: pointer;
|
||||
@@ -683,7 +697,7 @@ body { background: var(--page) !important; }
|
||||
.rail .sessrow[aria-selected="true"] { background: var(--raised); color: var(--text); box-shadow: inset 2px 0 var(--text); }
|
||||
.rail .sessrow .pip { opacity: .45; }
|
||||
.rail .sesstitle { font-size: 11px; line-height: 1.35; }
|
||||
.rail .sessmeta { color: var(--faint); font-size: 9px; }
|
||||
.rail .sessmeta { flex: none; color: var(--faint); font-size: 9px; white-space: nowrap; }
|
||||
.rail .sessrow > .wsdel { opacity: 0; transition: opacity 100ms ease; }
|
||||
.rail .sessrow:hover > .wsdel,
|
||||
.rail .sessrow:focus-within > .wsdel { opacity: 1; }
|
||||
@@ -851,13 +865,35 @@ body { background: var(--page) !important; }
|
||||
.acts-pop > button:hover { background: var(--overlay); color: var(--text); }
|
||||
.flow .call[data-k="say"] .copy[data-icon][data-state="done"] { color: var(--ghost); }
|
||||
.flow .call[data-k="me"] {
|
||||
display: flex; justify-content: flex-end; margin: 8px 0 28px;
|
||||
display: flex; justify-content: flex-end; margin: 8px 0 0;
|
||||
background: transparent; border: 0; box-shadow: none;
|
||||
}
|
||||
.flow .call[data-k="me"]:hover { background: transparent; border-color: transparent; }
|
||||
.flow .call[data-k="me"]::before { display: none; }
|
||||
.flow .call[data-k="me"] > .c { width: auto; max-width: 70%; padding: 0; }
|
||||
.flow .call[data-k="me"] .user-hl { min-height: 0; margin: 0 3px 4px; justify-content: flex-end; flex-wrap: nowrap; contain: inline-size; }
|
||||
.flow .user-acts { display: flex; align-items: center; justify-content: flex-end; gap: 4px; margin-top: 4px; min-height: 26px; contain: inline-size; opacity: 0; transition: opacity var(--m-line); }
|
||||
.flow .call > .c:hover .user-acts,
|
||||
.flow .call > .c:focus-within .user-acts,
|
||||
.flow .user-acts:has(.rewind[data-open]),
|
||||
.flow .user-acts:has(.copy[data-feedback]) { opacity: 1; }
|
||||
.flow .user-acts > .rewind { margin-left: 0; opacity: 1; }
|
||||
.flow .user-acts .rewind > .picker { display: flex; }
|
||||
.flow .user-acts > button,
|
||||
.flow .user-acts .stepctl button {
|
||||
display: inline-flex; align-items: center; justify-content: center; flex: none;
|
||||
width: 26px; height: 26px; padding: 0; opacity: 1; line-height: 12px;
|
||||
border: 0; border-radius: 6px; background: transparent; color: var(--muted);
|
||||
}
|
||||
.flow .user-acts .studio-icon { width: 12px; height: 12px; }
|
||||
.flow .call[data-k="me"] .user-acts > button:hover,
|
||||
.flow .call[data-k="me"] .user-acts .stepctl button:hover { background: var(--overlay); color: var(--text); }
|
||||
.flow .user-acts > .copy[data-feedback] { width: auto; gap: 5px; padding: 0 6px; white-space: nowrap; }
|
||||
.flow .user-acts > button.copy[data-state="done"],
|
||||
.flow .user-acts > button.copy[data-state="done"]:hover { color: var(--ok); }
|
||||
.flow .user-acts > button.copy[data-state="failed"],
|
||||
.flow .user-acts > button.copy[data-state="failed"]:hover { color: var(--warn-ink); }
|
||||
.flow .call[data-k="me"] .user-acts > .reask-open { background: transparent; color: var(--muted); }
|
||||
.flow .call[data-k="me"] .user-hl > .nm { display: none; }
|
||||
.flow .call[data-k="me"] .out { margin-top: 0; }
|
||||
.flow .call[data-k="me"] .out .txt {
|
||||
@@ -1126,7 +1162,7 @@ body { background: var(--page) !important; }
|
||||
.turntools .studio-branch { max-width: 160px; }
|
||||
.turntools .studio-branch .lb { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.turntools .model-picker { max-width: 180px; }
|
||||
.turntools .model-picker .nm { max-width: 130px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.turntools .model-picker .nm { max-width: 130px; line-height: 1.5; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.compose .mode {
|
||||
min-height: 28px;
|
||||
border-color: transparent;
|
||||
@@ -1176,7 +1212,7 @@ body { background: var(--page) !important; }
|
||||
.studio-meterrail > .studio-meter-cost {
|
||||
height: 25px; display: inline-flex; align-items: center; gap: 5px; padding: 0 6px;
|
||||
border: 0; border-radius: 5px; background: transparent; color: var(--faint);
|
||||
white-space: nowrap; font: 500 10px/1 var(--ui);
|
||||
white-space: nowrap; font: 500 10px/1 var(--ui); font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.studio-meterrail > button,
|
||||
.studio-meterrail .studio-context-anchor > button,
|
||||
@@ -1187,7 +1223,8 @@ body { background: var(--page) !important; }
|
||||
background: color-mix(in srgb, var(--text) 7%, transparent);
|
||||
color: var(--text);
|
||||
}
|
||||
.studio-meterrail b { color: inherit; font: 500 10px/1 var(--ui); }
|
||||
.studio-meterrail b { color: inherit; font: 500 10px/1 var(--ui); font-variant-numeric: tabular-nums; }
|
||||
.studio-meterrail .studio-meter-speed b { min-width: 5ch; text-align: right; }
|
||||
.studio-meterrail .studio-icon { width: 13px; height: 13px; }
|
||||
/* The line reads before the figure: the shape answers "is it moving" at a
|
||||
glance, and the number is what you look at once it has. It takes no room
|
||||
@@ -1406,7 +1443,7 @@ body { background: var(--page) !important; }
|
||||
.workbench-filelist em { color: var(--warn); font-style: normal; }
|
||||
.workbench-no-files { margin: 18px 12px; color: var(--faint); font: 500 10px/1.4 var(--ui); text-align: center; }
|
||||
.workbench-filebar { min-height: 39px; padding: 0 10px; flex: none; display: grid; grid-template-columns: minmax(0, 1fr) auto auto auto; align-items: center; gap: 8px; border-bottom: 1px solid var(--hair); }
|
||||
.workbench-filebar strong { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font: 500 11px/1 var(--mono); }
|
||||
.workbench-filebar strong { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font: 500 11px/1.5 var(--mono); }
|
||||
.workbench-filebar [role="group"] { display: flex; padding: 2px; background: var(--page); border: 1px solid var(--hair); border-radius: 5px; }
|
||||
.workbench-filebar button { min-height: 25px; padding: 0 8px; border: 0; border-radius: 3px; background: transparent; color: var(--muted); font: 600 10px/1 var(--ui); }
|
||||
.workbench-filebar button[aria-pressed="true"] { background: var(--raised); color: var(--text); }
|
||||
@@ -1650,11 +1687,16 @@ body { background: var(--page) !important; }
|
||||
.onb-model-menu .mi:hover,
|
||||
.onb-model-menu .mi[data-lead] { background: var(--overlay); }
|
||||
.onb-model-menu .mfind { background: var(--float); color: var(--text); border-bottom-color: var(--border); }
|
||||
.onb-foot { padding: 14px 24px 18px; text-align: center; color: var(--ghost); font: 500 10px/1 var(--ui); }
|
||||
.onb-foot { flex: none; padding: 14px 24px 18px; text-align: center; color: var(--ghost); font: 500 10px/1 var(--ui); }
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.app { --nav-w: 44px; }
|
||||
.studio-brand > span:nth-child(2) small { display: none; }
|
||||
.flow { width: calc(100% - 20px); margin-inline: auto; padding-inline: 0; }
|
||||
.compose { width: calc(100% - 20px); margin-inline: auto; }
|
||||
}
|
||||
|
||||
@container onboarding (max-width: 760px) {
|
||||
.onb-shell { width: min(560px, calc(100% - 24px)); grid-template-columns: 1fr; }
|
||||
.onb-progress { padding: 18px 20px; border-right: 0; border-bottom: 1px solid var(--hair); }
|
||||
.onb-progress ol { margin-top: 14px; display: flex; gap: 10px; }
|
||||
@@ -1662,8 +1704,6 @@ body { background: var(--page) !important; }
|
||||
.onb-progress li span small,
|
||||
.onb-progress p { display: none; }
|
||||
.onb { padding: 26px 22px 24px; }
|
||||
.flow { width: calc(100% - 20px); margin-inline: auto; padding-inline: 0; }
|
||||
.compose { width: calc(100% - 20px); margin-inline: auto; }
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
@@ -1738,7 +1778,7 @@ body { background: var(--page) !important; }
|
||||
|
||||
.onb-stage::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
position: fixed;
|
||||
z-index: -1;
|
||||
top: 7%;
|
||||
right: 10%;
|
||||
@@ -1755,7 +1795,8 @@ body { background: var(--page) !important; }
|
||||
}
|
||||
|
||||
.onb-brandbar {
|
||||
position: relative;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 2;
|
||||
background: color-mix(in srgb, var(--page) 76%, transparent);
|
||||
-webkit-backdrop-filter: blur(18px) saturate(1.12);
|
||||
@@ -1864,6 +1905,8 @@ body { font-size: 14px; line-height: 1.75; }
|
||||
display: flex; align-items: center; overflow: hidden;
|
||||
font: 400 11px/1.45 var(--ui); letter-spacing: -.01em;
|
||||
}
|
||||
.rail .sessrow-context > .sesstitle { padding-inline-end: 9px; }
|
||||
.rail .sessrow-context > .sesscopies { margin-inline-end: 8px; }
|
||||
.rail .sesstitle > span {
|
||||
min-width: 0; display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 2;
|
||||
overflow: hidden; white-space: normal; overflow-wrap: anywhere; word-break: break-word;
|
||||
@@ -2145,7 +2188,7 @@ body { font-size: 14px; line-height: 1.75; }
|
||||
}
|
||||
.studio-user-foot .acctrow { width: 30px; min-width: 30px; height: 30px; margin: 0; padding: 0; overflow: hidden; }
|
||||
.studio-user-foot .ini { width: 28px; height: 28px; }
|
||||
.studio-workspace-kind { color: var(--faint); font: 500 11px/1 var(--ui); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.studio-workspace-kind { color: var(--faint); font: 500 11px/1.5 var(--ui); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
|
||||
.turntools > .attach { order: 1 !important; }
|
||||
.turntools > .studio-mode-control { order: 2 !important; }
|
||||
@@ -2184,7 +2227,7 @@ body { font-size: 14px; line-height: 1.75; }
|
||||
.studio-mode-picker .studio-icon:last-child,
|
||||
.studio-effort-picker .studio-icon:last-child,
|
||||
.model-picker .studio-icon:last-child { width: 11px; height: 11px; color: var(--ghost); }
|
||||
.studio-model-control .model-picker .nm { max-width: 125px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.studio-model-control .model-picker .nm { max-width: 125px; line-height: 1.5; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.studio-model-control .model-picker {
|
||||
min-height: 28px;
|
||||
justify-content: flex-start;
|
||||
@@ -3485,7 +3528,7 @@ body { font-size: 14px; line-height: 1.75; }
|
||||
.run-signal-line button i { position: absolute; z-index: 1; left: 50%; top: 50%; width: 9px; height: 9px; translate: -50% -50%; rotate: 45deg; border: 2px solid var(--raised); background: var(--ra-color); box-shadow: 0 0 0 1px color-mix(in srgb, var(--ra-color) 62%, var(--border)); }
|
||||
.run-signal-line button:hover i,
|
||||
.run-signal-line button:focus-visible i { scale: 1.22; }
|
||||
.run-rounds ol { list-style: none; margin: 0; padding: 0; }
|
||||
.run-rounds ol { list-style: none; margin: 0; padding: 0; overflow-anchor: none; }
|
||||
.run-rounds li { border-radius: 0; border-bottom: 1px solid var(--hair); }
|
||||
.run-rounds li:last-child { border-bottom: 0; }
|
||||
.run-rounds li:hover { background: var(--overlay); }
|
||||
@@ -3511,7 +3554,8 @@ body { font-size: 14px; line-height: 1.75; }
|
||||
.run-round-copy b { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font: var(--w-med) 11.5px var(--mono); }
|
||||
.run-round-copy small { flex: none; color: var(--faint); font: var(--w-reg) 10px var(--mono); }
|
||||
.run-round-kind { justify-self: end; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--ra-color); font: var(--w-reg) 9.5px var(--mono); }
|
||||
.run-inspect { margin-top: 14px; padding: 14px 15px; border: 1px solid var(--border); border-radius: 10px; background: color-mix(in srgb, var(--raised) 84%, transparent); }
|
||||
.run-inspect { margin: 14px 0 0; padding: 14px 15px; border: 1px solid var(--border); border-radius: 10px; background: color-mix(in srgb, var(--raised) 84%, transparent); }
|
||||
.run-rounds .run-inspect { margin: 2px 10px 10px 44px; }
|
||||
.run-inspect > header { display: flex; justify-content: space-between; gap: 12px; color: var(--text); font: var(--w-reg) 11px var(--mono); }
|
||||
.run-inspect-facts { display: flex; flex-wrap: wrap; gap: 28px; margin-top: 11px; }
|
||||
.run-inspect-facts span { display: grid; gap: 2px; }
|
||||
@@ -3605,6 +3649,7 @@ body { font-size: 14px; line-height: 1.75; }
|
||||
.grp .now,
|
||||
.vway .lb,
|
||||
.addp .fields > label > span,
|
||||
.addp .fields .name-cell > label > span,
|
||||
.addp-section-head span { font-size: var(--prefs-meta); }
|
||||
.addp-head strong,
|
||||
.addp-section-head strong,
|
||||
@@ -3998,6 +4043,7 @@ body { font-size: 14px; line-height: 1.75; }
|
||||
.refine-card footer { display: flex; justify-content: flex-end; gap: 6px; }
|
||||
:root[data-theme="light"] .refine-card { background: var(--page); }
|
||||
@media (hover: none) {
|
||||
.flow .user-acts,
|
||||
.wsdel,
|
||||
.rail .sessrow > .wsdel,
|
||||
.rail .session-more,
|
||||
@@ -4069,3 +4115,38 @@ body { font-size: 14px; line-height: 1.75; }
|
||||
.studio-effort-menu .mi[aria-checked] .studio-item-badge { background: var(--overlay); color: var(--warn-ink); }
|
||||
.studio-effort-menu .mi[aria-checked] { position: sticky; bottom: 0; z-index: 1; background: var(--raised); box-shadow: 0 -1px 0 var(--border); }
|
||||
.studio-effort-menu .mi[aria-checked]:not(:disabled):hover { background: var(--overlay); }
|
||||
.bound .out-more { margin: 0 0 0 8px; text-decoration: underline; text-underline-offset: 2px; cursor: pointer; }
|
||||
.bound .out-more:hover { background: var(--overlay); color: var(--text); }
|
||||
.flow .bound .out-more { padding: 0 4px; }
|
||||
.ag-open {
|
||||
flex: none; width: 22px; height: 22px; display: grid; place-items: center; margin: -3px -4px -3px 0;
|
||||
padding: 0; border: 0; border-radius: var(--r-xs); background: transparent; color: var(--muted); cursor: pointer;
|
||||
}
|
||||
.ag-open .studio-icon { width: 11px; height: 11px; }
|
||||
.ag-open:hover { background: var(--overlay); color: var(--text); }
|
||||
.ag-open:focus-visible { outline: 2px solid var(--focus); outline-offset: -1px; }
|
||||
@media (pointer: coarse) { .ag-open { width: 32px; height: 32px; margin: -8px -8px -8px 0; } }
|
||||
.agent-tx-scrim {
|
||||
position: fixed; inset: var(--chrome-h, 39px) 0 0; z-index: 60; display: grid; place-items: center; padding: 28px;
|
||||
background: color-mix(in srgb, var(--page) 40%, transparent);
|
||||
-webkit-backdrop-filter: blur(14px) saturate(.92); backdrop-filter: blur(14px) saturate(.92);
|
||||
}
|
||||
.agent-tx {
|
||||
width: min(720px, 100%); max-height: 100%; display: flex; flex-direction: column; overflow: hidden;
|
||||
border: 1px solid var(--border); border-radius: var(--r-md); background: var(--float); box-shadow: var(--shadow-float);
|
||||
}
|
||||
.agent-tx-hd { display: flex; align-items: center; gap: 9px; padding: 8px 8px 8px 14px; border-bottom: 1px solid var(--hair); }
|
||||
.agent-tx-hd .who { font: var(--w-med) var(--fs-ui) var(--ui); color: var(--deleg); }
|
||||
.agent-tx-hd .rt { margin-left: auto; font: var(--w-reg) var(--fs-meta) var(--mono); color: var(--faint); }
|
||||
.agent-tx-x {
|
||||
flex: none; width: 26px; height: 26px; display: grid; place-items: center; padding: 0; border: 0;
|
||||
border-radius: var(--r-xs); background: transparent; color: var(--muted); cursor: pointer;
|
||||
}
|
||||
.agent-tx-x .studio-icon { width: 12px; height: 12px; }
|
||||
.agent-tx-x:hover { background: var(--overlay); color: var(--text); }
|
||||
.agent-tx-x:focus-visible { outline: 2px solid var(--focus); outline-offset: -1px; }
|
||||
.agent-tx-bd { flex: 1 1 auto; min-height: 0; overflow-y: auto; padding: 6px 14px 10px; }
|
||||
.agent-tx .nest-ret { flex: none; max-height: 30vh; overflow-y: auto; margin: 4px 14px 14px; white-space: pre-wrap; }
|
||||
.nest-bd :is(.term, .term-l) { white-space: pre-wrap; overflow-wrap: anywhere; }
|
||||
.agent-tx .call { content-visibility: auto; contain-intrinsic-size: auto 80px; }
|
||||
.agent-tx:focus { outline: none; }
|
||||
@@ -0,0 +1,26 @@
|
||||
import { t } from "../i18n";
|
||||
import type { AgentPort } from "../port/port";
|
||||
import { AuthorLine } from "./AuthorLine";
|
||||
import { Community } from "./Community";
|
||||
import { Group } from "./Group";
|
||||
import { Versions } from "./Versions";
|
||||
|
||||
export function About({ port }: { port: AgentPort }) {
|
||||
return (
|
||||
<>
|
||||
<Group id="versions"
|
||||
title={t("版本")}
|
||||
hint={t("当前安装的版本、可用更新,以及出现问题时如何回退。更新下载好后由你决定何时重启;回退后会固定在所选版本,不再提示新版本。")}
|
||||
>
|
||||
<Versions port={port} />
|
||||
</Group>
|
||||
<AuthorLine port={port} />
|
||||
<Group id="community"
|
||||
title={t("社区与贡献者")}
|
||||
hint={t("遇到问题、想交流用法或想参与进来,可以加入社区;下面是为 Reasonix 提交过代码的人。")}
|
||||
>
|
||||
<Community port={port} />
|
||||
</Group>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -52,6 +52,7 @@ export function AddPlugin({ port, onClose, onInstalled, updating, source, onAppl
|
||||
});
|
||||
|
||||
const look = async () => {
|
||||
if (busy || !text.trim()) return;
|
||||
setBusy(true);
|
||||
setError("");
|
||||
try {
|
||||
@@ -90,8 +91,17 @@ export function AddPlugin({ port, onClose, onInstalled, updating, source, onAppl
|
||||
};
|
||||
|
||||
const pick = async () => {
|
||||
const dir = await port.pickFolder();
|
||||
if (dir) setText(dir);
|
||||
if (busy) return;
|
||||
setBusy(true);
|
||||
setError("");
|
||||
try {
|
||||
const dir = await port.pickFolder();
|
||||
if (dir) setText(dir);
|
||||
} catch (e) {
|
||||
setError(reason(e));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
// An update has nothing to type: its source was recorded at install. Going
|
||||
@@ -106,7 +116,7 @@ export function AddPlugin({ port, onClose, onInstalled, updating, source, onAppl
|
||||
// you agree to install it. A tab has no path to fill it with, so nothing is
|
||||
// typed there and the box still takes an address.
|
||||
const [over, setOver] = useState(false);
|
||||
const drop = useFileDrop((d) => d.paths[0] && setText(d.paths[0]), setOver);
|
||||
const drop = useFileDrop((d) => !busy && d.paths[0] && setText(d.paths[0]), setOver);
|
||||
|
||||
if (done) {
|
||||
return (
|
||||
@@ -160,7 +170,7 @@ export function AddPlugin({ port, onClose, onInstalled, updating, source, onAppl
|
||||
<button
|
||||
className="act"
|
||||
data-action={updating ? "extensions.cancel" : "extensions.back"}
|
||||
disabled={!!updating && busy}
|
||||
disabled={busy}
|
||||
onClick={() => (updating ? onClose() : setPlan(null))}
|
||||
>
|
||||
{t(updating ? "取消" : "返回")}
|
||||
@@ -200,22 +210,26 @@ export function AddPlugin({ port, onClose, onInstalled, updating, source, onAppl
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="addpkg" data-stage="paste" ref={drop} data-over={over ? "" : undefined}>
|
||||
<div className="addpkg" data-stage="paste" ref={drop} data-over={over ? "" : undefined} aria-busy={busy}>
|
||||
<textarea
|
||||
className="paste"
|
||||
data-action-keydown="extensions.inspect"
|
||||
rows={3}
|
||||
autoFocus
|
||||
value={text}
|
||||
disabled={busy}
|
||||
placeholder={PLACEHOLDER}
|
||||
onChange={(e) => setText(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if ((e.metaKey || e.ctrlKey) && e.key === "Enter") void look();
|
||||
if ((e.metaKey || e.ctrlKey) && e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
void look();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<div className="acts">
|
||||
<span className="note">{t("仓库地址,或将文件夹拖入此处")}</span>
|
||||
<button className="act" data-action="extensions.pick-folder" onClick={() => void pick()}>
|
||||
<button className="act" data-action="extensions.pick-folder" disabled={busy} onClick={() => void pick()}>
|
||||
{t("选文件夹")}
|
||||
</button>
|
||||
<button className="act" data-action="extensions.cancel" onClick={onClose}>
|
||||
@@ -231,7 +245,7 @@ export function AddPlugin({ port, onClose, onInstalled, updating, source, onAppl
|
||||
{t(busy ? "读取中…" : "查看内容")}
|
||||
</button>
|
||||
</div>
|
||||
{error && <div className="why">{error}</div>}
|
||||
{error && <div className="why" role="alert">{error}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -358,16 +372,28 @@ function contributes(a: PluginAction): string[] {
|
||||
// leaves the package on disk and out of this session, and saying so is the
|
||||
// difference between waiting and re-installing.
|
||||
export function Outcome({ plan }: { plan: PluginPlan }) {
|
||||
const state = plan.reloadError ? "action_required" : plan.ok ? "ready" : "issue";
|
||||
return (
|
||||
const state = !plan.ok ? "issue" : plan.reloadError ? "action_required" : "ready";
|
||||
const failed = plan.actions?.filter((action) => action.status === "failed") ?? [];
|
||||
return <>
|
||||
<div className="outcome" data-state={state}>
|
||||
<i className="pip" />
|
||||
<span className="nm">{plan.actions?.[0]?.name || t("安装")}</span>
|
||||
<span className="dt">
|
||||
{state === "ready" && t("装好了,下一轮就能用")}
|
||||
{state === "action_required" && t("装好了,但这一轮还在跑:等它结束或新建会话后生效")}
|
||||
{state === "issue" && (plan.error || plan.next || t("没装上"))}
|
||||
{state === "action_required" && t("装好了,但运行时未重载:{reason}。请用「重载运行时」重试。", { reason: plan.reloadError! })}
|
||||
{state === "issue" && (plan.error || (failed.length ? t("有项目未安装成功,原因见下方。") : plan.next || t("没装上")))}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
{failed.map((action, i) => (
|
||||
<div className="why" role="alert" key={`${action.kind}:${action.name}:${i}`}>
|
||||
{action.name || action.kind}: {action.error || action.next || t("没装上")}
|
||||
{action.error && action.next && <div>{action.next}</div>}
|
||||
</div>
|
||||
))}
|
||||
{state === "issue" && plan.reloadError && (
|
||||
<div className="why" role="status">
|
||||
{t("运行时未重载:{reason}。请用「重载运行时」重试。", { reason: plan.reloadError })}
|
||||
</div>
|
||||
)}
|
||||
</>;
|
||||
}
|
||||
@@ -64,3 +64,130 @@ it("keeps relay-only controls optional and saves them when disclosed", async ()
|
||||
headers: { "X-Title": "Reasonix" },
|
||||
}));
|
||||
});
|
||||
|
||||
const nameCatalog = async () => [{ kind: "openai", discovery: "openai", serverWebSearch: false, reasoningParams: true }];
|
||||
|
||||
it("explains an unusable source name next to the field while it is typed and holds the add button", async () => {
|
||||
const saveProvider = vi.fn(async () => {});
|
||||
const port = { protocols: vi.fn(nameCatalog), saveProvider } as unknown as Port;
|
||||
|
||||
render(<AddProvider port={port} taken={[]} known={[]} onDone={() => {}} onCancel={() => {}} />);
|
||||
const field = screen.getByLabelText("来源名称");
|
||||
await userEvent.type(field, "公司中转站");
|
||||
await userEvent.type(screen.getByLabelText("接口地址"), "https://relay.example/v1");
|
||||
await userEvent.type(screen.getByRole("searchbox", { name: "搜索或添加模型" }), "m1{enter}");
|
||||
|
||||
expect(field.getAttribute("aria-invalid")).toBe("true");
|
||||
expect(screen.getByText(/名称只能用字母、数字、点、连字符和下划线/)).toBeTruthy();
|
||||
expect((screen.getByRole("button", { name: "添加来源" }) as HTMLButtonElement).disabled).toBe(true);
|
||||
expect(screen.queryByText("无法连接")).toBeNull();
|
||||
|
||||
await userEvent.clear(field);
|
||||
await userEvent.type(field, "company-relay");
|
||||
expect(field.getAttribute("aria-invalid")).toBeNull();
|
||||
expect((screen.getByRole("button", { name: "添加来源" }) as HTMLButtonElement).disabled).toBe(false);
|
||||
});
|
||||
|
||||
it("shows a coded name refusal from the kernel on the name field, not as a connection failure", async () => {
|
||||
const { HttpError } = await import("../port/http_error");
|
||||
const saveProvider = vi.fn(async () => {
|
||||
throw new HttpError(409, "taken", { code: "provider.name_taken", params: { name: "relay" } });
|
||||
});
|
||||
const port = { protocols: vi.fn(nameCatalog), saveProvider } as unknown as Port;
|
||||
|
||||
render(<AddProvider port={port} taken={[]} known={[]} onDone={() => {}} onCancel={() => {}} />);
|
||||
await userEvent.type(screen.getByLabelText("来源名称"), "relay");
|
||||
await userEvent.type(screen.getByLabelText("接口地址"), "https://relay.example/v1");
|
||||
await userEvent.type(screen.getByRole("searchbox", { name: "搜索或添加模型" }), "m1{enter}");
|
||||
await userEvent.click(screen.getByRole("button", { name: "添加来源" }));
|
||||
|
||||
const field = screen.getByLabelText("来源名称");
|
||||
await waitFor(() => expect(field.getAttribute("aria-invalid")).toBe("true"));
|
||||
expect(screen.getByText("已经有名为「relay」的连接了,换一个名称")).toBeTruthy();
|
||||
expect(screen.queryByText("无法连接")).toBeNull();
|
||||
expect(screen.queryByText("无法保存")).toBeNull();
|
||||
});
|
||||
|
||||
it("titles a failed save as a save failure, never as a connection failure", async () => {
|
||||
const { HttpError } = await import("../port/http_error");
|
||||
const saveProvider = vi.fn(async () => {
|
||||
throw new HttpError(422, "pick at least one model", { code: "provider.endpoint_required" });
|
||||
});
|
||||
const port = { protocols: vi.fn(nameCatalog), saveProvider } as unknown as Port;
|
||||
|
||||
render(<AddProvider port={port} taken={[]} known={[]} onDone={() => {}} onCancel={() => {}} />);
|
||||
await userEvent.type(screen.getByLabelText("来源名称"), "relay");
|
||||
await userEvent.type(screen.getByLabelText("接口地址"), "https://relay.example/v1");
|
||||
await userEvent.type(screen.getByRole("searchbox", { name: "搜索或添加模型" }), "m1{enter}");
|
||||
await userEvent.click(screen.getByRole("button", { name: "添加来源" }));
|
||||
|
||||
expect(await screen.findByText("无法保存")).toBeTruthy();
|
||||
expect(screen.queryByText("无法连接")).toBeNull();
|
||||
});
|
||||
|
||||
it("ties the name rule to the field and the button, and waits out IME composition", async () => {
|
||||
const { fireEvent } = await import("@testing-library/react");
|
||||
const port = { protocols: vi.fn(nameCatalog), saveProvider: vi.fn() } as unknown as Port;
|
||||
render(<AddProvider port={port} taken={[]} known={[]} onDone={() => {}} onCancel={() => {}} />);
|
||||
const field = screen.getByLabelText("来源名称");
|
||||
expect(field.getAttribute("aria-describedby")).toBe("addp-name-rule");
|
||||
|
||||
fireEvent.compositionStart(field);
|
||||
fireEvent.change(field, { target: { value: "gong'si" } });
|
||||
expect(field.getAttribute("aria-invalid")).toBeNull();
|
||||
fireEvent.compositionEnd(field);
|
||||
expect(field.getAttribute("aria-invalid")).toBe("true");
|
||||
expect(screen.getByRole("button", { name: "添加来源" }).getAttribute("aria-describedby")).toBe("addp-name-rule");
|
||||
});
|
||||
|
||||
it("keeps Add disabled while an IME name is still being composed", async () => {
|
||||
const { fireEvent } = await import("@testing-library/react");
|
||||
const saveProvider = vi.fn();
|
||||
const port = { protocols: vi.fn(nameCatalog), saveProvider } as unknown as Port;
|
||||
render(<AddProvider port={port} taken={[]} known={[]} onDone={() => {}} onCancel={() => {}} />);
|
||||
|
||||
const name = screen.getByLabelText("来源名称");
|
||||
fireEvent.change(name, { target: { value: "company-relay" } });
|
||||
fireEvent.change(screen.getByLabelText("接口地址"), { target: { value: "https://relay.example/v1" } });
|
||||
const model = screen.getByRole("searchbox", { name: "搜索或添加模型" });
|
||||
fireEvent.change(model, { target: { value: "m1" } });
|
||||
fireEvent.keyDown(model, { key: "Enter", code: "Enter", charCode: 13 });
|
||||
const add = screen.getByRole("button", { name: "添加来源" }) as HTMLButtonElement;
|
||||
await waitFor(() => expect(add.disabled).toBe(false));
|
||||
|
||||
fireEvent.compositionStart(name);
|
||||
fireEvent.change(name, { target: { value: "公司中转站" } });
|
||||
expect(add.disabled).toBe(true);
|
||||
fireEvent.click(add);
|
||||
expect(saveProvider).not.toHaveBeenCalled();
|
||||
|
||||
fireEvent.compositionEnd(name);
|
||||
expect(add.disabled).toBe(true);
|
||||
fireEvent.change(name, { target: { value: "company-relay" } });
|
||||
expect(add.disabled).toBe(false);
|
||||
});
|
||||
|
||||
it("normalizes spaces around an ASCII name and refuses a blank one", async () => {
|
||||
const { fireEvent } = await import("@testing-library/react");
|
||||
const saveProvider = vi.fn(async () => {});
|
||||
const port = { protocols: vi.fn(nameCatalog), saveProvider } as unknown as Port;
|
||||
render(<AddProvider port={port} taken={[]} known={[]} onDone={() => {}} onCancel={() => {}} />);
|
||||
|
||||
const name = screen.getByLabelText("来源名称");
|
||||
const add = screen.getByRole("button", { name: "添加来源" }) as HTMLButtonElement;
|
||||
expect(add.disabled).toBe(true);
|
||||
fireEvent.change(name, { target: { value: " " } });
|
||||
expect(add.disabled).toBe(true);
|
||||
|
||||
fireEvent.change(name, { target: { value: " company-relay.1_x " } });
|
||||
fireEvent.change(screen.getByLabelText("接口地址"), { target: { value: "https://relay.example/v1" } });
|
||||
const model = screen.getByRole("searchbox", { name: "搜索或添加模型" });
|
||||
fireEvent.change(model, { target: { value: "m1" } });
|
||||
fireEvent.keyDown(model, { key: "Enter", code: "Enter", charCode: 13 });
|
||||
await waitFor(() => expect(add.disabled).toBe(false));
|
||||
|
||||
fireEvent.click(add);
|
||||
await waitFor(() => expect(saveProvider).toHaveBeenCalledWith(expect.objectContaining({
|
||||
name: "company-relay.1_x",
|
||||
})));
|
||||
});
|
||||
@@ -2,14 +2,17 @@ import { useEffect, useState } from "react";
|
||||
import { t } from "../i18n";
|
||||
import type { Protocol, ProviderEntry, ProviderProbe } from "../port/port";
|
||||
import { clearModelCheckFacts, ModelChoice, type ModelFact } from "./ModelChoice";
|
||||
import { KIND_LABEL, hostOf, nameFrom, vendorLabel } from "./vendors";
|
||||
import { KIND_LABEL, hostOf, nameFrom, sourceNameUsable, vendorLabel } from "./vendors";
|
||||
import type { Port } from "./Providers";
|
||||
import { reason } from "../i18n/kernel";
|
||||
import { HttpError } from "../port/http_error";
|
||||
import { THINKING, parseExtraBody, parseHeaders } from "./provider_compat";
|
||||
|
||||
// Detection is assistance, not authority. Compatible relays often expose one
|
||||
// model-list shape while expecting another request protocol, so every durable
|
||||
// value remains editable and a failed probe never blocks manual setup.
|
||||
const NAME_CODES = new Set(["provider.name_required", "provider.name_invalid", "provider.name_taken"]);
|
||||
|
||||
export function AddProvider({
|
||||
port, taken, known, onDone, onCancel,
|
||||
}: {
|
||||
@@ -20,6 +23,9 @@ export function AddProvider({
|
||||
const [probe, setProbe] = useState<ProviderProbe | null>(null);
|
||||
const [catalog, setCatalog] = useState<Protocol[]>([]);
|
||||
const [err, setErr] = useState("");
|
||||
const [errOnSave, setErrOnSave] = useState(false);
|
||||
const [nameErr, setNameErr] = useState("");
|
||||
const [composing, setComposing] = useState(false);
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
// The endpoint says which wires are on the table and the kernel says what
|
||||
@@ -52,6 +58,7 @@ export function AddProvider({
|
||||
const [heads, setHeads] = useState("");
|
||||
const [extra, setExtra] = useState("");
|
||||
const [noProxy, setNoProxy] = useState(false);
|
||||
const nameBad = !composing && name.trim() !== "" && !sourceNameUsable(name);
|
||||
const extraBad = extra.trim() !== "" && parseExtraBody(extra) === null;
|
||||
const protocolCanThink = catalog.find((p) => p.kind === kind)?.reasoningParams ?? false;
|
||||
// A wire with no listing shape has nothing to read: the model id is declared
|
||||
@@ -66,6 +73,7 @@ export function AddProvider({
|
||||
const connect = async () => {
|
||||
setBusy(true);
|
||||
setErr("");
|
||||
setErrOnSave(false);
|
||||
try {
|
||||
const got = await port.probeProvider(baseUrl.trim(), apiKey.trim());
|
||||
setProbe(got);
|
||||
@@ -87,6 +95,8 @@ export function AddProvider({
|
||||
const save = async () => {
|
||||
setBusy(true);
|
||||
setErr("");
|
||||
setNameErr("");
|
||||
setErrOnSave(true);
|
||||
try {
|
||||
await port.saveProvider({
|
||||
name: name.trim(),
|
||||
@@ -107,7 +117,8 @@ export function AddProvider({
|
||||
});
|
||||
onDone();
|
||||
} catch (e) {
|
||||
setErr(reason(e));
|
||||
if (e instanceof HttpError && e.reason?.code && NAME_CODES.has(e.reason.code)) setNameErr(reason(e));
|
||||
else setErr(reason(e));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
@@ -167,18 +178,27 @@ export function AddProvider({
|
||||
<button className="addp-close" onClick={onCancel} disabled={busy || checkingModel !== ""} aria-label={t("取消")}>×</button>
|
||||
</div>
|
||||
<div className="fields">
|
||||
<label className="grow">
|
||||
<div className="grow name-cell">
|
||||
<label>
|
||||
<span>{t("来源名称")}</span>
|
||||
<input
|
||||
aria-label={t("来源名称")}
|
||||
data-action="provider.draft" data-value="name"
|
||||
value={name}
|
||||
placeholder={t("例如:公司中转站")}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
onChange={(e) => { setName(e.target.value); setNameErr(""); }}
|
||||
disabled={busy || checkingModel !== ""}
|
||||
spellCheck={false}
|
||||
aria-invalid={nameBad || nameErr !== "" || undefined}
|
||||
aria-describedby="addp-name-rule"
|
||||
onCompositionStart={() => setComposing(true)}
|
||||
onCompositionEnd={() => setComposing(false)}
|
||||
/>
|
||||
</label>
|
||||
<p id="addp-name-rule" className="name-rule" data-bad={nameBad || nameErr !== "" || undefined} aria-live="polite">
|
||||
{nameErr !== "" ? nameErr : t("名称只能用字母、数字、点、连字符和下划线,以字母或数字开头,最长 64 个字符")}
|
||||
</p>
|
||||
</div>
|
||||
<label className="grow">
|
||||
<span>{t("接口协议")}</span>
|
||||
<select aria-label={t("接口协议")} data-action="provider.draft" data-value="protocol" value={kind} onChange={(e) => {
|
||||
@@ -333,7 +353,7 @@ export function AddProvider({
|
||||
</div>
|
||||
|
||||
<div className="acts addp-footer">
|
||||
<button className="act" data-action="provider.add" data-primary onClick={save} disabled={busy || checkingModel !== "" || picked.length === 0 || name.trim() === "" || kind === "" || baseUrl.trim() === "" || extraBad}>
|
||||
<button className="act" data-action="provider.add" data-primary onClick={save} aria-describedby={nameBad ? "addp-name-rule" : undefined} disabled={busy || composing || checkingModel !== "" || picked.length === 0 || name.trim() === "" || nameBad || kind === "" || baseUrl.trim() === "" || extraBad}>
|
||||
{t(busy ? "保存中…" : "添加来源")}
|
||||
</button>
|
||||
<button className="act" onClick={onCancel} disabled={busy || checkingModel !== ""}>{t("取消")}</button>
|
||||
@@ -341,7 +361,7 @@ export function AddProvider({
|
||||
|
||||
{err && (
|
||||
<div className="find" data-lvl="warn">
|
||||
<span className="t">{t("无法连接")}</span>
|
||||
<span className="t">{errOnSave ? t("无法保存") : t("无法连接")}</span>
|
||||
<span className="why">{err}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -26,7 +26,13 @@ interface Props {
|
||||
onInstalled: () => void;
|
||||
}
|
||||
|
||||
export function AddServer({ port, canProject, onClose, onInstalled }: Props) {
|
||||
export function AddServer(props: Props) {
|
||||
const [connection, setConnection] = useState({ port: props.port, generation: 0 });
|
||||
if (connection.port !== props.port) setConnection({ port: props.port, generation: connection.generation + 1 });
|
||||
return <ServerInput key={connection.generation} {...props} />;
|
||||
}
|
||||
|
||||
function ServerInput({ port, canProject, onClose, onInstalled }: Props) {
|
||||
// Mounted only while open, so this component is the layer Escape closes.
|
||||
useEscape(true, onClose);
|
||||
const [text, setText] = useState("");
|
||||
@@ -35,8 +41,11 @@ export function AddServer({ port, canProject, onClose, onInstalled }: Props) {
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const [results, setResults] = useState<McpInstallResult[]>([]);
|
||||
const remaining = draft?.servers.filter((s) => !results.some((r) => r.name === s.name && r.state !== "issue")) ?? [];
|
||||
const hasSaved = results.some((r) => r.state !== "issue");
|
||||
|
||||
const parse = async () => {
|
||||
if (busy || !text.trim()) return;
|
||||
setBusy(true);
|
||||
setError("");
|
||||
try {
|
||||
@@ -53,19 +62,19 @@ export function AddServer({ port, canProject, onClose, onInstalled }: Props) {
|
||||
if (!draft) return;
|
||||
setBusy(true);
|
||||
setError("");
|
||||
const out: McpInstallResult[] = [];
|
||||
try {
|
||||
const out: McpInstallResult[] = [];
|
||||
for (const s of draft.servers) out.push(await port.installMcp(s, scope));
|
||||
setResults(out);
|
||||
if (out.some((r) => r.state !== "issue")) onInstalled();
|
||||
for (const s of remaining) out.push(await port.installMcp(s, scope));
|
||||
} catch (e) {
|
||||
setError(reason(e));
|
||||
} finally {
|
||||
setResults((previous) => [...new Map([...previous, ...out].map((r) => [r.name, r])).values()]);
|
||||
setBusy(false);
|
||||
if (out.some((r) => r.state !== "issue")) onInstalled();
|
||||
}
|
||||
};
|
||||
|
||||
if (results.length > 0) {
|
||||
if (results.length > 0 && remaining.length === 0 && !error && !busy) {
|
||||
return (
|
||||
<div className="addsrv" data-stage="done">
|
||||
{results.map((r) => (
|
||||
@@ -81,7 +90,7 @@ export function AddServer({ port, canProject, onClose, onInstalled }: Props) {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="addsrv" data-stage={draft ? "confirm" : "paste"}>
|
||||
<div className="addsrv" data-stage={draft ? "confirm" : "paste"} aria-busy={busy}>
|
||||
{!draft && (
|
||||
<>
|
||||
<textarea
|
||||
@@ -93,7 +102,10 @@ export function AddServer({ port, canProject, onClose, onInstalled }: Props) {
|
||||
placeholder={PLACEHOLDER}
|
||||
onChange={(e) => setText(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if ((e.metaKey || e.ctrlKey) && e.key === "Enter") void parse();
|
||||
if ((e.metaKey || e.ctrlKey) && e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
void parse();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<div className="acts">
|
||||
@@ -110,7 +122,8 @@ export function AddServer({ port, canProject, onClose, onInstalled }: Props) {
|
||||
|
||||
{draft && (
|
||||
<>
|
||||
{draft.servers.map((s) => (
|
||||
{results.map((r) => <Outcome key={r.name} r={r} />)}
|
||||
{remaining.map((s) => (
|
||||
<div className="cand" key={s.name}>
|
||||
<div className="cand-hd">
|
||||
<span className="nm">{s.name}</span>
|
||||
@@ -131,13 +144,13 @@ export function AddServer({ port, canProject, onClose, onInstalled }: Props) {
|
||||
question. Only the third option edits a tracked file, so it is the
|
||||
one that has to say so out loud — and it is never the default. */}
|
||||
<div className="scope" role="radiogroup" aria-label={t("安装位置")}>
|
||||
<button role="radio" aria-checked={scope === "user"} onClick={() => setScope("user")}>
|
||||
<button role="radio" aria-checked={scope === "user"} disabled={busy || hasSaved} onClick={() => setScope("user")}>
|
||||
{t("我的")}<i>{t("所有项目均可使用")}</i>
|
||||
</button>
|
||||
<button
|
||||
role="radio"
|
||||
aria-checked={scope === "local"}
|
||||
disabled={!canProject}
|
||||
disabled={busy || hasSaved || !canProject}
|
||||
onClick={() => setScope("local")}
|
||||
>
|
||||
{t("仅当前项目")}<i>{t("不写入仓库,他人不会获得")}</i>
|
||||
@@ -145,7 +158,7 @@ export function AddServer({ port, canProject, onClose, onInstalled }: Props) {
|
||||
<button
|
||||
role="radio"
|
||||
aria-checked={scope === "project"}
|
||||
disabled={!canProject}
|
||||
disabled={busy || hasSaved || !canProject}
|
||||
onClick={() => setScope("project")}
|
||||
>
|
||||
{t("写进仓库")}<i>{t("clone 仓库的人也会获得")}</i>
|
||||
@@ -155,7 +168,7 @@ export function AddServer({ port, canProject, onClose, onInstalled }: Props) {
|
||||
<div className="warn">{t("这会修改仓库中的配置文件,属于一处待提交的改动。")}</div>
|
||||
)}
|
||||
<div className="acts">
|
||||
<button className="act" onClick={() => setDraft(null)}>
|
||||
<button className="act" disabled={busy} onClick={() => { setDraft(null); setResults([]); setError(""); }}>
|
||||
{t("返回")}
|
||||
</button>
|
||||
<button className="act" data-action="mcp.add" data-primary disabled={busy} onClick={() => void install()}>
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { t } from "../i18n";
|
||||
|
||||
interface Props {
|
||||
busy: boolean;
|
||||
onSubmit: (path: string) => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
/** The picker-free path into a workspace. It only appears when the kernel says
|
||||
* it has no native picker, so a desktop shell keeps its familiar dialog. */
|
||||
export function AddWorkspacePrompt({ busy, onSubmit, onClose }: Props) {
|
||||
const [path, setPath] = useState("");
|
||||
const input = useRef<HTMLInputElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
input.current?.focus();
|
||||
}, []);
|
||||
|
||||
return createPortal(
|
||||
<div className="pickveil" role="dialog" aria-modal="true" aria-labelledby="addws-t">
|
||||
<form
|
||||
className="pickcard"
|
||||
data-action="workspace.add"
|
||||
onSubmit={(ev) => {
|
||||
ev.preventDefault();
|
||||
onSubmit(path);
|
||||
}}
|
||||
>
|
||||
<h2 id="addws-t">{t("添加工作区")}</h2>
|
||||
<p className="picknote">{t("无法打开文件夹选择器。请输入内核所在机器上的路径。")}</p>
|
||||
<div className="pickpath">
|
||||
<input
|
||||
ref={input}
|
||||
value={path}
|
||||
spellCheck={false}
|
||||
dir="ltr"
|
||||
placeholder="/srv/project"
|
||||
aria-label={t("工作区路径")}
|
||||
data-action="workspace.add"
|
||||
onChange={(ev) => setPath(ev.target.value)}
|
||||
/>
|
||||
<button type="submit" className="pickgo" data-action="workspace.add" disabled={busy || !path.trim()}>
|
||||
{t("添加")}
|
||||
</button>
|
||||
</div>
|
||||
<div className="pickact">
|
||||
<button type="button" data-action="workspace.add-cancel" disabled={busy} onClick={onClose}>{t("取消")}</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
@@ -25,6 +25,7 @@ import { usePaint } from "./paint";
|
||||
import { rememberActivePane, savedActivePane } from "./activepane";
|
||||
import { Sidebar } from "./Sidebar";
|
||||
import { Sky } from "./Sky";import { useAddWorkspace } from "./addws";
|
||||
import { AddWorkspacePrompt } from "./AddWorkspacePrompt";
|
||||
import { PaneTabs } from "./PaneTabs";
|
||||
import { Onboarding } from "./Onboarding";
|
||||
import { Welcome } from "./Welcome";
|
||||
@@ -754,6 +755,8 @@ export function App({ hub }: { hub: HubPort }) {
|
||||
|
||||
</div>
|
||||
|
||||
{adder.pathOpen && <AddWorkspacePrompt busy={adder.busy} onSubmit={adder.addPath} onClose={adder.closePath} />}
|
||||
|
||||
{feedback && (networkPort ?? activePort) && (
|
||||
<Feedback port={(networkPort ?? activePort)!} tab={feedback} onClose={() => setFeedback(null)} onError={setError} onUnread={setFeedbackUnread} />
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
import { t } from "../i18n";
|
||||
import type { AgentPort } from "../port/port";
|
||||
import { COMMUNITY } from "./communityLinks";
|
||||
|
||||
export function AuthorLine({ port }: { port: Pick<AgentPort, "openExternal"> }) {
|
||||
return (
|
||||
<div className="comm-author">
|
||||
<span className="comm-k">{t("作者与维护者")}</span>
|
||||
<a href={COMMUNITY.authorUrl} data-action="community.author" onClick={(e) => { e.preventDefault(); void port.openExternal(COMMUNITY.authorUrl).catch(() => {}); }}>{COMMUNITY.author}</a>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
// @vitest-environment jsdom
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { act, cleanup, render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import "./testkit";
|
||||
import { BackupRestore } from "./BackupRestore";
|
||||
import { MockPort } from "../port/mock";
|
||||
import type { AgentPort, BackupEntry, PluginPlan } from "../port/port";
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
const backup: BackupEntry = {
|
||||
id: "plugins", label: "laptop", format: 1, appVersion: "2.20.5", platform: "darwin/arm64",
|
||||
categories: ["extensions"], ciphertextBytes: 2048, createdAt: "2026-09-27T00:00:00Z",
|
||||
};
|
||||
const sources = { alpha: "/packages/alpha", beta: "/packages/beta" };
|
||||
|
||||
function plan(name: keyof typeof sources): PluginPlan {
|
||||
return {
|
||||
ok: true, status: "planned", applied: false, planId: `ticket-${name}`, actions: [{
|
||||
kind: "plugin", action: "install_plugin_package", name, status: "planned", riskLevel: "low", skillCount: 1,
|
||||
}],
|
||||
};
|
||||
}
|
||||
|
||||
function deferred<T>() {
|
||||
let resolve!: (value: T) => void;
|
||||
let reject!: (error: Error) => void;
|
||||
const promise = new Promise<T>((yes, no) => { resolve = yes; reject = no; });
|
||||
return { promise, resolve, reject };
|
||||
}
|
||||
|
||||
async function setup() {
|
||||
const port = new MockPort() as unknown as AgentPort;
|
||||
vi.spyOn(port, "applyBackup").mockResolvedValue({
|
||||
applied: ["plugins"], plugins: Object.entries(sources).map(([name, source]) => ({ name, source })),
|
||||
});
|
||||
const preview = vi.spyOn(port, "planPlugin").mockImplementation(async ({ source }) => plan(source === sources.alpha ? "alpha" : "beta"));
|
||||
const install = vi.spyOn(port, "installPlugin").mockResolvedValue({ ...plan("alpha"), status: "done", applied: true, actions: [] });
|
||||
render(<BackupRestore port={port} backup={backup} onClose={() => {}} />);
|
||||
await userEvent.type(screen.getByLabelText("加密口令"), "local passphrase");
|
||||
await userEvent.click(screen.getByRole("button", { name: "预览差异" }));
|
||||
await userEvent.click(await screen.findByRole("button", { name: /恢复所选/ }));
|
||||
await screen.findByText("以下插件需要走正常的安装确认:");
|
||||
return { port, preview, install };
|
||||
}
|
||||
|
||||
async function select(name: keyof typeof sources) {
|
||||
await userEvent.click(document.querySelector<HTMLButtonElement>(`[data-action="backup.install-plugin"][data-target="${name}"]`)!);
|
||||
}
|
||||
|
||||
describe("restored plugin installation ownership", () => {
|
||||
it("switches the source before inspection and disables the already selected row", async () => {
|
||||
const { preview, install } = await setup();
|
||||
await select("alpha");
|
||||
expect(screen.getByRole<HTMLTextAreaElement>("textbox").value).toBe(sources.alpha);
|
||||
expect(document.querySelector<HTMLButtonElement>('[data-action="backup.install-plugin"][data-target="alpha"]')!.disabled).toBe(true);
|
||||
await select("beta");
|
||||
expect(screen.getByRole<HTMLTextAreaElement>("textbox").value).toBe(sources.beta);
|
||||
await userEvent.click(screen.getByRole("button", { name: "查看内容" }));
|
||||
await screen.findByRole("button", { name: "安装" });
|
||||
expect(preview).toHaveBeenCalledOnce();
|
||||
expect(preview).toHaveBeenCalledWith(expect.objectContaining({ source: sources.beta }));
|
||||
expect(install).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("discards the previous confirmation and applies only the newly inspected ticket", async () => {
|
||||
const { preview, install } = await setup();
|
||||
await select("alpha");
|
||||
await userEvent.click(screen.getByRole("button", { name: "查看内容" }));
|
||||
await screen.findByRole("button", { name: "安装" });
|
||||
await select("beta");
|
||||
expect(screen.queryByRole("button", { name: "安装" })).toBeNull();
|
||||
expect(screen.getByRole<HTMLTextAreaElement>("textbox").value).toBe(sources.beta);
|
||||
await userEvent.click(screen.getByRole("button", { name: "查看内容" }));
|
||||
await userEvent.click(await screen.findByRole("button", { name: "安装" }));
|
||||
await waitFor(() => expect(screen.queryByRole("textbox")).toBeNull());
|
||||
expect(preview).toHaveBeenCalledTimes(2);
|
||||
expect(install).toHaveBeenCalledOnce();
|
||||
expect(install).toHaveBeenCalledWith(expect.objectContaining({ source: sources.beta, planId: "ticket-beta" }));
|
||||
});
|
||||
|
||||
it.each(["success", "failure"])("ignores an old pending preview's %s after switching", async (outcome) => {
|
||||
const { preview, install } = await setup();
|
||||
const pending = deferred<PluginPlan>();
|
||||
preview.mockImplementationOnce(() => pending.promise);
|
||||
await select("alpha");
|
||||
await userEvent.click(screen.getByRole("button", { name: "查看内容" }));
|
||||
await select("beta");
|
||||
expect(screen.getByRole<HTMLTextAreaElement>("textbox").value).toBe(sources.beta);
|
||||
await act(async () => {
|
||||
if (outcome === "success") pending.resolve(plan("alpha"));
|
||||
else pending.reject(new Error("old alpha preview failed"));
|
||||
await pending.promise.catch(() => {});
|
||||
});
|
||||
expect(screen.queryByText("old alpha preview failed")).toBeNull();
|
||||
expect(screen.queryByRole("button", { name: "安装" })).toBeNull();
|
||||
expect(screen.getByRole<HTMLButtonElement>("button", { name: "查看内容" }).disabled).toBe(false);
|
||||
await userEvent.click(screen.getByRole("button", { name: "查看内容" }));
|
||||
await userEvent.click(await screen.findByRole("button", { name: "安装" }));
|
||||
expect(install).toHaveBeenCalledWith(expect.objectContaining({ source: sources.beta, planId: "ticket-beta" }));
|
||||
});
|
||||
|
||||
it.each(["success", "failure", "refusal"])("keeps the new confirmation when an old install returns %s", async (outcome) => {
|
||||
const { install } = await setup();
|
||||
const pending = deferred<PluginPlan>();
|
||||
install.mockImplementationOnce(() => pending.promise);
|
||||
await select("alpha");
|
||||
await userEvent.click(screen.getByRole("button", { name: "查看内容" }));
|
||||
await userEvent.click(await screen.findByRole("button", { name: "安装" }));
|
||||
await select("beta");
|
||||
await userEvent.click(screen.getByRole("button", { name: "查看内容" }));
|
||||
await screen.findByRole("button", { name: "安装" });
|
||||
await act(async () => {
|
||||
if (outcome === "failure") pending.reject(new Error("old alpha install failed"));
|
||||
else pending.resolve({ ...plan("alpha"), ok: outcome === "success", status: outcome === "success" ? "done" : "blocked", applied: outcome === "success", actions: [] });
|
||||
await pending.promise.catch(() => {});
|
||||
});
|
||||
expect(screen.queryByText("old alpha install failed")).toBeNull();
|
||||
expect(screen.getByRole<HTMLButtonElement>("button", { name: "安装" }).disabled).toBe(false);
|
||||
await userEvent.click(screen.getByRole("button", { name: "安装" }));
|
||||
expect(install).toHaveBeenNthCalledWith(1, expect.objectContaining({ source: sources.alpha, planId: "ticket-alpha" }));
|
||||
expect(install).toHaveBeenNthCalledWith(2, expect.objectContaining({ source: sources.beta, planId: "ticket-beta" }));
|
||||
});
|
||||
|
||||
it("does not dismiss a later selection of the same plugin when its first install completes", async () => {
|
||||
const { install } = await setup();
|
||||
const pending = deferred<PluginPlan>();
|
||||
install.mockImplementationOnce(() => pending.promise);
|
||||
await select("alpha");
|
||||
await userEvent.click(screen.getByRole("button", { name: "查看内容" }));
|
||||
await userEvent.click(await screen.findByRole("button", { name: "安装" }));
|
||||
await select("beta");
|
||||
await select("alpha");
|
||||
await userEvent.click(screen.getByRole("button", { name: "查看内容" }));
|
||||
await screen.findByRole("button", { name: "安装" });
|
||||
await act(async () => { pending.resolve({ ...plan("alpha"), status: "done", applied: true, actions: [] }); await pending.promise; });
|
||||
expect(screen.getByRole<HTMLButtonElement>("button", { name: "安装" }).disabled).toBe(false);
|
||||
await userEvent.click(screen.getByRole("button", { name: "安装" }));
|
||||
expect(install).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
@@ -99,7 +99,7 @@ export function BackupRestore({ port, backup, onClose }: { port: AgentPort; back
|
||||
<span className="nm">{p.name}</span>
|
||||
<code className="meta">{p.source}{p.version ? " · " + p.version : ""}</code>
|
||||
</div>
|
||||
<button className="btn sm" data-action="backup.install-plugin" data-target={p.name} onClick={() => setInstalling(p)}>
|
||||
<button className="btn sm" data-action="backup.install-plugin" data-target={p.name} disabled={installing?.name === p.name} onClick={() => setInstalling({ ...p })}>
|
||||
{t("安装…")}
|
||||
</button>
|
||||
</li>
|
||||
@@ -111,7 +111,7 @@ export function BackupRestore({ port, backup, onClose }: { port: AgentPort; back
|
||||
<button className="act" data-action="backup.done" onClick={onClose}>{t("完成")}</button>
|
||||
</div>
|
||||
{installing && (
|
||||
<AddPlugin port={port} source={installing.source} onClose={() => setInstalling(null)} onInstalled={() => setInstalling(null)} />
|
||||
<AddPlugin key={installing.name} port={port} source={installing.source} onClose={() => setInstalling(null)} onInstalled={() => setInstalling((current) => current === installing ? null : current)} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
// @vitest-environment jsdom
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { cleanup, render, screen, waitFor, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import "./testkit";
|
||||
import { Community } from "./Community";
|
||||
import { AuthorLine } from "./AuthorLine";
|
||||
import { profileUrl } from "./communityLinks";
|
||||
import contributors from "../data/contributors.json";
|
||||
import type { AgentPort } from "../port/port";
|
||||
|
||||
const QR = import.meta.glob("../assets/qq-group-qr.svg", { query: "?raw", import: "default", eager: true }) as Record<string, string>;
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
const portWith = (openExternal = vi.fn(async () => {})) => ({ openExternal }) as unknown as Pick<AgentPort, "openExternal">;
|
||||
|
||||
describe("community and contributors", () => {
|
||||
it("names the group and shows its number", () => {
|
||||
render(<Community port={portWith()} logins={["a"]} />);
|
||||
expect(screen.getByText("DeepSeek-Reasonix官方群")).toBeTruthy();
|
||||
expect(screen.getByText("1093562660")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("opens the join link, Discord and issues through the host", async () => {
|
||||
const openExternal = vi.fn(async () => {});
|
||||
render(<Community port={portWith(openExternal)} logins={["a"]} />);
|
||||
await userEvent.click(screen.getByRole("button", { name: "加入 QQ 群" }));
|
||||
expect(openExternal).toHaveBeenLastCalledWith("https://qm.qq.com/q/i59b0z2R8s");
|
||||
await userEvent.click(screen.getByRole("button", { name: "Discord" }));
|
||||
expect(openExternal).toHaveBeenLastCalledWith("https://discord.gg/XF78rEME2D");
|
||||
await userEvent.click(screen.getByRole("button", { name: "GitHub issues" }));
|
||||
expect(openExternal).toHaveBeenLastCalledWith("https://github.com/esengine/DeepSeek-Reasonix/issues");
|
||||
});
|
||||
|
||||
it("says so, with the address, when the host cannot open it", async () => {
|
||||
const openExternal = vi.fn(async () => { throw new Error("no browser"); });
|
||||
render(<Community port={portWith(openExternal)} logins={[]} />);
|
||||
await userEvent.click(screen.getByRole("button", { name: "Discord" }));
|
||||
expect((await screen.findByRole("alert")).textContent).toContain("https://discord.gg/XF78rEME2D");
|
||||
});
|
||||
|
||||
it("copies the group number", async () => {
|
||||
const writeText = vi.fn(async () => {});
|
||||
Object.defineProperty(navigator, "clipboard", { value: { writeText }, configurable: true });
|
||||
render(<Community port={portWith()} logins={[]} />);
|
||||
await userEvent.click(screen.getByRole("button", { name: "复制群号" }));
|
||||
await waitFor(() => expect(writeText).toHaveBeenCalledWith("1093562660"));
|
||||
});
|
||||
|
||||
it("references a QR image that exists", () => {
|
||||
render(<Community port={portWith()} logins={[]} />);
|
||||
const img = screen.getByRole("img", { name: "QQ 群二维码" }) as HTMLImageElement;
|
||||
expect(img.getAttribute("src")).toBeTruthy();
|
||||
expect(Object.values(QR)[0]).toMatch(/^<svg /);
|
||||
});
|
||||
|
||||
it("shows the Douyin ID, copies it, and keeps the QR hidden until asked", async () => {
|
||||
const writeText = vi.fn(async () => {});
|
||||
Object.defineProperty(navigator, "clipboard", { value: { writeText }, configurable: true });
|
||||
render(<Community port={portWith()} logins={[]} />);
|
||||
expect(screen.getByText("做游戏的小鱼")).toBeTruthy();
|
||||
expect(screen.getByText("22703872788")).toBeTruthy();
|
||||
expect(screen.queryByRole("img", { name: "抖音二维码" })).toBeNull();
|
||||
await userEvent.click(screen.getByRole("button", { name: "复制抖音号" }));
|
||||
await waitFor(() => expect(writeText).toHaveBeenCalledWith("22703872788"));
|
||||
const toggle = screen.getByRole("button", { name: "显示二维码" });
|
||||
await userEvent.click(toggle);
|
||||
expect(toggle.getAttribute("aria-expanded")).toBe("true");
|
||||
expect((screen.getByRole("img", { name: "抖音二维码" }) as HTMLImageElement).getAttribute("src")).toBeTruthy();
|
||||
await userEvent.click(screen.getByRole("button", { name: "收起二维码" }));
|
||||
expect(screen.queryByRole("img", { name: "抖音二维码" })).toBeNull();
|
||||
});
|
||||
|
||||
it("has no Douyin link, only the ID", () => {
|
||||
const { container } = render(<Community port={portWith()} logins={[]} />);
|
||||
expect(container.innerHTML).not.toMatch(/douyin\.com/);
|
||||
});
|
||||
|
||||
it("links the author to their GitHub profile through the host", async () => {
|
||||
const openExternal = vi.fn(async () => {});
|
||||
render(<AuthorLine port={portWith(openExternal)} />);
|
||||
expect(screen.getByText("作者与维护者")).toBeTruthy();
|
||||
const link = screen.getByRole("link", { name: "esengine" });
|
||||
expect(link.getAttribute("href")).toBe("https://github.com/esengine");
|
||||
await userEvent.click(link);
|
||||
expect(openExternal).toHaveBeenCalledWith("https://github.com/esengine");
|
||||
});
|
||||
|
||||
it("lists contributors as links to their exact profiles", async () => {
|
||||
const openExternal = vi.fn(async () => {});
|
||||
render(<Community port={portWith(openExternal)} logins={["SivanCola", "o-n-e"]} />);
|
||||
const link = screen.getByRole("link", { name: "SivanCola" });
|
||||
expect(link.getAttribute("href")).toBe("https://github.com/SivanCola");
|
||||
await userEvent.click(link);
|
||||
expect(openExternal).toHaveBeenLastCalledWith("https://github.com/SivanCola");
|
||||
await userEvent.click(screen.getByRole("button", { name: "查看全部贡献者" }));
|
||||
expect(openExternal).toHaveBeenLastCalledWith("https://github.com/esengine/DeepSeek-Reasonix/graphs/contributors");
|
||||
});
|
||||
|
||||
it("never turns a hostile login into a URL", () => {
|
||||
const hostile = ["a/b", 'x"y', "../../evil", "-lead", "trail-", "a--b", "", "x".repeat(40), "https://evil.test"];
|
||||
for (const login of hostile) expect(profileUrl(login)).toBeNull();
|
||||
expect(profileUrl("A-b1")).toBe("https://github.com/A-b1");
|
||||
render(<Community port={portWith()} logins={[...hostile, "good"]} />);
|
||||
const people = screen.getByRole("list", { name: "贡献者" });
|
||||
expect(within(people).getAllByRole("link").map((a) => a.getAttribute("href"))).toEqual(["https://github.com/good"]);
|
||||
});
|
||||
|
||||
it("collapses a long list and expands it on request", async () => {
|
||||
const logins = Array.from({ length: 60 }, (_, i) => `user${i}`);
|
||||
render(<Community port={portWith()} logins={logins} />);
|
||||
const people = screen.getByRole("list", { name: "贡献者" });
|
||||
expect(within(people).getAllByRole("link")).toHaveLength(24);
|
||||
await userEvent.click(screen.getByRole("button", { name: "显示全部 60 位" }));
|
||||
expect(within(people).getAllByRole("link")).toHaveLength(60);
|
||||
await userEvent.click(screen.getByRole("button", { name: "收起" }));
|
||||
expect(within(people).getAllByRole("link")).toHaveLength(24);
|
||||
});
|
||||
|
||||
it("offers no toggle for a short list", () => {
|
||||
render(<Community port={portWith()} logins={["a", "b"]} />);
|
||||
expect(screen.queryByRole("button", { name: /显示全部/ })).toBeNull();
|
||||
});
|
||||
|
||||
it("ships a contributors file whose every entry is a valid login", () => {
|
||||
expect(contributors.length).toBeGreaterThan(0);
|
||||
for (const login of contributors) expect(profileUrl(login)).not.toBeNull();
|
||||
expect(contributors.map((l) => l.toLowerCase())).not.toContain("esengine");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,83 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { t } from "../i18n";
|
||||
import type { AgentPort } from "../port/port";
|
||||
import contributors from "../data/contributors.json";
|
||||
import qrSrc from "../assets/qq-group-qr.svg";
|
||||
import douyinSrc from "../assets/douyin-qr.jpg";
|
||||
import { CopyButton } from "./CopyButton";
|
||||
import { COMMUNITY, profileUrl } from "./communityLinks";
|
||||
|
||||
const COLLAPSED = 24;
|
||||
|
||||
export function Community({ port, logins = contributors }: { port: Pick<AgentPort, "openExternal">; logins?: string[] }) {
|
||||
const [all, setAll] = useState(false);
|
||||
const [failed, setFailed] = useState("");
|
||||
const [douyinQr, setDouyinQr] = useState(false);
|
||||
|
||||
const open = (url: string) =>
|
||||
void port.openExternal(url).then(() => setFailed(""), () => setFailed(t("无法打开浏览器,请手动访问 {at}", { at: url })));
|
||||
|
||||
const people = useMemo(
|
||||
() => logins.flatMap((login) => { const url = profileUrl(login); return url ? [{ login, url }] : []; }),
|
||||
[logins],
|
||||
);
|
||||
const shown = all ? people : people.slice(0, COLLAPSED);
|
||||
|
||||
return (
|
||||
<div className="comm">
|
||||
<div className="comm-join">
|
||||
<img className="comm-qr" src={qrSrc} alt={t("QQ 群二维码")} width={185} height={185} />
|
||||
<div className="comm-qq">
|
||||
<span className="comm-name">{COMMUNITY.qqName}</span>
|
||||
<span className="comm-num">
|
||||
<span className="comm-k">{t("群号")}</span>
|
||||
<code>{COMMUNITY.qqNumber}</code>
|
||||
<CopyButton text={COMMUNITY.qqNumber} iconOnly showFeedback label={t("复制群号")} />
|
||||
</span>
|
||||
<span className="comm-how">{t("用 QQ 扫描二维码,或点击下方按钮加入。")}</span>
|
||||
<span className="comm-acts">
|
||||
<button type="button" className="btn" data-primary data-action="community.join" onClick={() => open(COMMUNITY.qqJoin)}>{t("加入 QQ 群")}</button>
|
||||
<button type="button" className="btn" data-action="community.discord" onClick={() => open(COMMUNITY.discord)}>Discord</button>
|
||||
<button type="button" className="btn" data-action="community.issues" onClick={() => open(COMMUNITY.issues)}>GitHub issues</button>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="comm-dy">
|
||||
<span className="comm-k">{t("抖音")}</span>
|
||||
<span className="comm-dy-name">{COMMUNITY.douyinName}</span>
|
||||
<span className="comm-k">{t("抖音号")}</span>
|
||||
<code>{COMMUNITY.douyinId}</code>
|
||||
<CopyButton text={COMMUNITY.douyinId} iconOnly showFeedback label={t("复制抖音号")} />
|
||||
<button type="button" className="btn sm" data-action="community.douyin-qr" aria-expanded={douyinQr} aria-controls="comm-dy-qr" onClick={() => setDouyinQr((v) => !v)}>
|
||||
{douyinQr ? t("收起二维码") : t("显示二维码")}
|
||||
</button>
|
||||
</div>
|
||||
{douyinQr && (
|
||||
<img id="comm-dy-qr" className="comm-dy-qr" src={douyinSrc} alt={t("抖音二维码")} width={180} height={269} />
|
||||
)}
|
||||
|
||||
{failed && <p className="comm-fail" role="alert">{failed}</p>}
|
||||
|
||||
<div className="comm-who">
|
||||
<div className="comm-who-hd">
|
||||
<h4>{t("贡献者")}</h4>
|
||||
<span className="comm-k">{t("按提交数排序,名单随版本更新")}</span>
|
||||
<button type="button" className="btn sm" data-action="community.contributors" onClick={() => open(COMMUNITY.contributors)}>{t("查看全部贡献者")}</button>
|
||||
</div>
|
||||
<ul className="comm-list" aria-label={t("贡献者")}>
|
||||
{shown.map(({ login, url }) => (
|
||||
<li key={login}>
|
||||
<a href={url} data-action="community.profile" data-target={login} onClick={(e) => { e.preventDefault(); open(url); }}>{login}</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
{people.length > COLLAPSED && (
|
||||
<button type="button" className="btn sm comm-more" data-action="community.more" aria-expanded={all} onClick={() => setAll((v) => !v)}>
|
||||
{all ? t("收起") : t("显示全部 {n} 位", { n: people.length })}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -7,7 +7,7 @@ import { MockPort } from "../port/mock";
|
||||
import type { AgentPort, ApprovalMode, Attachment, Completion, ModelEntry, Preset, SessionStatus } from "../port/port";
|
||||
import { draftKey } from "./drafts";
|
||||
|
||||
afterEach(() => { cleanup(); localStorage.clear(); });
|
||||
afterEach(() => { cleanup(); localStorage.clear(); vi.restoreAllMocks(); });
|
||||
|
||||
const status = (over: Partial<SessionStatus> = {}) =>
|
||||
({
|
||||
@@ -27,6 +27,19 @@ function deferred<T>() {
|
||||
return { promise, resolve };
|
||||
}
|
||||
|
||||
function touchPointer() {
|
||||
vi.spyOn(window, "matchMedia").mockImplementation((query: string) => ({
|
||||
matches: query === "(pointer: coarse)",
|
||||
media: query,
|
||||
onchange: null,
|
||||
addListener() {},
|
||||
removeListener() {},
|
||||
addEventListener() {},
|
||||
removeEventListener() {},
|
||||
dispatchEvent: () => false,
|
||||
}) as MediaQueryList);
|
||||
}
|
||||
|
||||
function draw(
|
||||
over: { running?: boolean; onSubmit?: (text: string) => Promise<boolean>; port?: MockPort; st?: SessionStatus; changeCount?: number; host?: string } = {},
|
||||
) {
|
||||
@@ -58,6 +71,35 @@ describe("composer submission", () => {
|
||||
expect((screen.getByRole("button", { name: "发送" }) as HTMLButtonElement).disabled).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps Enter in the textarea when a touch-first pointer has focus", () => {
|
||||
touchPointer();
|
||||
const { box, onSubmit } = draw();
|
||||
fireEvent.change(box, { target: { value: "两行输入", selectionStart: 4 } });
|
||||
expect(fireEvent.keyDown(box, { key: "Enter" })).toBe(true);
|
||||
expect(onSubmit).not.toHaveBeenCalled();
|
||||
expect(screen.getByText("点按发送 · 回车换行")).toBeTruthy();
|
||||
expect(box.hasAttribute("aria-keyshortcuts")).toBe(false);
|
||||
});
|
||||
|
||||
it("still submits through the Send button on a touch-first pointer", async () => {
|
||||
touchPointer();
|
||||
const { box, onSubmit } = draw();
|
||||
fireEvent.change(box, { target: { value: "点按发送", selectionStart: 4 } });
|
||||
fireEvent.keyDown(box, { key: "Enter" });
|
||||
expect(onSubmit).not.toHaveBeenCalled();
|
||||
fireEvent.click(screen.getByRole("button", { name: "发送" }));
|
||||
await waitFor(() => expect(onSubmit).toHaveBeenCalledWith("点按发送"));
|
||||
});
|
||||
|
||||
it("shows the tap-to-steer hint on a touch-first pointer during a live turn", () => {
|
||||
touchPointer();
|
||||
const { box, onSubmit } = draw({ running: true });
|
||||
fireEvent.change(box, { target: { value: "补充一句", selectionStart: 4 } });
|
||||
expect(fireEvent.keyDown(box, { key: "Enter" })).toBe(true);
|
||||
expect(onSubmit).not.toHaveBeenCalled();
|
||||
expect(screen.getByText("点按插话 · 回车换行")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("locks repeated Enter presses until the first submit settles", async () => {
|
||||
const pending = deferred<boolean>();
|
||||
const onSubmit = vi.fn(() => pending.promise);
|
||||
@@ -471,3 +513,31 @@ describe("a model mode switch", () => {
|
||||
await waitFor(() => expect(set).toHaveBeenCalledWith(""));
|
||||
});
|
||||
});
|
||||
|
||||
describe("a line taken back from the queue", () => {
|
||||
const props = () => ({ port: new MockPort() as unknown as AgentPort, status: status(), running: false, onSubmit: async () => true, onChanged: vi.fn(), onError: vi.fn() });
|
||||
|
||||
it("lands in the box", () => {
|
||||
const view = render(<Composer {...props()} />);
|
||||
view.rerender(<Composer {...props()} restore={{ n: 1, text: "取回的这一句" }} />);
|
||||
expect((view.container.querySelector("textarea") as HTMLTextAreaElement).value).toBe("取回的这一句");
|
||||
});
|
||||
|
||||
it("goes under what was typed meanwhile instead of replacing it", () => {
|
||||
const p = props();
|
||||
const view = render(<Composer {...p} />);
|
||||
const box = view.container.querySelector("textarea") as HTMLTextAreaElement;
|
||||
fireEvent.change(box, { target: { value: "正在写的", selectionStart: 4 } });
|
||||
view.rerender(<Composer {...p} restore={{ n: 1, text: "取回的这一句" }} />);
|
||||
expect(box.value).toBe("正在写的\n取回的这一句");
|
||||
});
|
||||
|
||||
it("restores the same text twice when it is taken back twice", () => {
|
||||
const p = props();
|
||||
const view = render(<Composer {...p} restore={{ n: 1, text: "甲" }} />);
|
||||
const box = view.container.querySelector("textarea") as HTMLTextAreaElement;
|
||||
fireEvent.change(box, { target: { value: "", selectionStart: 0 } });
|
||||
view.rerender(<Composer {...p} restore={{ n: 2, text: "甲" }} />);
|
||||
expect(box.value).toBe("甲");
|
||||
});
|
||||
});
|
||||
@@ -10,6 +10,7 @@ import { CompletionMenu, useCompletion } from "./Completion";
|
||||
import { ChipMirror, useSkillChips } from "./ChipMirror";
|
||||
import { useIme } from "./ime";
|
||||
import { countLines, pasteIsLong, planTone, planVerb } from "./intake";
|
||||
import { kindOf, nameOf, previewURL } from "./chipfile";
|
||||
import { useIntake } from "./useIntake";
|
||||
import type { Dropped } from "./filedrop";
|
||||
import type { Quote } from "./cards/SayCard";
|
||||
@@ -17,6 +18,7 @@ import { StudioIcon } from "./StudioIcon";
|
||||
import { usePromptRefine } from "./PromptRefine";
|
||||
import { useProviderOrder } from "../state/providerorder";
|
||||
import { useDraft } from "./useDraft";
|
||||
import { touchKeyboard } from "./touchKeyboard";
|
||||
|
||||
interface Props {
|
||||
port: AgentPort;
|
||||
@@ -26,6 +28,9 @@ interface Props {
|
||||
// twice two requests. Quoting the same reply again is an ordinary thing to
|
||||
// do, and comparing the string alone would drop the second one.
|
||||
quote?: Quote;
|
||||
// A line taken back from the queue. The counter makes the same text twice
|
||||
// two requests, as it does for a quote.
|
||||
restore?: { n: number; text: string };
|
||||
// Resolves false when the line never left, so what was typed comes back
|
||||
// rather than being lost to a refusal the user could not have prevented.
|
||||
// Bumped when something outside asks for the cursor — answering a plan card
|
||||
@@ -58,30 +63,6 @@ type Chip =
|
||||
| { k: "paste"; id: string; body: string; lines: number; name?: string }
|
||||
| { k: "quote"; id: string; body: string; turn?: number; lines: number };
|
||||
|
||||
// Two screenshots pasted in a row are one filename apart, which is the one
|
||||
// thing the chip has to tell them by. The preview comes off the blob that was
|
||||
// just attached — the kernel keeps the bytes, this keeps a handle to look at.
|
||||
function previewURL(blob: Blob): string | undefined {
|
||||
try {
|
||||
return URL.createObjectURL(blob);
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
// A dropped file has no preview to stand behind: the host named it, it was
|
||||
// never read. Its kind fills the square, because a blank one reads as an image
|
||||
// that failed to load.
|
||||
function kindOf(path: string): string {
|
||||
const name = nameOf(path);
|
||||
const dot = name.lastIndexOf(".");
|
||||
return dot > 0 ? name.slice(dot + 1).toUpperCase().slice(0, 4) : "FILE";
|
||||
}
|
||||
|
||||
function nameOf(path: string): string {
|
||||
return path.split(/[\\/]/).pop() ?? path;
|
||||
}
|
||||
|
||||
function chipName(c: Chip): string {
|
||||
if (c.k === "quote") return c.turn === undefined ? t("引用回复") : t("引用第 {n} 轮回复", { n: c.turn });
|
||||
return c.k === "paste" ? c.name ?? t("粘贴的文本") : c.name;
|
||||
@@ -110,7 +91,8 @@ function releaseChip(c: Chip) {
|
||||
let chipSeq = 0;
|
||||
const chipId = () => `c${++chipSeq}`;
|
||||
|
||||
export function Composer({ port, status, running, quote, focus, onSubmit, onChanged, onError, onSettings = () => {}, changeCount = 0, pulse = 0, draftKey = "" }: Props) {
|
||||
export function Composer({ port, status, running, quote, restore, focus, onSubmit, onChanged, onError, onSettings = () => {}, changeCount = 0, pulse = 0, draftKey = "" }: Props) {
|
||||
const touch = touchKeyboard();
|
||||
const providerOrder = useProviderOrder();
|
||||
const [branch, setBranch] = useState("");
|
||||
useEffect(() => {
|
||||
@@ -172,6 +154,16 @@ export function Composer({ port, status, running, quote, focus, onSubmit, onChan
|
||||
queueMicrotask(() => box.current?.focus());
|
||||
}, [quote?.n]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!restore?.n) return;
|
||||
setText((prev) => {
|
||||
const next = prev.trim() ? `${prev.replace(/\s+$/, "")}\n${restore.text}` : restore.text;
|
||||
pending.current = next.length;
|
||||
return next;
|
||||
});
|
||||
queueMicrotask(() => box.current?.focus());
|
||||
}, [restore?.n]);
|
||||
|
||||
const moveTo = useCallback((next: string, at: number) => {
|
||||
pending.current = at;
|
||||
type(next, at);
|
||||
@@ -528,7 +520,7 @@ export function Composer({ port, status, running, quote, focus, onSubmit, onChan
|
||||
role="combobox"
|
||||
aria-label={t("任务输入")}
|
||||
aria-describedby={guide}
|
||||
aria-keyshortcuts="Enter Shift+Enter"
|
||||
aria-keyshortcuts={touch ? undefined : "Enter Shift+Enter"}
|
||||
aria-busy={submitting}
|
||||
readOnly={submitting}
|
||||
aria-expanded={menu.open}
|
||||
@@ -601,7 +593,7 @@ export function Composer({ port, status, running, quote, focus, onSubmit, onChan
|
||||
menu.dismiss();
|
||||
return;
|
||||
}
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
if (e.key === "Enter" && !e.shiftKey && !touch) {
|
||||
e.preventDefault();
|
||||
send();
|
||||
}
|
||||
@@ -623,7 +615,9 @@ export function Composer({ port, status, running, quote, focus, onSubmit, onChan
|
||||
? t("正在添加附件…")
|
||||
: failed
|
||||
? t("有附件添加失败,请重试或移除")
|
||||
: t(running ? "Enter 插话 · Shift+Enter 换行" : "Enter 发送 · Shift+Enter 换行")}
|
||||
: t(touch
|
||||
? running ? "点按插话 · 回车换行" : "点按发送 · 回车换行"
|
||||
: running ? "Enter 插话 · Shift+Enter 换行" : "Enter 发送 · Shift+Enter 换行")}
|
||||
</span>
|
||||
{showCount && <span className="fcount">{t("{n} 字 · {lines} 行", { n: text.length, lines })}</span>}
|
||||
</div>
|
||||
|
||||
@@ -18,7 +18,7 @@ export async function copyText(text: string) {
|
||||
if (!ok) throw new Error("copy rejected");
|
||||
}
|
||||
|
||||
export function CopyButton({ text, iconOnly = false, className, label: what }: { text: string; iconOnly?: boolean; className?: string; label?: string }) {
|
||||
export function CopyButton({ text, iconOnly = false, showFeedback = false, className, label: what }: { text: string; iconOnly?: boolean; showFeedback?: boolean; className?: string; label?: string }) {
|
||||
const [state, setState] = useState<"idle" | "done" | "failed">("idle");
|
||||
const timer = useRef<number | null>(null);
|
||||
|
||||
@@ -34,7 +34,7 @@ export function CopyButton({ text, iconOnly = false, className, label: what }: {
|
||||
|
||||
// A clipboard the host denied is not the same as nothing happening, and the
|
||||
// reader is about to try again — so the failure says so instead of staying idle.
|
||||
const label = state === "done" ? t("已复制") : state === "failed" ? t("复制不了") : what ?? t("复制回复");
|
||||
const label = state === "done" ? t("已复制") : state === "failed" ? t("复制失败,请重试") : what ?? t("复制回复");
|
||||
|
||||
return (
|
||||
<button
|
||||
@@ -42,12 +42,13 @@ export function CopyButton({ text, iconOnly = false, className, label: what }: {
|
||||
type="button"
|
||||
data-icon={iconOnly ? "" : undefined}
|
||||
data-state={state}
|
||||
data-feedback={showFeedback && state !== "idle" ? "" : undefined}
|
||||
onClick={copy}
|
||||
aria-label={what ?? t("复制这段回答")}
|
||||
title={label}
|
||||
>
|
||||
{iconOnly && <StudioIcon name={state === "done" ? "check" : "copy"} />}
|
||||
<span className={iconOnly ? "sr-only" : undefined} aria-live="polite">{label}</span>
|
||||
{iconOnly && <StudioIcon name={state === "done" ? "check" : state === "failed" ? "warning" : "copy"} />}
|
||||
<span className={iconOnly && !(showFeedback && state !== "idle") ? "sr-only" : undefined} aria-live="polite">{label}</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -40,10 +40,12 @@ describe("the deck readings", () => {
|
||||
expect(settled.querySelector('[data-action="deck.jobs"]')?.hasAttribute("data-live")).toBe(false);
|
||||
});
|
||||
|
||||
// Delegates that stopped are history the transcript keeps; a reading left on
|
||||
// the rail after they stopped reads as work still going.
|
||||
it("shows the delegates only while one is running", () => {
|
||||
// Finished delegates stay reachable (their transcript opens from here), but
|
||||
// only a running one animates the reading.
|
||||
it("keeps finished delegates listed and animates only while one is running", () => {
|
||||
expect(draw([task(true)], []).querySelector('[data-action="deck.agents"]')?.hasAttribute("data-live")).toBe(true);
|
||||
expect(draw([task(false)], []).querySelector('[data-action="deck.agents"]')).toBeNull();
|
||||
const done = draw([task(false)], []).querySelector('[data-action="deck.agents"]');
|
||||
expect(done).not.toBeNull();
|
||||
expect(done?.hasAttribute("data-live")).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -1,9 +1,10 @@
|
||||
import { useCallback, useMemo, useRef } from "react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import type { JobEntry } from "../port/port";
|
||||
import { t } from "../i18n";
|
||||
import { StudioIcon } from "./StudioIcon";
|
||||
import { agentsIn } from "./panels/derive";
|
||||
import { Agents } from "./panels/Agents";
|
||||
import { AgentTranscript } from "./panels/AgentTranscript";
|
||||
import { Jobs } from "./panels/Jobs";
|
||||
import type { Task } from "./panels/Agents";
|
||||
import { useDismiss } from "./dismiss";
|
||||
@@ -18,19 +19,25 @@ export type Deck = "" | "agents" | "jobs";
|
||||
export function DeckChips({ tasks, jobs, open, onOpen, onCancelJob }: { tasks: Task[]; jobs: JobEntry[]; open: Deck; onOpen: (next: (was: Deck) => Deck) => void; onCancelJob?: (id: string) => Promise<void> }) {
|
||||
const agentsBox = useRef<HTMLDivElement>(null);
|
||||
const jobsBox = useRef<HTMLDivElement>(null);
|
||||
const [viewing, setViewing] = useState("");
|
||||
const viewed = viewing ? tasks.find((x) => x.id === viewing) : undefined;
|
||||
useEffect(() => {
|
||||
if (viewing && !viewed) setViewing("");
|
||||
}, [viewing, viewed]);
|
||||
const closeView = useCallback(() => {
|
||||
setViewing("");
|
||||
agentsBox.current?.querySelector<HTMLElement>('[data-action="deck.agents"]')?.focus();
|
||||
}, []);
|
||||
const shut = useCallback(() => onOpen(() => ""), [onOpen]);
|
||||
useDismiss(open === "agents", agentsBox, shut);
|
||||
useDismiss(open === "jobs", jobsBox, shut);
|
||||
const liveAgents = useMemo(() => agentsIn(tasks.filter((x) => x.running)), [tasks]);
|
||||
const liveJobs = useMemo(() => jobs.filter((j) => j.status === "running").length, [jobs]);
|
||||
if (liveAgents === 0 && jobs.length === 0) return null;
|
||||
if (tasks.length === 0 && jobs.length === 0) return null;
|
||||
|
||||
// Only while a delegate is running: a finished one is history, which the
|
||||
// transcript and the side panel keep, and a reading left standing after the
|
||||
// work stopped reads as work still going.
|
||||
return (
|
||||
<>
|
||||
{liveAgents > 0 && (
|
||||
{tasks.length > 0 && (
|
||||
<div ref={agentsBox} className="studio-deck-anchor" data-open={open === "agents" ? "" : undefined}>
|
||||
<button
|
||||
type="button"
|
||||
@@ -42,14 +49,15 @@ export function DeckChips({ tasks, jobs, open, onOpen, onCancelJob }: { tasks: T
|
||||
onClick={() => onOpen((was) => (was === "agents" ? "" : "agents"))}
|
||||
>
|
||||
<StudioIcon name="branch" />
|
||||
<b>{liveAgents}</b>
|
||||
<b>{agentsIn(tasks)}</b>
|
||||
<span>{t("子代理")}</span>
|
||||
</button>
|
||||
<div className="studio-deck-pop" role="dialog" aria-label={t("子代理")}>
|
||||
<Agents tasks={tasks} />
|
||||
<Agents tasks={tasks} onOpen={(id) => { shut(); setViewing(id); }} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{viewed && <AgentTranscript task={viewed} onClose={closeView} />}
|
||||
{jobs.length > 0 && (
|
||||
<div ref={jobsBox} className="studio-deck-anchor" data-open={open === "jobs" ? "" : undefined}>
|
||||
<button
|
||||
|
||||
@@ -225,7 +225,7 @@ const CODES: [string, number, Record<string, string | number> | undefined, RegEx
|
||||
[FEEDBACK_CODE.duplicate, 409, undefined, /刚刚已经提交过/],
|
||||
[FEEDBACK_CODE.badToken, 409, undefined, /没有认出这台电脑/],
|
||||
[FEEDBACK_CODE.offline, 502, undefined, /没能连上反馈服务/],
|
||||
[FEEDBACK_CODE.unavailable, 502, undefined, /暂时出了问题/],
|
||||
[FEEDBACK_CODE.unavailable, 502, undefined, /已填的内容都还在/],
|
||||
[FEEDBACK_CODE.internal, 500, undefined, /本机保存反馈记录时出错/],
|
||||
[FEEDBACK_CODE.imageMetadata, 400, undefined, /隐藏信息/],
|
||||
[FEEDBACK_CODE.badBody, 400, undefined, /没能被解析/],
|
||||
@@ -313,6 +313,17 @@ describe("refusals", () => {
|
||||
expect(screen.getByRole("button", { name: "重试发送" })).toBeTruthy();
|
||||
});
|
||||
|
||||
it("keeps an unavailable report and offers the existing GitHub destination", async () => {
|
||||
const { port } = setup();
|
||||
sessionStorage.setItem("rx-mock-feedback-fault", FEEDBACK_CODE.unavailable);
|
||||
await fill("Neutral report fixture", "tester");
|
||||
await userEvent.click(send());
|
||||
expect((await screen.findByRole("alert")).textContent).toContain("稍等片刻再试,也可以直接到 GitHub 提交问题");
|
||||
expect(body().value).toBe("Neutral report fixture");
|
||||
await userEvent.click(screen.getByRole("button", { name: "去 GitHub" }));
|
||||
expect(port.openExternal).toHaveBeenCalledWith("https://github.com/esengine/DeepSeek-Reasonix/issues/new/choose");
|
||||
});
|
||||
|
||||
it("points a duplicate at My feedback and a disabled channel at GitHub", async () => {
|
||||
const { port } = setup();
|
||||
port.sendFeedback = vi.fn(async () => {
|
||||
|
||||
@@ -379,7 +379,7 @@ export function FeedbackForm({ port, onMine, onClose, onFile }: Props) {
|
||||
{failure.code === FEEDBACK_CODE.duplicate && (
|
||||
<button type="button" className="btn sm" data-action="feedback.mine" onClick={onMine}>{t("查看我的反馈")}</button>
|
||||
)}
|
||||
{failure.code === FEEDBACK_CODE.disabled && (
|
||||
{(failure.code === FEEDBACK_CODE.disabled || failure.code === FEEDBACK_CODE.unavailable) && (
|
||||
<button type="button" className="btn sm" data-action="feedback.link" onClick={() => onFile(FEEDBACK_REPO_ISSUES + "new/choose")}>
|
||||
{t("去 GitHub")}
|
||||
</button>
|
||||
|
||||
@@ -249,7 +249,7 @@ export function FeedbackMine({ port, onFile, onUnread }: Props) {
|
||||
))}
|
||||
</ol>}
|
||||
<FeedbackThread item={item} fresh={fresh[item.receipt] ?? 0} onShowAll={unfolded} />
|
||||
<FeedbackReplyBox port={port} item={item} limit={replyBytes} offline={mine.offline} onSent={sent} onStale={load} />
|
||||
<FeedbackReplyBox port={port} item={item} limit={replyBytes} offline={mine.offline} onSent={sent} onFile={onFile} onStale={load} />
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
@@ -109,20 +109,26 @@ describe("a question waiting for the reporter", () => {
|
||||
[FEEDBACK_CODE.rateLimited, /提交得太频繁/],
|
||||
[FEEDBACK_CODE.badToken, /身份已经变了/],
|
||||
[FEEDBACK_CODE.offline, /你写的回复还在/],
|
||||
[FEEDBACK_CODE.unavailable, /暂时出了问题/],
|
||||
[FEEDBACK_CODE.unavailable, /你写的回复还在/],
|
||||
[FEEDBACK_CODE.disabled, /暂时关闭/],
|
||||
])("says what %s means and keeps the text", async (code, words) => {
|
||||
const port = portWith(asking);
|
||||
port.replyFeedback = vi.fn(async () => {
|
||||
throw new HttpError(409, code, { code, error: code });
|
||||
});
|
||||
render(<FeedbackMine port={port} onFile={() => {}} />);
|
||||
const onFile = vi.fn();
|
||||
render(<FeedbackMine port={port} onFile={onFile} />);
|
||||
await screen.findByText("FB-AAAA-0001");
|
||||
await userEvent.type(box(), "macOS 15");
|
||||
await userEvent.click(sendButton());
|
||||
const alert = await screen.findByRole("alert");
|
||||
expect(alert.textContent).toMatch(words);
|
||||
expect(box().value).toBe("macOS 15");
|
||||
if (code === FEEDBACK_CODE.unavailable) {
|
||||
expect(alert.textContent).toContain("重发前先刷新列表确认是否送达");
|
||||
await userEvent.click(screen.getByRole("button", { name: "去 GitHub" }));
|
||||
expect(onFile).toHaveBeenCalledWith("https://github.com/esengine/DeepSeek-Reasonix/issues/new/choose");
|
||||
}
|
||||
await waitFor(() => expect(port.myFeedback).toHaveBeenCalledTimes(code === FEEDBACK_CODE.notReplyable ? 2 : 1));
|
||||
});
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useMemo, useRef, useState } from "react";
|
||||
import { current, t } from "../i18n";
|
||||
import { FEEDBACK_CODE, FEEDBACK_REPLYABLE, type FeedbackItem, type FeedbackReply } from "../port/feedback";
|
||||
import { FEEDBACK_CODE, FEEDBACK_REPO_ISSUES, FEEDBACK_REPLYABLE, type FeedbackItem, type FeedbackReply } from "../port/feedback";
|
||||
import type { AgentPort } from "../port/port";
|
||||
import { replyFailure, type FeedbackFailure } from "./feedbackfailure";
|
||||
import { StudioIcon } from "./StudioIcon";
|
||||
@@ -69,10 +69,11 @@ interface ReplyProps {
|
||||
limit: number | null;
|
||||
offline: boolean;
|
||||
onSent: (receipt: string) => void;
|
||||
onFile: (url: string) => void;
|
||||
onStale: () => void;
|
||||
}
|
||||
|
||||
export function FeedbackReplyBox({ port, item, limit, offline, onSent, onStale }: ReplyProps) {
|
||||
export function FeedbackReplyBox({ port, item, limit, offline, onSent, onFile, onStale }: ReplyProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [text, setText] = useState("");
|
||||
const [sending, setSending] = useState(false);
|
||||
@@ -152,6 +153,11 @@ export function FeedbackReplyBox({ port, item, limit, offline, onSent, onStale }
|
||||
<div className="fbk-note" role="alert" data-tone="error" data-code={failure.code}>
|
||||
<StudioIcon name="warning" />
|
||||
<span>{failure.message}</span>
|
||||
{failure.code === FEEDBACK_CODE.unavailable && (
|
||||
<button type="button" className="btn sm" data-action="feedback.link" onClick={() => onFile(FEEDBACK_REPO_ISSUES + "new/choose")}>
|
||||
{t("去 GitHub")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="fbk-acts">
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// @vitest-environment jsdom
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { cleanup, fireEvent, render } from "@testing-library/react";
|
||||
import { DOCK, Gutter, dockMax } from "./Gutter";
|
||||
import { DOCK, Gutter, RAIL, dockMax, widthOf } from "./Gutter";
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
@@ -33,3 +33,11 @@ describe("workbench width on wide displays", () => {
|
||||
expect(separator.getAttribute("aria-valuemax")).toBe("880");
|
||||
});
|
||||
});
|
||||
|
||||
describe("workspace column floor", () => {
|
||||
it("raises a width saved below the floor, where the session filters no longer fit", () => {
|
||||
localStorage.setItem(RAIL.key, "176");
|
||||
expect(widthOf(RAIL)).toBe(RAIL.min);
|
||||
expect(RAIL.min).toBeGreaterThanOrEqual(232);
|
||||
});
|
||||
});
|
||||
@@ -16,7 +16,7 @@ export interface Span {
|
||||
|
||||
// 拖出来的宽度是读者对自己这块屏幕的判断,不是布局的默认值 —— 所以它归本地
|
||||
// 存,不进内核配置:换台机器、换块屏幕,判断本来就不同。
|
||||
export const RAIL: Span = { min: 176, max: 440, def: 264, key: "rx-rail-w", css: "--rail-open", col: "--rail-w" };
|
||||
export const RAIL: Span = { min: 232, max: 440, def: 264, key: "rx-rail-w", css: "--rail-open", col: "--rail-w" };
|
||||
export const SIDE: Span = { min: 244, max: 540, def: 316, key: "rx-side-w", css: "--side-open", col: "--side-w" };
|
||||
// The workbench beside the conversation. Wider than the rail because what sits
|
||||
// in it is a page someone is reading, not a list of names.
|
||||
|
||||
@@ -88,6 +88,7 @@ describe("market list", () => {
|
||||
const port = new MockPort() as unknown as AgentPort;
|
||||
let finish!: (value: MarketDetail) => void;
|
||||
port.marketList = async () => ({ packages: [row("a/kit", true)], limit: 24, offset: 0 });
|
||||
port.marketMyVote = async () => ({ signedIn: false, value: 0 });
|
||||
port.marketDetail = vi.fn()
|
||||
.mockRejectedValueOnce(new Error("offline"))
|
||||
.mockImplementationOnce(() => new Promise<MarketDetail>((resolve) => { finish = resolve; }));
|
||||
|
||||
@@ -361,6 +361,9 @@ const VIEWS: [View, string][] = [["browse", "浏览"], ["mine", "我的发布"],
|
||||
// page rather than two sections: what the market adds shows up on the other tab.
|
||||
// Publishing spends the account session, so it is offered only while signed in.
|
||||
export function MarketGroup({ port, onInstalled, onViewInstalled, account, onSignIn, onApplying }: Props & { account: AccountState | null; onSignIn: () => void; onApplying?: (applying: boolean) => void }) {
|
||||
const connection = useRef({ port });
|
||||
if (connection.current.port !== port) connection.current = { port };
|
||||
const owner = connection.current;
|
||||
const [view, setView] = useState<View>("browse");
|
||||
const [applying, setApplying] = useState(false);
|
||||
const applyingChanged = useCallback((busy: boolean) => { setApplying(busy); onApplying?.(busy); }, [onApplying]);
|
||||
@@ -388,7 +391,9 @@ export function MarketGroup({ port, onInstalled, onViewInstalled, account, onSig
|
||||
)
|
||||
)}
|
||||
{at === "browse" && <Market port={port} onInstalled={onInstalled} onViewInstalled={onViewInstalled} onSignIn={onSignIn} />}
|
||||
{at === "mine" && <MyPackages port={port} onInstalled={onInstalled} onViewInstalled={onViewInstalled} onApplying={applyingChanged} />}
|
||||
{at === "mine" && <MyPackages key={handle} port={port} onInstalled={() => {
|
||||
if (connection.current === owner) onInstalled();
|
||||
}} onViewInstalled={onViewInstalled} onApplying={applyingChanged} />}
|
||||
{at === "publish" && handle && <PublishForm port={port} handle={handle} onMine={() => setView("mine")} />}
|
||||
</Group>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
// @vitest-environment jsdom
|
||||
import { afterEach, expect, it, vi } from "vitest";
|
||||
import { act, cleanup, render, screen, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import "./testkit";
|
||||
import { MockPort } from "../port/mock";
|
||||
import type { AccountState, AgentPort, MarketPackage, MarketPlan } from "../port/port";
|
||||
import { MarketGroup } from "./Market";
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
const account = (handle: string): AccountState => ({
|
||||
signedIn: true, user: { handle, email: `${handle}@example.com`, label: handle },
|
||||
});
|
||||
const draw = (port: AgentPort, handle: string, onInstalled = () => {}) => (
|
||||
<MarketGroup port={port} account={account(handle)} onInstalled={onInstalled} onSignIn={() => {}} />
|
||||
);
|
||||
|
||||
it("keeps an open author preview when the same account state is refreshed", async () => {
|
||||
const port = new MockPort() as unknown as AgentPort;
|
||||
const read = vi.spyOn(port, "myMarket");
|
||||
const preview = vi.spyOn(port, "planOwnMarket");
|
||||
const view = render(draw(port, "demo"));
|
||||
await userEvent.click(screen.getByRole("radio", { name: "我的发布" }));
|
||||
const row = (await screen.findByText("ship-notes")).closest("li")!;
|
||||
await userEvent.click(within(row).getByRole("button", { name: "安装" }));
|
||||
await screen.findByText(/demo\/ship-notes .* 将安装以下内容/);
|
||||
view.rerender(draw(port, "demo"));
|
||||
expect(screen.getByText(/demo\/ship-notes .* 将安装以下内容/)).toBeTruthy();
|
||||
expect(read).toHaveBeenCalledTimes(1);
|
||||
expect(preview).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("refreshes installed capabilities when the old author's install completes on the same host", async () => {
|
||||
const port = new MockPort() as unknown as AgentPort;
|
||||
const pkg = (await port.myMarket()).find((p) => p.slug === "demo/ship-notes")!;
|
||||
const plan = await port.planOwnMarket({ slug: pkg.slug });
|
||||
const fresh = { ...pkg, handle: "other", slug: "other/new-package", name: "new-package" };
|
||||
vi.spyOn(port, "myMarket").mockResolvedValueOnce([pkg]).mockResolvedValue([fresh]);
|
||||
let finish!: (out: MarketPlan) => void;
|
||||
vi.spyOn(port, "installOwnMarket").mockImplementationOnce(() => new Promise((resolve) => { finish = resolve; }));
|
||||
const onInstalled = vi.fn();
|
||||
const view = render(draw(port, "demo", onInstalled));
|
||||
await userEvent.click(screen.getByRole("radio", { name: "我的发布" }));
|
||||
await userEvent.click(await screen.findByRole("button", { name: "安装" }));
|
||||
await userEvent.click(await screen.findByRole("button", { name: "安装" }));
|
||||
view.rerender(draw(port, "other", onInstalled));
|
||||
await screen.findByText("new-package");
|
||||
await act(async () => finish({ ...plan, applied: true, status: "done" }));
|
||||
expect(onInstalled).toHaveBeenCalledTimes(1);
|
||||
expect(screen.getByText("new-package")).toBeTruthy();
|
||||
expect(screen.queryByText("装好了,下一轮就能用")).toBeNull();
|
||||
});
|
||||
|
||||
it("closes the old author's preview when the account changes on the same connection", async () => {
|
||||
const port = new MockPort() as unknown as AgentPort;
|
||||
const pkg = (await port.myMarket()).find((p) => p.slug === "demo/ship-notes")!;
|
||||
const fresh = { ...pkg, handle: "other", slug: "other/new-package", name: "new-package" };
|
||||
const read = vi.spyOn(port, "myMarket").mockResolvedValueOnce([pkg]).mockResolvedValue([fresh]);
|
||||
const preview = vi.spyOn(port, "planOwnMarket");
|
||||
const install = vi.spyOn(port, "installOwnMarket");
|
||||
const view = render(draw(port, "demo"));
|
||||
await userEvent.click(screen.getByRole("radio", { name: "我的发布" }));
|
||||
await userEvent.click(await screen.findByRole("button", { name: "安装" }));
|
||||
await screen.findByText(/demo\/ship-notes .* 将安装以下内容/);
|
||||
view.rerender(draw(port, "other"));
|
||||
expect(await screen.findByText("new-package")).toBeTruthy();
|
||||
expect(screen.queryByText(/demo\/ship-notes .* 将安装以下内容/)).toBeNull();
|
||||
expect(read).toHaveBeenCalledTimes(2);
|
||||
expect(preview).toHaveBeenCalledTimes(1);
|
||||
expect(install).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each([
|
||||
["success", false], ["failure", false], ["success", true], ["failure", true],
|
||||
])("ignores the earlier account's list %s on the same connection (return=%s)", async (outcome, returnToAccount) => {
|
||||
const port = new MockPort() as unknown as AgentPort;
|
||||
const pkg = (await port.myMarket()).find((p) => p.slug === "demo/ship-notes")!;
|
||||
const other = { ...pkg, handle: "other", slug: "other/current-package", name: "current-package" };
|
||||
const fresh = { ...pkg, slug: "demo/fresh-package", name: "fresh-package" };
|
||||
let finish!: (rows: MarketPackage[]) => void;
|
||||
let fail!: (error: Error) => void;
|
||||
const read = vi.spyOn(port, "myMarket")
|
||||
.mockImplementationOnce(() => new Promise((resolve, reject) => { finish = resolve; fail = reject; }))
|
||||
.mockResolvedValueOnce([other]).mockResolvedValue([fresh]);
|
||||
const view = render(draw(port, "demo"));
|
||||
await userEvent.click(screen.getByRole("radio", { name: "我的发布" }));
|
||||
view.rerender(draw(port, "other"));
|
||||
await screen.findByText("current-package");
|
||||
if (returnToAccount) {
|
||||
view.rerender(draw(port, "demo"));
|
||||
await screen.findByText("fresh-package");
|
||||
}
|
||||
await act(async () => {
|
||||
if (outcome === "success") finish([pkg]);
|
||||
else fail(new Error("old account inventory failed"));
|
||||
});
|
||||
expect(screen.getByText(returnToAccount ? "fresh-package" : "current-package")).toBeTruthy();
|
||||
expect(screen.queryByText("ship-notes")).toBeNull();
|
||||
expect(screen.queryByText("old account inventory failed")).toBeNull();
|
||||
expect(read).toHaveBeenCalledTimes(returnToAccount ? 3 : 2);
|
||||
});
|
||||
@@ -208,7 +208,8 @@ describe("the account's own packages", () => {
|
||||
const row = (await screen.findByText(pkg.name)).closest("li")!;
|
||||
await userEvent.click(row.querySelector<HTMLButtonElement>('[data-action="market.own-inspect"]')!);
|
||||
await userEvent.click(await screen.findByRole("button", { name: "安装" }));
|
||||
await screen.findByText("Some actions failed");
|
||||
await screen.findByText("有项目未安装成功,原因见下方。");
|
||||
expect(screen.getByRole("alert").textContent).toContain("unavailable-server: server unavailable");
|
||||
|
||||
const installed = document.querySelector(".mkt-installed")!;
|
||||
expect(installed.textContent).toContain("working-server");
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
// @vitest-environment jsdom
|
||||
import { afterEach, expect, it, vi } from "vitest";
|
||||
import { act, cleanup, render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import "./testkit";
|
||||
import { OwnInstall } from "./MarketOwn";
|
||||
import { boot, STORAGE, t } from "../i18n";
|
||||
import type { AgentPort, MarketPackage, MarketPlan } from "../port/port";
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
const pkg = { slug: "demo/notes-kit", kind: "skill", latestVersion: "1.0.0", status: "private" } as MarketPackage;
|
||||
const preview = (id: string, version: string): MarketPlan => ({
|
||||
ok: true, status: "planned", applied: false, slug: pkg.slug, version, planId: id,
|
||||
contentDigest: `sha256:${(id === "consumed" ? "a" : "b").repeat(64)}`, unreviewed: true,
|
||||
actions: ["notes", "checks"].map((name) => ({ kind: "skill", name, action: "copy_skill", status: "planned", riskLevel: "low" })),
|
||||
});
|
||||
const original = preview("consumed", "1.0.0");
|
||||
const fresh = preview("fresh", "2.0.0");
|
||||
const failed = (applied: boolean): MarketPlan => ({
|
||||
...original, ok: false, status: "failed", applied,
|
||||
actions: original.actions!.map((action) => ({ ...action, status: "failed", error: "The target is unavailable", next: "Correct the target and preview again" })),
|
||||
});
|
||||
const successful: MarketPlan = { ...fresh, status: "done", applied: true, actions: fresh.actions!.map((action) => ({ ...action, status: "done" })) };
|
||||
|
||||
function language(lang: string) {
|
||||
localStorage.setItem(STORAGE, lang);
|
||||
boot();
|
||||
}
|
||||
|
||||
async function confirm() {
|
||||
await userEvent.click(await screen.findByRole("checkbox"));
|
||||
await userEvent.click(screen.getByRole("button", { name: t("安装") }));
|
||||
}
|
||||
|
||||
it.each(["zh", "en"].flatMap((lang) => [false, true].map((applied) => ({ lang, applied }))))("re-previews a failed author install with fresh version, digest and consent ($lang, applied=$applied)", async ({ lang, applied }) => {
|
||||
language(lang);
|
||||
let finish!: (plan: MarketPlan) => void;
|
||||
const planning = vi.fn().mockResolvedValueOnce(original).mockImplementationOnce(() => new Promise<MarketPlan>((resolve) => { finish = resolve; }));
|
||||
const installing = vi.fn().mockResolvedValueOnce(failed(applied)).mockResolvedValueOnce(successful);
|
||||
const onInstalled = vi.fn();
|
||||
const onApplying = vi.fn();
|
||||
const onBack = vi.fn();
|
||||
const port = { planOwnMarket: planning, installOwnMarket: installing } as unknown as AgentPort;
|
||||
render(<OwnInstall port={port} pkg={pkg} onBack={onBack} onInstalled={onInstalled} onApplying={onApplying} />);
|
||||
await confirm();
|
||||
expect((await screen.findAllByRole("alert"))[0].textContent).toContain("The target is unavailable");
|
||||
await userEvent.click(screen.getByRole("button", { name: t("重试") }));
|
||||
expect(planning).toHaveBeenCalledTimes(2);
|
||||
expect(planning).toHaveBeenLastCalledWith({ slug: pkg.slug, replace: false });
|
||||
expect(screen.queryByRole("checkbox")).toBeNull();
|
||||
expect(screen.queryByRole("button", { name: t("安装") })).toBeNull();
|
||||
expect(screen.queryByRole("button", { name: t("重试") })).toBeNull();
|
||||
expect(installing).toHaveBeenCalledTimes(1);
|
||||
await act(async () => finish(fresh));
|
||||
const seen = screen.getByRole<HTMLInputElement>("checkbox");
|
||||
expect(seen.checked).toBe(false);
|
||||
expect(screen.getByRole<HTMLButtonElement>("button", { name: t("安装") }).disabled).toBe(true);
|
||||
expect(screen.getByText(t("内容摘要:{digest}", { digest: fresh.contentDigest! }))).toBeTruthy();
|
||||
await userEvent.click(seen);
|
||||
await userEvent.click(screen.getByRole("button", { name: t("安装") }));
|
||||
expect(installing).toHaveBeenLastCalledWith({ slug: pkg.slug, version: "2.0.0", planId: "fresh", replace: false, digest: fresh.contentDigest });
|
||||
expect(onInstalled).toHaveBeenCalledTimes(applied ? 2 : 1);
|
||||
expect(onApplying.mock.calls.filter(([value]) => value)).toHaveLength(2);
|
||||
expect(onBack).not.toHaveBeenCalled();
|
||||
expect(screen.queryByRole("button", { name: t("重试") })).toBeNull();
|
||||
});
|
||||
|
||||
it.each(["zh", "en"])("recovers a new preview failure without reapplying the consumed plan (%s)", async (lang) => {
|
||||
language(lang);
|
||||
const planning = vi.fn().mockResolvedValueOnce(original).mockRejectedValueOnce(new Error("source unavailable")).mockResolvedValueOnce(fresh);
|
||||
const installing = vi.fn().mockResolvedValueOnce(failed(true)).mockResolvedValueOnce(successful);
|
||||
const port = { planOwnMarket: planning, installOwnMarket: installing } as unknown as AgentPort;
|
||||
render(<OwnInstall port={port} pkg={pkg} onBack={() => {}} onInstalled={() => {}} />);
|
||||
await confirm();
|
||||
await userEvent.click(await screen.findByRole("button", { name: t("重试") }));
|
||||
expect(await screen.findByText("source unavailable")).toBeTruthy();
|
||||
expect(installing).toHaveBeenCalledTimes(1);
|
||||
await userEvent.click(screen.getByRole("button", { name: t("重试") }));
|
||||
expect(await screen.findByRole("checkbox")).toBeTruthy();
|
||||
expect(planning).toHaveBeenCalledTimes(3);
|
||||
expect(installing).toHaveBeenCalledTimes(1);
|
||||
await confirm();
|
||||
expect(installing).toHaveBeenLastCalledWith({ slug: pkg.slug, version: "2.0.0", planId: "fresh", replace: false, digest: fresh.contentDigest });
|
||||
});
|
||||
@@ -0,0 +1,55 @@
|
||||
// @vitest-environment jsdom
|
||||
import { afterEach, expect, it, vi } from "vitest";
|
||||
import { act, cleanup, render, screen, waitFor, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import "./testkit";
|
||||
import { boot, STORAGE, t } from "../i18n";
|
||||
import { MockPort } from "../port/mock";
|
||||
import type { AgentPort, MarketPackage } from "../port/port";
|
||||
import { MyPackages } from "./MarketPublish";
|
||||
|
||||
afterEach(() => { cleanup(); localStorage.setItem(STORAGE, "zh"); boot(); });
|
||||
|
||||
it.each(["zh", "en"].flatMap((lang) => ["success", "failure"].map((outcome) => ({ lang, outcome }))))(
|
||||
"keeps the review result after an earlier install refresh $outcome ($lang)",
|
||||
async ({ lang, outcome }) => {
|
||||
localStorage.setItem(STORAGE, lang);
|
||||
boot();
|
||||
const port = new MockPort() as unknown as AgentPort;
|
||||
const published = await port.publishMarket({
|
||||
kind: "theme", name: "review-refresh-demo", visibility: "private",
|
||||
source: "https://github.com/demo/themes/tree/" + "a".repeat(40) + "/dusk",
|
||||
});
|
||||
const read = port.myMarket.bind(port);
|
||||
const submit = port.submitMarket.bind(port);
|
||||
let finishReview!: () => Promise<MarketPackage>;
|
||||
const review = vi.spyOn(port, "submitMarket").mockImplementation((slug) => new Promise((resolve) => {
|
||||
finishReview = async () => { const pkg = await submit(slug); resolve(pkg); return pkg; };
|
||||
}));
|
||||
let finishList!: () => void;
|
||||
const refresh = vi.spyOn(port, "myMarket").mockImplementationOnce(read).mockImplementationOnce(async () => {
|
||||
const rows = await read();
|
||||
return new Promise((resolve, reject) => {
|
||||
finishList = () => outcome === "success" ? resolve(rows) : reject(new Error("old inventory failed"));
|
||||
});
|
||||
});
|
||||
const onInstalled = vi.fn();
|
||||
render(<MyPackages port={port} onInstalled={onInstalled} />);
|
||||
const row = (await screen.findByText(published.package.name)).closest("li")!;
|
||||
await userEvent.click(within(row).getByRole("button", { name: t("提交审核") }));
|
||||
await userEvent.click(within(row).getByRole("button", { name: t("安装") }));
|
||||
await userEvent.click(await screen.findByRole("button", { name: t("安装") }));
|
||||
await waitFor(() => expect(onInstalled).toHaveBeenCalledTimes(1));
|
||||
await waitFor(() => expect(refresh).toHaveBeenCalledTimes(2));
|
||||
await act(async () => { await finishReview(); });
|
||||
await act(async () => finishList());
|
||||
await userEvent.click(screen.getByRole("button", { name: t("返回我的发布") }));
|
||||
const current = (await screen.findByText(published.package.name)).closest("li")!;
|
||||
expect(within(current).getByText(t("审核中"))).toBeTruthy();
|
||||
expect(within(current).getByText(t("已安装"))).toBeTruthy();
|
||||
expect(within(current).queryByRole("button", { name: t("提交审核") })).toBeNull();
|
||||
expect(screen.queryByText("old inventory failed")).toBeNull();
|
||||
expect(review).toHaveBeenCalledTimes(1);
|
||||
expect(onInstalled).toHaveBeenCalledTimes(1);
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,95 @@
|
||||
// @vitest-environment jsdom
|
||||
import { afterEach, expect, it, vi } from "vitest";
|
||||
import { act, cleanup, render, screen, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import "./testkit";
|
||||
import { OwnInstall } from "./MarketOwn";
|
||||
import { MyPackages } from "./MarketPublish";
|
||||
import { MarketGroup } from "./Market";
|
||||
import { MockPort } from "../port/mock";
|
||||
import type { AccountState, AgentPort, MarketPlan } from "../port/port";
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
const signedIn: AccountState = { signedIn: true, user: { handle: "demo", email: "[email protected]", label: "demo" } };
|
||||
|
||||
async function pendingInstall(port: AgentPort) {
|
||||
const pkg = (await port.myMarket()).find((p) => p.slug === "demo/ship-notes")!;
|
||||
const plan = await port.planOwnMarket({ slug: pkg.slug });
|
||||
let finish!: (out: MarketPlan) => void;
|
||||
vi.spyOn(port, "installOwnMarket").mockImplementationOnce(() => new Promise((resolve) => { finish = resolve; }));
|
||||
return { pkg, finish: () => finish({ ...plan, applied: true, status: "done" }) };
|
||||
}
|
||||
|
||||
async function installFromList() {
|
||||
const row = (await screen.findByText("ship-notes")).closest("li")!;
|
||||
await userEvent.click(within(row).getByRole("button", { name: "安装" }));
|
||||
await userEvent.click(await screen.findByRole("button", { name: "安装" }));
|
||||
}
|
||||
|
||||
it("notifies a successful install after the confirmation unmounts", async () => {
|
||||
const port = new MockPort() as unknown as AgentPort;
|
||||
const pending = await pendingInstall(port);
|
||||
const onInstalled = vi.fn();
|
||||
const view = render(<OwnInstall port={port} pkg={pending.pkg} onBack={() => {}} onInstalled={onInstalled} />);
|
||||
await userEvent.click(await screen.findByRole("button", { name: "安装" }));
|
||||
view.unmount();
|
||||
await act(async () => pending.finish());
|
||||
expect(onInstalled).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("does not notify an install from an earlier lifetime of the same port", async () => {
|
||||
const port = new MockPort() as unknown as AgentPort;
|
||||
const next = new MockPort() as unknown as AgentPort;
|
||||
const pending = await pendingInstall(port);
|
||||
const onInstalled = vi.fn();
|
||||
const draw = (active: AgentPort) => <OwnInstall port={active} pkg={pending.pkg} onBack={() => {}} onInstalled={onInstalled} />;
|
||||
const view = render(draw(port));
|
||||
await userEvent.click(await screen.findByRole("button", { name: "安装" }));
|
||||
view.rerender(draw(next));
|
||||
await screen.findByRole("button", { name: "安装" });
|
||||
view.rerender(draw(port));
|
||||
await screen.findByRole("button", { name: "安装" });
|
||||
await act(async () => pending.finish());
|
||||
expect(onInstalled).not.toHaveBeenCalled();
|
||||
expect(screen.queryByText("装好了,下一轮就能用")).toBeNull();
|
||||
expect(screen.getByRole<HTMLButtonElement>("button", { name: "安装" }).disabled).toBe(false);
|
||||
});
|
||||
|
||||
it.each([false, true])("ignores the old keyed install after MyPackages changes port (return=%s)", async (returnToPort) => {
|
||||
const port = new MockPort() as unknown as AgentPort;
|
||||
const next = new MockPort() as unknown as AgentPort;
|
||||
const pending = await pendingInstall(port);
|
||||
const onInstalled = vi.fn();
|
||||
const draw = (active: AgentPort) => <MyPackages port={active} onInstalled={onInstalled} />;
|
||||
const view = render(draw(port));
|
||||
await installFromList();
|
||||
view.rerender(draw(next));
|
||||
await screen.findByText("ship-notes");
|
||||
if (returnToPort) {
|
||||
view.rerender(draw(port));
|
||||
await screen.findByText("ship-notes");
|
||||
}
|
||||
await act(async () => pending.finish());
|
||||
expect(onInstalled).not.toHaveBeenCalled();
|
||||
expect(screen.queryByText("装好了,下一轮就能用")).toBeNull();
|
||||
});
|
||||
|
||||
it.each([false, true])("ignores the signed-out install after MarketGroup changes port (return=%s)", async (returnToPort) => {
|
||||
const port = new MockPort() as unknown as AgentPort;
|
||||
const next = new MockPort() as unknown as AgentPort;
|
||||
const pending = await pendingInstall(port);
|
||||
const onInstalled = vi.fn();
|
||||
const draw = (active: AgentPort, account: AccountState) => <MarketGroup port={active} account={account} onInstalled={onInstalled} onSignIn={() => {}} />;
|
||||
const view = render(draw(port, signedIn));
|
||||
await userEvent.click(screen.getByRole("radio", { name: "我的发布" }));
|
||||
await installFromList();
|
||||
view.rerender(draw(port, { signedIn: false }));
|
||||
await screen.findByRole("button", { name: "去登录" });
|
||||
view.rerender(draw(next, { signedIn: false }));
|
||||
if (returnToPort) view.rerender(draw(port, { signedIn: false }));
|
||||
await act(async () => pending.finish());
|
||||
expect(onInstalled).not.toHaveBeenCalled();
|
||||
expect(screen.queryByText("装好了,下一轮就能用")).toBeNull();
|
||||
expect(screen.getByRole("button", { name: "去登录" })).toBeTruthy();
|
||||
});
|
||||
@@ -24,6 +24,8 @@ export function OwnInstall({ port, pkg, onBack, onInstalled, onViewInstalled, on
|
||||
const [error, setError] = useState("");
|
||||
const [attempt, setAttempt] = useState(0);
|
||||
const generation = useRef(0);
|
||||
const connection = useRef({ port });
|
||||
if (connection.current.port !== port) connection.current = { port };
|
||||
const replace = !!pkg.installed && pkg.installed.version !== pkg.latestVersion;
|
||||
|
||||
useEffect(() => {
|
||||
@@ -48,6 +50,7 @@ export function OwnInstall({ port, pkg, onBack, onInstalled, onViewInstalled, on
|
||||
const install = async () => {
|
||||
if (!plan) return;
|
||||
const current = generation.current;
|
||||
const owner = connection.current;
|
||||
setBusy(true);
|
||||
onApplying?.(true);
|
||||
setError("");
|
||||
@@ -55,9 +58,8 @@ export function OwnInstall({ port, pkg, onBack, onInstalled, onViewInstalled, on
|
||||
const out = await port.installOwnMarket({
|
||||
slug: pkg.slug, version: plan.version, planId: plan.planId, replace, digest: plan.contentDigest,
|
||||
});
|
||||
if (current !== generation.current) return;
|
||||
setDone(out);
|
||||
if (out.applied) onInstalled();
|
||||
if (current === generation.current) setDone(out);
|
||||
if (out.applied && connection.current === owner) onInstalled();
|
||||
} catch (e) {
|
||||
if (current === generation.current) setError(reason(e));
|
||||
} finally {
|
||||
@@ -81,6 +83,7 @@ export function OwnInstall({ port, pkg, onBack, onInstalled, onViewInstalled, on
|
||||
{installed.length > 0 && <ul className="mkt-installed">{installed.map((action, i) => <li key={`${action.kind}:${action.name}:${i}`}>{action.name}</li>)}</ul>}
|
||||
<div className="acts">
|
||||
{back}
|
||||
{!done.ok && <button className="act" data-action="market.own-retry" onClick={() => setAttempt((n) => n + 1)}>{t("重试")}</button>}
|
||||
{location && onViewInstalled && (
|
||||
<button className="act" data-action="market.view-installed" onClick={() => onViewInstalled(location.kind, location.name!)}>{t("查看已安装能力")}</button>
|
||||
)}
|
||||
|
||||
Loaded 100 of 484 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user