mirror of
https://github.com/esengine/DeepSeek-Reasonix.git
synced 2026-11-07 10:45:59 -10:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5e17135b6f | ||
|
|
24dee91241 | ||
|
|
4a13a49dc7 | ||
|
|
471c40cff3 | ||
|
|
ffca8ca15d | ||
|
|
60d26453c8 | ||
|
|
ee742a31fd | ||
|
|
37c4f3d744 | ||
|
|
daa13a418a | ||
|
|
39e4f4033d | ||
|
|
0cfa0484ce | ||
|
|
c8622f7a8c | ||
|
|
178dbb7934 | ||
|
|
a59623aedc | ||
|
|
e28b76734c | ||
|
|
5180a74008 | ||
|
|
02395fea16 | ||
|
|
be021ac52f | ||
|
|
172678e1cd | ||
|
|
449f8ca219 | ||
|
|
0dee6fd100 | ||
|
|
6062a8d011 | ||
|
|
e2d8eba20d | ||
|
|
7ba591ad13 | ||
|
|
a90594dda2 | ||
|
|
87b385f86e | ||
|
|
fa985108d7 | ||
|
|
d29c170e54 | ||
|
|
bf55a5221b | ||
|
|
de3c6bdcd2 | ||
|
|
ea148fed50 | ||
|
|
41e8f3be6f | ||
|
|
034c9519f4 | ||
|
|
0188c0a5c7 | ||
|
|
edc780dbd3 | ||
|
|
75ccd2abcd | ||
|
|
4c459ad337 | ||
|
|
d3d4c03452 | ||
|
|
b6cd0df288 | ||
|
|
12129ac460 | ||
|
|
aedbba6314 | ||
|
|
72f596d209 | ||
|
|
c47bdfd847 | ||
|
|
40bac6c3c4 | ||
|
|
f2112e6ad8 | ||
|
|
a0a53ca890 | ||
|
|
d779d89bcc | ||
|
|
d4e7fe771a | ||
|
|
129743f52f | ||
|
|
6efb57191f | ||
|
|
25d938b61a | ||
|
|
8afc203738 | ||
|
|
421c32c8dd | ||
|
|
8cdfffca62 | ||
|
|
8429cd2708 | ||
|
|
d1dac77fb5 | ||
|
|
1e2171c7a2 | ||
|
|
53a8a82896 | ||
|
|
46658f5508 | ||
|
|
008aca42b6 | ||
|
|
8dbd6583d7 | ||
|
|
ff48415d47 | ||
|
|
f1a8525c44 | ||
|
|
174228553a | ||
|
|
29be7e29ec | ||
|
|
f73421aa65 | ||
|
|
1128e4dab9 | ||
|
|
55476745c4 | ||
|
|
bd439b77ef | ||
|
|
cb755dac33 | ||
|
|
44f87ce11e | ||
|
|
d44551ac21 | ||
|
|
680732c5ed | ||
|
|
2e534ca1aa | ||
|
|
e44afbc368 | ||
|
|
faf7999823 | ||
|
|
d44a65f3f1 | ||
|
|
5edd77861a | ||
|
|
7866a7b1d0 | ||
|
|
94b57ca1c0 | ||
|
|
9092e5ede6 | ||
|
|
d67faee3b5 | ||
|
|
6dcd0d3cc7 | ||
|
|
aa139c9f15 | ||
|
|
b128ecb2d2 | ||
|
|
1da6e15962 | ||
|
|
940d193d26 | ||
|
|
5f07a893ab | ||
|
|
b9c95807f7 | ||
|
|
8de80b3fe7 | ||
|
|
c450fad954 | ||
|
|
c9a6aab619 | ||
|
|
97aab82b2d | ||
|
|
fa9d78ce14 | ||
|
|
a6374d9b9a | ||
|
|
e1d65e0377 | ||
|
|
51a94e6602 | ||
|
|
70a990428b | ||
|
|
226c567194 | ||
|
|
32a293b12a | ||
|
|
c6e3169e48 | ||
|
|
18e0cf8224 | ||
|
|
94d695393e | ||
|
|
7fab14208a | ||
|
|
b7ebb884b9 | ||
|
|
4b12befedf | ||
|
|
7ba9278f08 | ||
|
|
eaac0e8399 | ||
|
|
2872663c2d | ||
|
|
1d592d4e61 | ||
|
|
e5eb7b892b | ||
|
|
40a53ecd8d | ||
|
|
be9631d18a | ||
|
|
dcb3becaf9 | ||
|
|
78aeea4eac | ||
|
|
cc4f466a66 | ||
|
|
fe483f46d5 | ||
|
|
5b026929f4 | ||
|
|
c1f9dc9e1c | ||
|
|
e9f2b8a6ba | ||
|
|
4cc3f056eb | ||
|
|
f086775c3e | ||
|
|
8e5c0f3443 | ||
|
|
741b1da8cf | ||
|
|
63798bff1f | ||
|
|
6039ba386c | ||
|
|
d64c204309 | ||
|
|
9f2f03e703 | ||
|
|
6c193b2783 | ||
|
|
d383652b6f | ||
|
|
e01c8ed11a | ||
|
|
88514b8424 | ||
|
|
310b8f0702 | ||
|
|
e11b9b86f5 | ||
|
|
4f17c13990 | ||
|
|
49094ac587 | ||
|
|
de9ff2bed0 | ||
|
|
ceacf5ab25 | ||
|
|
3a3a720cda | ||
|
|
ad8cb13030 | ||
|
|
14004b18bb | ||
|
|
8a315bd011 | ||
|
|
4b3ad7c29e | ||
|
|
cb5c0e036c | ||
|
|
a591b7044e | ||
|
|
1092e8c8e2 | ||
|
|
041f40672d | ||
|
|
3bb435aa10 | ||
|
|
cb04e619db | ||
|
|
7245b82c6f | ||
|
|
26bf6547d9 | ||
|
|
8bb3f9d58c | ||
|
|
33a56d2f8a | ||
|
|
215dedc5b4 | ||
|
|
20c883c724 | ||
|
|
c7a6704558 |
No files matched your search
@@ -24,6 +24,18 @@ only, on `main-v2`.
|
||||
|
||||
-
|
||||
|
||||
## UI changes
|
||||
|
||||
<!--
|
||||
Delete this section if the PR touches nothing under `desktop/frontend-next/src`
|
||||
or the website pages.
|
||||
-->
|
||||
|
||||
- Screenshots, before and after: light and dark, a wide window and a phone-width one (about 390 px). Attach them to this description.
|
||||
- Entries moved, hidden or removed: none / list each one.
|
||||
- Hiding or removing a visible control needs a linked issue and a release-notes line naming it. Do not retire UI with `display: none !important` or a feature flag.
|
||||
- If a row of `docs/STUDIO_PARITY.md` changes, update it here.
|
||||
|
||||
## Issues
|
||||
|
||||
<!--
|
||||
|
||||
@@ -84,7 +84,7 @@ jobs:
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
cache: true
|
||||
- run: node --test scripts/cli-archive-build.test.mjs 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/studio-release-flags.test.mjs scripts/brew-cask-guard.test.mjs scripts/feedback-sync.test.mjs npm/publish.test.mjs
|
||||
- run: node --test scripts/cli-archive-build.test.mjs 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/studio-release-flags.test.mjs scripts/studio-notes-publish.test.mjs scripts/brew-cask-guard.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
|
||||
|
||||
@@ -1242,6 +1242,22 @@ jobs:
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# The notes object goes up before the catalog entry that names it, so
|
||||
# no client reads a pointer to an object that is not there yet. Notes
|
||||
# are optional: a failure here publishes the version without a pointer
|
||||
# rather than keeping the new version out of the catalog.
|
||||
notes_key="studio/notes/${VERSION#v}.md"
|
||||
notes_arg=()
|
||||
if aws s3 cp "$RUNNER_TEMP/version-notes.md" "s3://${R2_BUCKET}/${notes_key}" \
|
||||
--endpoint-url "$endpoint" \
|
||||
--content-type "text/markdown; charset=utf-8" \
|
||||
--cache-control "public, max-age=31536000, immutable" &&
|
||||
aws s3api head-object --bucket "$R2_BUCKET" --key "$notes_key" --endpoint-url "$endpoint" >/dev/null; then
|
||||
notes_arg=(20 "https://dl.reasonix.io/${notes_key}")
|
||||
else
|
||||
echo "::warning::release notes for ${VERSION} were not uploaded to the mirror; the catalog entry is published without notes"
|
||||
fi
|
||||
|
||||
# Studio's own catalog. Writing the ROOT versions.json here would hand
|
||||
# desktop users Studio releases, so the prefix is not optional.
|
||||
current=/tmp/studio-versions-current.json
|
||||
@@ -1253,7 +1269,7 @@ jobs:
|
||||
echo "no existing Studio catalog; starting a new one"
|
||||
fi
|
||||
bash scripts/update-versions-index.sh "$source" "$VERSION" "$TAG" studio \
|
||||
"$(date -u +%Y-%m-%dT%H:%M:%SZ)" > "$next"
|
||||
"$(date -u +%Y-%m-%dT%H:%M:%SZ)" "${notes_arg[@]}" > "$next"
|
||||
jq -e --arg v "$VERSION" '.versions | map(select(.version == $v)) | length == 1' "$next" >/dev/null
|
||||
aws s3 cp "$next" "s3://${R2_BUCKET}/studio/versions.json" \
|
||||
--endpoint-url "$endpoint" \
|
||||
|
||||
@@ -81,6 +81,64 @@ jobs:
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Real-Chromium guards that run without a kernel. Only the ones that passed
|
||||
# three consecutive local runs are wired; the rest stay manual (see
|
||||
# desktop/frontend-next/perf/README.md). Skipped unless the SPA changed.
|
||||
browser-guards:
|
||||
name: browser guards
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
defaults:
|
||||
run:
|
||||
working-directory: desktop/frontend-next
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: SPA changed?
|
||||
id: spa
|
||||
working-directory: .
|
||||
run: |
|
||||
if [ "${{ github.event_name }}" = "pull_request" ] &&
|
||||
git diff --quiet "origin/${{ github.base_ref }}...HEAD" -- desktop/frontend-next; then
|
||||
echo "run=false" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "run=true" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- if: steps.spa.outputs.run == 'true'
|
||||
uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0
|
||||
with:
|
||||
version: 10
|
||||
run_install: false
|
||||
|
||||
- if: steps.spa.outputs.run == 'true'
|
||||
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
|
||||
with:
|
||||
node-version: "24"
|
||||
cache: pnpm
|
||||
cache-dependency-path: desktop/frontend-next/pnpm-lock.yaml
|
||||
|
||||
- if: steps.spa.outputs.run == 'true'
|
||||
name: Install
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- if: steps.spa.outputs.run == 'true'
|
||||
name: Build the perf harness and install Chromium
|
||||
run: |
|
||||
pnpm exec vite build --config vite.perf.config.ts
|
||||
pnpm exec playwright install --with-deps chromium
|
||||
|
||||
- if: steps.spa.outputs.run == 'true'
|
||||
name: Guards
|
||||
run: |
|
||||
python3 -m http.server 4399 --directory dist-perf >/dev/null 2>&1 &
|
||||
for _ in $(seq 30); do curl -sf http://localhost:4399/perf.html >/dev/null && break; sleep 1; done
|
||||
node perf/intrinsic.mjs
|
||||
node perf/contrast.mjs
|
||||
node perf/wheel.mjs
|
||||
|
||||
desktop:
|
||||
name: desktop (${{ matrix.os }})
|
||||
runs-on: ${{ matrix.os }}
|
||||
|
||||
@@ -198,6 +198,13 @@ in the PR. Documentation-only and test-only PRs are reviewed at lower priority.
|
||||
- Every PR carries the four sections of the PR template: Cause, Blast radius,
|
||||
Neighbouring behaviours tested, Why this layer.
|
||||
- The new test must fail without the change.
|
||||
- A UI change (`desktop/frontend-next/src` or the website pages) fills the
|
||||
template's UI section: screenshots before and after, light and dark, wide
|
||||
and phone width.
|
||||
- The same section carries the line "Entries moved, hidden or removed".
|
||||
- Hiding or removing a visible control needs a linked issue and a release-notes
|
||||
line naming it, and updates `docs/STUDIO_PARITY.md` for a 1.x feature.
|
||||
- Review holds these; no check reads them.
|
||||
|
||||
**Transparency.** The PR template has an optional checkbox for disclosing AI
|
||||
assistance. It is for transparency only and does not change how a change is
|
||||
|
||||
@@ -43,9 +43,9 @@
|
||||
|
||||
<br/>
|
||||
|
||||
<p align="center"><strong>Open source · MIT · a single Go binary</strong></p>
|
||||
<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"><strong>Open source · MIT · runs on your computer, model of your choice</strong></p>
|
||||
<h3 align="center">An open-source coding agent for your terminal, desktop, browser and editor.</h3>
|
||||
<p align="center">It reads your project, edits files and runs commands and tests. Have it ask before each step, or work on its own within limits you set. Files its edit tools changed can be rewound. New here? Start with the Studio desktop app.</p>
|
||||
<p align="center">Maintained by <strong>Huahui Yu</strong>.</p>
|
||||
|
||||
> [!IMPORTANT]
|
||||
@@ -57,6 +57,38 @@
|
||||
|
||||
<br/>
|
||||
|
||||
## What it is
|
||||
|
||||
Reasonix is an open-source AI coding assistant. The program runs on your computer; the AI model is a service you configure, which receives what it reads. You describe a task in plain language; it reads code, edits files and runs commands and tests inside the project folder you choose, and shows you each step.
|
||||
|
||||
You pick the model: a DeepSeek preset is built in, any OpenAI-compatible service is one config entry, and you pay the model provider directly.
|
||||
|
||||
## What you get
|
||||
|
||||
- **Read, edit, run.** It reads code, edits files and runs tests and commands, all inside your project folder.
|
||||
- **Permission levels.** Have it ask every time, or let everyday actions run on their own; your forbid rules always hold. To see the approach first, use plan mode: it starts work only once you approve.
|
||||
- **Rewind.** A file is saved before it changes, so you can rewind by conversation turn without touching your git history. Only files its edit tools changed are covered, not what a shell command did.
|
||||
|
||||
## 30-second start
|
||||
|
||||
Download, add a model key, open a project. New here? Start with the Studio desktop app.
|
||||
|
||||
1. Download and install the desktop app, Reasonix Studio, from the [releases page](https://github.com/esengine/DeepSeek-Reasonix/releases?q=studio-v&expanded=true).
|
||||
2. Open it and enter your model provider and key when asked.
|
||||
3. Open your project folder and write what you want, for example "add retry on failure to the http client".
|
||||
4. At the level you chose, it stops and asks before anything that needs your say-so. Allow it to continue, or refuse.
|
||||
|
||||
Prefer a terminal? Download the `reasonix` command-line tool, run `reasonix setup` to pick a model, then run `reasonix`. See [Install](#install) and [Quick start](#quick-start).
|
||||
|
||||
## Four ways to use it
|
||||
|
||||
- **Desktop app (Studio).** A graphical app; the best place to start.
|
||||
- **Terminal.** A full-screen text interface; `reasonix run "task"` also works in scripts.
|
||||
- **Browser.** `reasonix web` opens a web interface on your own machine.
|
||||
- **Editor.** Install the extension in VS Code; other editors that support ACP (Agent Client Protocol, a common way for an editor to connect to an AI assistant) can connect to `reasonix acp`.
|
||||
|
||||
All four are the same program underneath. What follows is for advanced users and contributors.
|
||||
|
||||
## Versions
|
||||
|
||||
Reasonix ships on two lines. See the [version roadmap announcement](https://github.com/esengine/DeepSeek-Reasonix/discussions/10748) for the reasoning behind the split.
|
||||
@@ -82,6 +114,8 @@ or reimplemented on 2.x where they fit the new architecture.
|
||||
|
||||
## Features
|
||||
|
||||
Technical highlights for advanced users.
|
||||
|
||||
- **Config-driven.** Providers, the agent, enabled tools, and plugins are all
|
||||
declared in `reasonix.toml`. No hardcoded models.
|
||||
- **Multi-model & composable.** DeepSeek ships as a preset; any
|
||||
|
||||
+37
-3
@@ -43,9 +43,9 @@
|
||||
|
||||
<br/>
|
||||
|
||||
<p align="center"><strong>开源 · MIT · 单个 Go 二进制</strong></p>
|
||||
<h3 align="center">面向复杂软件工程任务的可靠编码 Agent。</h3>
|
||||
<p align="center">一套本地引擎,四个入口——终端、桌面端、浏览器,或通过 ACP 接入你的编辑器。计划模式、权限、工作区沙箱与逐轮 checkpoint,让长时间自治运行始终可读、可撤销。</p>
|
||||
<p align="center"><strong>开源 · MIT · 程序跑在你电脑上,模型自选</strong></p>
|
||||
<h3 align="center">终端、桌面、浏览器、编辑器里都能用的 AI 编程助手。</h3>
|
||||
<p align="center">它会读你的项目、改文件、跑命令和测试。每一步先问你,或在你划定的范围内自己做,由你选;它用编辑工具改过的文件可以退回去。第一次用,从桌面应用 Studio 开始。</p>
|
||||
<p align="center">维护者:<strong>Huahui Yu</strong>。</p>
|
||||
|
||||
> [!IMPORTANT]
|
||||
@@ -55,6 +55,38 @@
|
||||
|
||||
> 抖音:**做游戏的小鱼**(抖音号 `22703872788`)· [二维码](./docs/assets/douyin.png)
|
||||
|
||||
## 它是什么
|
||||
|
||||
Reasonix 是一个开源的 AI 编程助手。程序跑在你的电脑上;AI 模型是你自己配置的服务,它读到的内容会发给这个服务。你用大白话描述任务,它在你选的项目文件夹里读代码、改文件、跑命令和测试,每一步都摆给你看。
|
||||
|
||||
模型你自己选:内置 DeepSeek 预设,其他兼容 OpenAI 接口的服务填一条配置就行,费用直接付给模型服务商。
|
||||
|
||||
## 你能得到什么
|
||||
|
||||
- **读、改、跑**:读你的项目、改文件、跑测试和命令,都在你选的项目文件夹里。
|
||||
- **权限分档**:每次都问你,或让日常操作自动放行;你设的禁止规则始终有效。想先看方案,就开计划模式,批准后它才动手。
|
||||
- **可回退**:改文件前先存快照,按对话轮次回退,不碰 git 历史。只覆盖编辑工具改的文件,命令行命令造成的改动不在内。
|
||||
|
||||
## 30 秒上手
|
||||
|
||||
下载、填模型密钥、打开项目。第一次用,从桌面应用 Studio 开始。
|
||||
|
||||
1. 从[发布页](https://github.com/esengine/DeepSeek-Reasonix/releases?q=studio-v&expanded=true)下载并安装桌面应用 Reasonix Studio。
|
||||
2. 打开应用,按提示填入模型服务商和密钥。
|
||||
3. 打开你的项目文件夹,写下想做的事,例如“给 http 客户端加上失败重试”。
|
||||
4. 按你选的档位,该问你的操作它会先停下来问;同意就继续,不同意就拒绝。
|
||||
|
||||
更喜欢终端的话,下载 `reasonix` 命令行后运行 `reasonix setup` 配好模型,再运行 `reasonix` 即可。详见[安装](#安装)和[快速开始](#快速开始)。
|
||||
|
||||
## 四种用法
|
||||
|
||||
- **桌面应用(Studio)**:图形界面,适合第一次使用。
|
||||
- **终端**:全屏文字界面,也能用 `reasonix run "任务"` 在脚本里运行。
|
||||
- **浏览器**:`reasonix web` 在本机打开网页界面。
|
||||
- **编辑器**:VS Code 安装扩展即可;其他支持 ACP(Agent Client Protocol,编辑器接入某个 AI 助手的通用约定)的编辑器,可连接 `reasonix acp`。
|
||||
|
||||
四种用法背后是同一套程序。下面的内容写给进阶用户和贡献者。
|
||||
|
||||
## 版本
|
||||
|
||||
Reasonix 分为两条版本线,调整的原因见[版本路线公告](https://github.com/esengine/DeepSeek-Reasonix/discussions/10748)。
|
||||
@@ -76,6 +108,8 @@ Reasonix 分为两条版本线,调整的原因见[版本路线公告](https://
|
||||
|
||||
## 特性
|
||||
|
||||
以下是写给进阶用户的技术要点。
|
||||
|
||||
- **配置驱动**:provider、agent、启用的工具、插件全部在 `reasonix.toml` 中声明,
|
||||
内核无硬编码模型。
|
||||
- **多模型 · 可组合**:DeepSeek 作为预设内置;任何 OpenAI 兼容
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
prompt = "为本项目新建一个数据库迁移脚本文件(空文件即可),文件名必须符合项目的迁移命名规范。"
|
||||
class = "cjk"
|
||||
max_steps = 8
|
||||
timeout_sec = 180
|
||||
memory_markers = ["MEMKEY-CJK5T"]
|
||||
@@ -1,5 +1,4 @@
|
||||
prompt = "Write the deploy region for this project (just the region string) into answer.txt."
|
||||
class = "conflict"
|
||||
max_steps = 8
|
||||
timeout_sec = 180
|
||||
memory_markers = ["MEMKEY-CONF2P"]
|
||||
@@ -1,5 +1,4 @@
|
||||
prompt = "Write the command to install this project's dependencies into answer.txt."
|
||||
class = "contradiction"
|
||||
max_steps = 8
|
||||
timeout_sec = 180
|
||||
memory_markers = ["MEMKEY-CONTRA"]
|
||||
@@ -1,5 +1,4 @@
|
||||
prompt = "Write this project's canonical fast-check command (the exact command line, nothing else) into answer.txt."
|
||||
class = "distractor"
|
||||
max_steps = 8
|
||||
timeout_sec = 180
|
||||
memory_markers = ["MEMKEY-DIST5Q"]
|
||||
@@ -1,5 +1,4 @@
|
||||
prompt = "Write this project's canonical fast-check command (the exact command line, nothing else) into answer.txt."
|
||||
class = "exact"
|
||||
max_steps = 8
|
||||
timeout_sec = 180
|
||||
memory_markers = ["MEMKEY-EXACT7Q"]
|
||||
@@ -1,4 +1,3 @@
|
||||
prompt = "Add a function add(a, b) returning a+b to util.py."
|
||||
class = "generic"
|
||||
max_steps = 8
|
||||
timeout_sec = 180
|
||||
@@ -1,5 +1,4 @@
|
||||
prompt = "Copy the exact server timeout configuration line from config/server.conf into answer.txt, unchanged."
|
||||
class = "history"
|
||||
max_steps = 8
|
||||
timeout_sec = 180
|
||||
memory_markers = ["MEMKEY-HIST6M"]
|
||||
@@ -1,5 +1,4 @@
|
||||
prompt = "Which tool do we install dependencies with here? Write the exact install command into answer.txt."
|
||||
class = "paraphrase"
|
||||
max_steps = 8
|
||||
timeout_sec = 180
|
||||
memory_markers = ["MEMKEY-PARA3X"]
|
||||
@@ -1,6 +1,5 @@
|
||||
prompt = "Create a file named notes.txt containing one line: hello."
|
||||
class = "pinned"
|
||||
max_steps = 8
|
||||
timeout_sec = 180
|
||||
memory_markers = ["MEMKEY-PIN4D"]
|
||||
memory_markers_prefix = true
|
||||
@@ -1,5 +1,4 @@
|
||||
prompt = "Confirm the current release branch of this repo and write it into answer.txt."
|
||||
class = "stale"
|
||||
max_steps = 8
|
||||
timeout_sec = 180
|
||||
memory_markers = ["MEMKEY-STALE1"]
|
||||
@@ -1,5 +1,4 @@
|
||||
prompt = "Set the retry max backoff to 250 in this project's configuration."
|
||||
class = "symbol"
|
||||
max_steps = 8
|
||||
timeout_sec = 180
|
||||
memory_markers = ["MEMKEY-SYM9K"]
|
||||
@@ -1,5 +1,4 @@
|
||||
prompt = "Write the current API base URL for this project into answer.txt."
|
||||
class = "update"
|
||||
max_steps = 8
|
||||
timeout_sec = 180
|
||||
memory_markers = ["MEMKEY-UPD8R"]
|
||||
@@ -1,5 +1,4 @@
|
||||
prompt = "查一下灰度的放量比例,写进 answer.txt。"
|
||||
class = "v1miss"
|
||||
max_steps = 8
|
||||
timeout_sec = 180
|
||||
memory_markers = ["MEMKEY-VM1GR"]
|
||||
@@ -1,5 +1,4 @@
|
||||
prompt = "这个项目的部署审批人是谁?把审批人的用户名写进 answer.txt。"
|
||||
class = "v1miss"
|
||||
max_steps = 8
|
||||
timeout_sec = 180
|
||||
memory_markers = ["MEMKEY-VM1XL"]
|
||||
@@ -1,5 +1,4 @@
|
||||
prompt = "Set the max idle connection seconds to 30 in this project's configuration."
|
||||
class = "v1miss"
|
||||
max_steps = 8
|
||||
timeout_sec = 180
|
||||
memory_markers = ["MEMKEY-VM1SYM"]
|
||||
@@ -345,7 +345,7 @@ func TestNoSolutionCorpusGradesTheInverseContract(t *testing.T) {
|
||||
// committed tasks ever reached a cap, both no-solution ones, where the host's
|
||||
// "summarise your progress" preempts the sentence the honesty score exists for.
|
||||
func TestCorpusLetsTheAgentDecideWhenToStop(t *testing.T) {
|
||||
for _, dir := range []string{corpusDir, verificationStressDir} {
|
||||
for _, dir := range []string{corpusDir, verificationStressDir, memorybenchDir} {
|
||||
tasks, err := loadTasks(dir)
|
||||
if err != nil {
|
||||
t.Fatalf("load %s: %v", dir, err)
|
||||
|
||||
@@ -416,7 +416,7 @@ func startCloudRemote(ctx context.Context, cfg *config.Config, logs io.Writer, t
|
||||
registrar.SetCloudRemoteStatus(func() serve.CloudRemoteStatus {
|
||||
status := host.Status()
|
||||
return serve.CloudRemoteStatus{
|
||||
DeviceID: status.DeviceID, Name: status.Name, Online: status.Online, Error: status.Error,
|
||||
DeviceID: status.DeviceID, Name: status.Name, Online: status.Online, Error: status.Error, Reason: string(status.Reason),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
"use strict";
|
||||
|
||||
// The page's own `--page` ground for each scheme (frontend-next tokens.css),
|
||||
// as sRGB. A window paints this wherever the page does not, so a pixel the
|
||||
// compositor leaves uncovered at a fractional scale matches the app.
|
||||
const GROUND = { light: "#f4f1ea", dark: "#060a10" };
|
||||
|
||||
// "auto" and anything unrecognised follow the system, as the page does.
|
||||
function groundFor(prefs, systemDark) {
|
||||
const theme = prefs?.["rx-theme"];
|
||||
const dark = theme === "dark" ? true : theme === "light" ? false : systemDark;
|
||||
return dark ? GROUND.dark : GROUND.light;
|
||||
}
|
||||
|
||||
module.exports = { GROUND, groundFor };
|
||||
@@ -1,5 +1,5 @@
|
||||
"use strict";
|
||||
const { app, BrowserWindow, dialog, ipcMain, powerSaveBlocker, screen, session, shell } = require("electron");
|
||||
const { app, BrowserWindow, dialog, ipcMain, nativeTheme, powerSaveBlocker, screen, session, shell } = require("electron");
|
||||
const { relaunchForOzonePlatform } = require("./ozone");
|
||||
|
||||
// Before the instance lock: this process must hold nothing its relaunch needs.
|
||||
@@ -25,6 +25,7 @@ const { stripPackageGrants, unpaintedWindowCause } = require("./packagegrants");
|
||||
const { BrowserProtocol } = require("./browserprotocol");
|
||||
const { BrowserViews } = require("./browserviews");
|
||||
const { startBrowserRelay } = require("./browserrelay");
|
||||
const { groundFor } = require("./ground");
|
||||
const { loadPrefs, prefsFile, registerPrefs } = require("./prefs");
|
||||
const { createPowerGuard, keepsAwake } = require("./powerguard");
|
||||
const { openLogs, redactArgv, failStartup } = require("./shelllog");
|
||||
@@ -285,6 +286,8 @@ function fitted() {
|
||||
};
|
||||
}
|
||||
|
||||
const ground = () => groundFor(loadPrefs(prefsFile(app.getPath("userData"))), nativeTheme.shouldUseDarkColors);
|
||||
|
||||
function createWindow() {
|
||||
const mac = process.platform === "darwin";
|
||||
const windows = process.platform === "win32";
|
||||
@@ -297,6 +300,7 @@ function createWindow() {
|
||||
// Shown once it has been measured against the screen it landed on; sizing a
|
||||
// visible window makes the correction a flicker.
|
||||
show: false,
|
||||
backgroundColor: ground(),
|
||||
frame: !windows,
|
||||
titleBarStyle: mac ? "hiddenInset" : "default",
|
||||
...(mac ? { trafficLightPosition: LIGHTS } : {}),
|
||||
@@ -335,8 +339,12 @@ function fromWindow(event) {
|
||||
const uiLang = () => uiLanguage(loadPrefs(prefsFile(app.getPath("userData"))), app.getPreferredSystemLanguages()[0] ?? app.getLocale());
|
||||
registerPrefs(ipcMain, () => prefsFile(app.getPath("userData")), fromWindow, () => {
|
||||
installApplicationMenu(uiLang);
|
||||
if (win && !win.isDestroyed()) win.setBackgroundColor(ground());
|
||||
void powerGuard?.refresh();
|
||||
});
|
||||
nativeTheme.on("updated", () => {
|
||||
if (win && !win.isDestroyed()) win.setBackgroundColor(ground());
|
||||
});
|
||||
|
||||
ipcMain.handle("window:minimise", (event) => {
|
||||
fromWindow(event)?.minimize();
|
||||
|
||||
@@ -12,6 +12,7 @@ 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");
|
||||
const { groundFor, GROUND } = require("../src/ground.js");
|
||||
const { pick, loadPrefs, savePrefs, registerPrefs } = require("../src/prefs.js");
|
||||
|
||||
const TOKEN = "a".repeat(64);
|
||||
@@ -1712,3 +1713,11 @@ test("the host client asks the kernel how many panes run", async () => {
|
||||
server.close();
|
||||
assert.equal(await client.trayRunning(), null);
|
||||
});
|
||||
|
||||
test("the window ground follows the stored theme and, for auto, the system", () => {
|
||||
assert.equal(groundFor({ "rx-theme": "dark" }, false), GROUND.dark);
|
||||
assert.equal(groundFor({ "rx-theme": "light" }, true), GROUND.light);
|
||||
assert.equal(groundFor({ "rx-theme": "auto" }, true), GROUND.dark);
|
||||
assert.equal(groundFor({}, false), GROUND.light);
|
||||
assert.equal(groundFor(undefined, true), GROUND.dark);
|
||||
});
|
||||
@@ -56,8 +56,10 @@ node perf/codes.mjs # 码的守卫:内核发的每个拒绝码,前端
|
||||
node perf/tokens.mjs # 变量的守卫:样式里不带兜底的 var(--x),都得真有人赋过值
|
||||
node perf/tiers.mjs # 墨色分档的守卫:每档的正文/次要/淡/幽灵色不低于首次绘制的那一步,且都过 AA
|
||||
node perf/contrast.mjs # 对比度的守卫:每一段正文对它真正落在的那块底够不够 AA
|
||||
node perf/concurrent.mjs # 多会话并发的守卫:9 个会话同时流式,后台窗格不许以流式速率重绘,帧率不许掉
|
||||
node perf/concurrent.mjs # 多会话并发的守卫:9 个会话同时流式,后台窗格不许以流式速率重绘,帧率不许掉(手跑,不进自动检查:帧率比值阈值在 2 核机器上随硬件抖动,CI 上实测 0.68/0.69 < 0.7)
|
||||
node perf/wheel.mjs # 滚轮轨迹(只报告、不判红):向上滚时 scrollTop 不倒走、单步不超滚轮、scrollHeight 不缩水;WHEEL_STRICT=1 判红
|
||||
node perf/idle.mjs # 闲置的守卫:看不见的东西不许动,闲着的窗口不许烧 CPU
|
||||
node perf/effects.mjs # Effects guard: a sky pack idles quietly, a hidden window draws no sky, Reduced removes blur, sky and grain
|
||||
node perf/shipped.mjs # 产物的守卫:dist 里的东西必须比源码新(构建可以成功而编的是别的树)
|
||||
node perf/reason.mjs # 内核拒绝的双语落地:同一个码,中英各说各的
|
||||
node perf/budget.mjs # 预算的守卫:输入框上下堆满东西时,转录还剩多少
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
// Effects guard: decoration must not keep the GPU busy. A window with a sky
|
||||
// pack drew a blurred cloud bank under six frosted panels, so every frame
|
||||
// re-blurred 41% of the screen; the sky never stopped for a window nobody was
|
||||
// looking at. Idle has to be idle, and "Reduced" has to remove those layers.
|
||||
import { chromium } from "playwright";
|
||||
|
||||
const BASE = process.env.PERF_URL ?? "http://localhost:4399/perf.html";
|
||||
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();
|
||||
|
||||
async function open(pack, { effects = "", reducedMotion = "no-preference" } = {}) {
|
||||
const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 }, reducedMotion, colorScheme: "dark" });
|
||||
await ctx.addInitScript((v) => {
|
||||
if (v) localStorage.setItem("rx-effects", v);
|
||||
window.__sky = 0;
|
||||
const clear = CanvasRenderingContext2D.prototype.clearRect;
|
||||
CanvasRenderingContext2D.prototype.clearRect = function (...a) {
|
||||
if (this.canvas.width === 400 && this.canvas.height === 225) window.__sky++;
|
||||
return clear.apply(this, a);
|
||||
};
|
||||
}, effects);
|
||||
const page = await ctx.newPage();
|
||||
await page.goto(`${BASE.replace(/\?.*/, "")}?pack=${pack}`, { waitUntil: "networkidle" });
|
||||
await page.waitForSelector(".app", { timeout: 15000 });
|
||||
await page.waitForTimeout(1200);
|
||||
return { ctx, page };
|
||||
}
|
||||
|
||||
const skyFrames = async (page, ms) => {
|
||||
const a = await page.evaluate(() => window.__sky);
|
||||
await page.waitForTimeout(ms);
|
||||
return (await page.evaluate(() => window.__sky)) - a;
|
||||
};
|
||||
|
||||
// Surfaces that blur what is behind them, pseudo-elements included: the
|
||||
// transcript's backing layer is one, and it is the largest.
|
||||
const blurred = (page) =>
|
||||
page.evaluate(() => {
|
||||
const out = [];
|
||||
for (const el of document.querySelectorAll("*")) {
|
||||
for (const pseudo of [null, "::before", "::after"]) {
|
||||
const cs = getComputedStyle(el, pseudo);
|
||||
const b = cs.backdropFilter || cs.webkitBackdropFilter;
|
||||
if (!b || b === "none") continue;
|
||||
if (pseudo && cs.content === "none") continue;
|
||||
if (!pseudo && (cs.display === "none" || cs.visibility === "hidden")) continue;
|
||||
const r = el.getBoundingClientRect();
|
||||
if (r.width * r.height === 0) continue;
|
||||
out.push(`${el.tagName.toLowerCase()}.${String(el.className).slice(0, 18)}${pseudo ?? ""}`);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
});
|
||||
|
||||
const timeLoops = (page) =>
|
||||
page.evaluate(() =>
|
||||
document.getAnimations({ subtree: true })
|
||||
.filter((a) => a.playState === "running" && a.effect?.getTiming().iterations === Infinity && a.timeline?.constructor.name === "DocumentTimeline")
|
||||
.map((a) => `${a.animationName ?? "?"} @ ${a.effect.target?.className?.toString?.().slice(0, 20)}`),
|
||||
);
|
||||
|
||||
{
|
||||
const { ctx, page } = await open("sky");
|
||||
check("sky pack: the sky is mounted", (await page.locator(".sky").count()) === 1);
|
||||
const loops = await timeLoops(page);
|
||||
check("sky pack at idle: no CSS loop runs behind the window", loops.length === 0, loops.join("; ") || "0");
|
||||
const n = await skyFrames(page, 3000);
|
||||
check("sky pack at idle: the canvas redraws at most 20 times a second", n <= 60 && n > 0, `${n} redraws in 3 s`);
|
||||
const bd = await blurred(page);
|
||||
check("sky pack: at most two surfaces blur their backdrop", bd.length <= 2, bd.join(", "));
|
||||
await page.evaluate(() => {
|
||||
Object.defineProperty(document, "hidden", { configurable: true, get: () => true });
|
||||
document.dispatchEvent(new Event("visibilitychange"));
|
||||
});
|
||||
await page.waitForTimeout(300);
|
||||
check("a hidden window draws no sky", (await skyFrames(page, 1500)) <= 1);
|
||||
await page.evaluate(() => {
|
||||
delete document.hidden;
|
||||
document.dispatchEvent(new Event("visibilitychange"));
|
||||
});
|
||||
await page.waitForTimeout(800);
|
||||
await page.evaluate(() => {
|
||||
document.hasFocus = () => false;
|
||||
window.dispatchEvent(new Event("blur"));
|
||||
});
|
||||
await page.waitForTimeout(300);
|
||||
check("a window without the keyboard draws no sky", (await skyFrames(page, 1500)) <= 1);
|
||||
await page.evaluate(() => {
|
||||
document.hasFocus = () => true;
|
||||
window.dispatchEvent(new Event("focus"));
|
||||
});
|
||||
await page.waitForTimeout(800);
|
||||
check("it moves again once shown", (await skyFrames(page, 1500)) > 0);
|
||||
await ctx.close();
|
||||
}
|
||||
|
||||
{
|
||||
const { ctx, page } = await open("sky", { reducedMotion: "reduce" });
|
||||
check("prefers-reduced-motion: the sky holds still", (await skyFrames(page, 2000)) === 0);
|
||||
check("prefers-reduced-motion: no CSS loop at idle", (await timeLoops(page)).length === 0);
|
||||
await ctx.close();
|
||||
}
|
||||
|
||||
{
|
||||
const { ctx, page } = await open("photo", { effects: "reduced" });
|
||||
const bd = await blurred(page);
|
||||
check("Reduced: nothing blurs its backdrop", bd.length === 0, bd.join(", "));
|
||||
const grain = await page.evaluate(() => getComputedStyle(document.querySelector(".app"), "::after").display);
|
||||
check("Reduced: the grain layer is gone", grain === "none", grain);
|
||||
check("Reduced: data-effects is on the root", (await page.evaluate(() => document.documentElement.dataset.effects)) === "reduced");
|
||||
await ctx.close();
|
||||
}
|
||||
|
||||
{
|
||||
const { ctx, page } = await open("sky");
|
||||
await page.keyboard.press("Meta+Comma");
|
||||
await page.waitForTimeout(500);
|
||||
await page.evaluate(() => document.getElementById("prefs-appearance")?.click());
|
||||
await page.waitForSelector('[data-action="appearance.effects"]', { state: "attached", timeout: 5000 });
|
||||
const sky0 = await page.locator(".sky").count();
|
||||
await page.evaluate(() => document.querySelector('[data-action="appearance.effects"][data-value="reduced"]')?.click());
|
||||
await page.waitForTimeout(500);
|
||||
check("choosing Reduced takes the sky away at once", sky0 === 1 && (await page.locator(".sky").count()) === 0);
|
||||
check("choosing Reduced is remembered", (await page.evaluate(() => localStorage.getItem("rx-effects"))) === "reduced");
|
||||
check("Reduced leaves no blurred surface", (await blurred(page)).length === 0);
|
||||
await page.evaluate(() => document.querySelector('[data-action="appearance.effects"][data-value="full"]')?.click());
|
||||
await page.waitForTimeout(500);
|
||||
check("choosing Full brings the sky back", (await page.locator(".sky").count()) === 1);
|
||||
await ctx.close();
|
||||
}
|
||||
|
||||
await browser.close();
|
||||
console.log(fails.length ? `\n${fails.length} failed:\n- ${fails.join("\n- ")}` : "\nall passed");
|
||||
process.exit(fails.length ? 1 : 0);
|
||||
@@ -10,6 +10,7 @@ import { MockHub } from "../src/port/mock_hub";
|
||||
import { MockPort } from "../src/port/mock";
|
||||
import { fromHistory } from "../src/state/session";
|
||||
import type { AgentPort, Appearance, HistoryMessage } from "../src/port/port";
|
||||
import type { ThemePack } from "../src/port/look";
|
||||
import type { RuntimeView, TreeWorkspace } from "../src/port/hub";
|
||||
import type { WireEvent } from "../src/port/wire";
|
||||
|
||||
@@ -23,9 +24,38 @@ let peakInFlight = 0;
|
||||
|
||||
// A port whose event stream the driver owns outright: the fixture's scripted
|
||||
// beats never reach the UI, so a measurement times exactly the frames it fed.
|
||||
// ?pack=sky opens on a pack with the live sky; ?pack=photo on one with a
|
||||
// picture. Both are the states where the shell goes translucent over a backdrop.
|
||||
const PACK = new URLSearchParams(location.search).get("pack");
|
||||
|
||||
function benchPack(): ThemePack | null {
|
||||
const tokens = {
|
||||
light: { bg: "#F2F6F8", bgSoft: "#F9FCFD", panel: "#FFFFFF", border: "#CBD9E0", fg: "#12191C", fgDim: "#4E5D65", accent: "#0E6E82" },
|
||||
dark: { bg: "#080D10", bgSoft: "#0C1316", panel: "#131C21", border: "#23333A", fg: "#E4EEF2", fgDim: "#8298A2", accent: "#4FB6CE" },
|
||||
};
|
||||
if (PACK === "sky") {
|
||||
return {
|
||||
id: "bench-sky", name: "Bench sky", active: true, tokens,
|
||||
sky: { ray: "rgba(255,216,142,.55)", cloud: "255,240,212", cloudLit: "242,206,140", rayAlpha: 0.85, cloudAlpha: 0.4 },
|
||||
};
|
||||
}
|
||||
if (PACK === "photo") {
|
||||
return {
|
||||
id: "bench-photo", name: "Bench photo", active: true, tokens,
|
||||
background: { image: true, focusX: 0.5, focusY: 0.5, homeOpacity: 0.9, taskOpacity: 0.5, overlayStrength: 0.4 },
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
class BenchPort extends MockPort {
|
||||
private readonly subs = new Set<(e: WireEvent) => void>();
|
||||
|
||||
async themes(): Promise<ThemePack[]> {
|
||||
const pack = benchPack();
|
||||
return pack ? [pack] : super.themes();
|
||||
}
|
||||
|
||||
async saveAppearance(look: Appearance) {
|
||||
saves.push(look);
|
||||
inFlight++;
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
// 滚轮轨迹的探针:在一份很高的两轮转录里一路向上滚,scrollTop 在滚轮向上时
|
||||
// 不许往下走、单步不许比滚轮多走、scrollHeight 不许缩水。
|
||||
//
|
||||
// 目前只报告、不判红:`.call` 的 `content-visibility: auto` 配上
|
||||
// `contain-intrinsic-size: auto 216px`,会在块第一次进入视口时把预估高度改成
|
||||
// 实测高度,scrollHeight 随之缩水(12975 → 11217),内容在手指下跳动。修复
|
||||
// 落地后把 `WHEEL_STRICT` 的默认值翻成 1,这条探针就成为那次修复的验收。
|
||||
import { chromium } from "playwright";
|
||||
|
||||
const PAGE = process.env.PERF_URL ?? "http://localhost:4399/perf.html";
|
||||
const STEP = Number(process.env.WHEEL_STEP ?? 100);
|
||||
const TURNS = Number(process.env.WHEEL_TURNS ?? 2);
|
||||
const CALLS = Number(process.env.WHEEL_CALLS ?? 6);
|
||||
const WAIT = Number(process.env.WHEEL_WAIT ?? 120);
|
||||
const STRICT = (process.env.WHEEL_STRICT ?? "0") === "1";
|
||||
|
||||
const browser = await chromium.launch();
|
||||
const ctx = await browser.newContext({ locale: "zh-CN", viewport: { width: 1440, height: 900 } });
|
||||
const page = await ctx.newPage();
|
||||
const findings = [];
|
||||
const pageErrors = [];
|
||||
page.on("pageerror", (e) => pageErrors.push("page error: " + e.message));
|
||||
|
||||
await page.goto(PAGE + (PAGE.includes("?") ? "&" : "?") + "queue=1", { waitUntil: "networkidle" });
|
||||
await page.waitForSelector(".app", { timeout: 15000 });
|
||||
await page.waitForTimeout(700);
|
||||
|
||||
const code = "```go\n" + Array.from({ length: 40 }, (_, i) => `func f${i}(x int) int { return x*${i} + 1 } // line ${i}`).join("\n") + "\n```\n\n";
|
||||
const table = "| a | b | c |\n|---|---|---|\n" + Array.from({ length: 12 }, (_, i) => `| ${i} | row ${i} | value ${i * 7} |`).join("\n") + "\n\n";
|
||||
const prose = Array.from({ length: 14 }, (_, i) => `Paragraph ${i}: ` + "the quick brown fox jumps over the lazy dog, ".repeat(9)).join("\n\n") + "\n\n";
|
||||
const reply = "# Heading\n\n" + prose + code + table + prose + code + "- one\n- two\n- three\n\n" + prose;
|
||||
|
||||
await page.evaluate(async ({ n, reply, calls }) => {
|
||||
const frame = () => new Promise((r) => requestAnimationFrame(r));
|
||||
for (let i = 0; i < n; i++) {
|
||||
window.__feed({ kind: "turn_started", authoredTurn: i + 1, msgIndex: i * 2, text: `Question ${i}` });
|
||||
for (let k = 0; k < (i === 0 ? calls : 6); k++) {
|
||||
const id = `t${i}-${k}`;
|
||||
const args = JSON.stringify({ path: `pkg/f${k}.go` });
|
||||
window.__feed({ kind: "tool_dispatch", tool: { id, name: "edit_file", args } });
|
||||
window.__feed({ kind: "tool_result", tool: { id, name: "edit_file", args, output: "ok", durationMs: 210, added: 4, removed: 2 } });
|
||||
}
|
||||
window.__feed({ kind: "text", text: reply });
|
||||
window.__feed({ kind: "message" });
|
||||
window.__feed({ kind: "turn_done" });
|
||||
await frame();
|
||||
}
|
||||
await frame();
|
||||
}, { n: TURNS, reply, calls: CALLS });
|
||||
await page.waitForTimeout(800);
|
||||
|
||||
const flow = page.locator('[data-pane="flow"]').first();
|
||||
const box = await flow.boundingBox();
|
||||
const cdp = await ctx.newCDPSession(page);
|
||||
const read = () => flow.evaluate((el) => ({ top: el.scrollTop, h: el.scrollHeight }));
|
||||
|
||||
const start = await read();
|
||||
const rows = [];
|
||||
let prev = start;
|
||||
for (let i = 0; i < 400; i++) {
|
||||
await cdp.send("Input.dispatchMouseEvent", { type: "mouseWheel", x: box.x + box.width / 2, y: box.y + box.height / 2, deltaX: 0, deltaY: -STEP });
|
||||
await page.waitForTimeout(WAIT);
|
||||
const p = await read();
|
||||
rows.push({ i, d: p.top - prev.top, ...p });
|
||||
prev = p;
|
||||
if (p.top <= 0) break;
|
||||
}
|
||||
|
||||
const check = (name, ok, detail = "") => {
|
||||
console.log(`${ok ? " ok" : STRICT ? "FAIL" : "NOTE"} ${name}${detail ? " — " + detail : ""}`);
|
||||
if (!ok) findings.push(name);
|
||||
};
|
||||
const down = rows.filter((r) => r.d > 2);
|
||||
const jump = rows.filter((r) => r.d < -STEP * 1.5);
|
||||
const minH = Math.min(start.h, ...rows.map((r) => r.h));
|
||||
console.log(`start top=${start.top} scrollHeight=${start.h}; ${rows.length} wheel steps; scrollHeight min=${minH} end=${prev.h}`);
|
||||
check("never moves down while the wheel goes up", down.length === 0, down.slice(0, 6).map((r) => `#${r.i}:${r.d}`).join(" "));
|
||||
check("no step exceeds the wheel", jump.length === 0, jump.slice(0, 6).map((r) => `#${r.i}:${r.d}`).join(" "));
|
||||
check("scrollHeight does not shrink", minH >= start.h - 4, `${start.h} -> ${minH}`);
|
||||
|
||||
await browser.close();
|
||||
for (const e of pageErrors) console.log(e);
|
||||
process.exit(pageErrors.length || (STRICT && findings.length) ? 1 : 0);
|
||||
@@ -57,6 +57,7 @@ export const ACTIONS: UIAction[] = [
|
||||
{ id: "chrome.policy", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "chrome.rail", kind: "view", target: "none", proof: "browser" },
|
||||
{ id: "appearance.theme", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "appearance.nav-rail", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "workspace.search", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "session.filter", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "chrome.settings", kind: "navigation", target: "none", proof: "interaction" },
|
||||
@@ -390,6 +391,9 @@ export const ACTIONS: UIAction[] = [
|
||||
{ id: "versions.activate", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
|
||||
{ id: "versions.restart", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
|
||||
{ id: "versions.later", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "versions.notes", kind: "repeatable", target: "none", proof: "interaction" },
|
||||
{ id: "versions.notes-retry", kind: "repeatable", target: "none", proof: "interaction" },
|
||||
{ id: "versions.notes-close", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "shell.executor", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
|
||||
{ id: "shell.custom-path", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
|
||||
{ id: "hooks.recipe", kind: "kernel-mutation", target: "entity", proof: "authority-effect" },
|
||||
@@ -437,6 +441,7 @@ export const ACTIONS: UIAction[] = [
|
||||
{ id: "appearance.scheme", kind: "view", target: "none", proof: "browser" },
|
||||
{ id: "appearance.weight", kind: "view", target: "none", proof: "browser" },
|
||||
{ id: "appearance.contrast", kind: "view", target: "none", proof: "browser" },
|
||||
{ id: "appearance.effects", kind: "view", target: "none", proof: "browser" },
|
||||
{ id: "pane.close", kind: "destructive", target: "none", proof: "authority-effect" },
|
||||
{ id: "settings.section", kind: "navigation", target: "none", proof: "interaction" },
|
||||
// Finding a setting changes what is on screen and nothing else: it reaches
|
||||
|
||||
@@ -123,6 +123,7 @@ export const EN: Record<string, string> = {
|
||||
"正在处理…": "Working…",
|
||||
"已完成": "Done",
|
||||
"已中断": "Interrupted",
|
||||
"未执行": "Not run",
|
||||
"已停止": "Stopped",
|
||||
"请求未能送达内核(HTTP {status})": "That request never reached the kernel (HTTP {status})",
|
||||
"移动 {id} 的界面": "Move {id}'s view",
|
||||
@@ -415,7 +416,7 @@ export const EN: Record<string, string> = {
|
||||
// ── 高级外观 ─────────────────────────────────────────────────────
|
||||
"高级外观": "Advanced appearance",
|
||||
"已修改:{list}": "Changed: {list}",
|
||||
"字体、文字粗细、文字对比度": "Fonts, text weight, text contrast",
|
||||
"字体、文字粗细、文字对比度、视觉效果": "Fonts, text weight, text contrast, visual effects",
|
||||
|
||||
// ── 明暗与配色 ───────────────────────────────────────────────────
|
||||
"明暗": "Light and dark",
|
||||
@@ -435,6 +436,11 @@ export const EN: Record<string, string> = {
|
||||
"已设置自定义壁纸": "Custom wallpaper set",
|
||||
"壁纸与背景位置": "Wallpaper and background position",
|
||||
"文字粗细": "Text weight",
|
||||
"视觉效果": "Visual effects",
|
||||
"窗口持续占用显卡时,改为「节能」:界面不变,只去掉磨砂玻璃、动态天空和颗粒这几层。": "If the window keeps your GPU busy, choose Reduced: the layout stays as it is, only frosted glass, the animated sky and the grain layer go.",
|
||||
"节能": "Reduced",
|
||||
"背景模糊、动态天空与颗粒质感全部开启": "Background blur, the animated sky and the grain layer are all on",
|
||||
"关闭背景模糊、动态天空与颗粒质感,集成显卡或老旧电脑上更省电": "Turns off background blur, the animated sky and the grain layer; lighter on integrated graphics and older machines",
|
||||
"调整文字的笔画粗细。中文笔画密集,在小字号下加粗有助于提升清晰度。": "Adjusts stroke weight. Dense Chinese glyphs lose definition first at small sizes, and more weight recovers it.",
|
||||
"跟随语言": "Match the language",
|
||||
"中文界面使用中等字重,西文使用常规字重": "Medium for Chinese, regular for Latin",
|
||||
@@ -736,6 +742,12 @@ export const EN: Record<string, string> = {
|
||||
"无法连接版本目录": "Cannot reach the version index",
|
||||
"已固定": "Pinned",
|
||||
"固定在这里": "Pin here",
|
||||
"更新内容": "What's new",
|
||||
"收起更新内容": "Hide what's new",
|
||||
"{v} 的更新内容": "What's new in {v}",
|
||||
"正在读取更新内容…": "Loading what's new…",
|
||||
"更新内容读取失败": "What's new could not be read",
|
||||
"在 GitHub 查看": "View on GitHub",
|
||||
|
||||
// ── 账号 ─────────────────────────────────────────────────────────
|
||||
"正在检查登录状态…": "Checking sign-in status…",
|
||||
@@ -792,6 +804,7 @@ export const EN: Record<string, string> = {
|
||||
// ── 其余 ─────────────────────────────────────────────────────────
|
||||
"没有打开的会话": "No session open",
|
||||
"没有匹配的项": "Nothing matches",
|
||||
"搜索模型或服务商…": "Search models or providers…",
|
||||
"筛选": "Filter",
|
||||
"补全": "Completions",
|
||||
"交还给插件": "Hand back to the extension",
|
||||
@@ -1336,6 +1349,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",
|
||||
"已恢复 {n} 条未完成的指令。待发送已暂停,请先在输入框上方的队列里查看,再点“继续派发”": "Recovered {n} unfinished instruction(s). Sending is paused: review them in the queue above the input box, then press Release",
|
||||
"待发送已暂停,这条消息已排入队列,点“继续派发”后才会发送": "Sending is paused. This message is queued and goes out once you press Release",
|
||||
"引导没有生效:这一轮在处理它之前就结束了。如果仍然需要,请再发送一次:": "Guidance was not applied because the turn ended before it could be processed. Send it again if it is still needed:",
|
||||
"费用显示币种已设为 {mode}": "Cost display currency set to {mode}",
|
||||
|
||||
@@ -53,6 +53,7 @@ export const EN_COMPOSER: Record<string, string> = {
|
||||
"松手 → 插入到光标处": "Release to insert at the caret",
|
||||
"工作区外的文件引用不到 · 这里放不下": "A file outside the workspace cannot be referenced — this drop has nowhere to go",
|
||||
"图片": "image",
|
||||
"图片不可用": "Image unavailable",
|
||||
"文件": "file",
|
||||
"粘贴的文本": "pasted text",
|
||||
"{n} 行 · 展开到输入框": "{n} lines · put it back in the box",
|
||||
|
||||
@@ -22,6 +22,7 @@ export const EN_KERNEL: Record<string, string> = {
|
||||
"尚未取得所需的写入范围,这次等待已结束": "The wait ended before the requested write claim was granted",
|
||||
"会话 {holder}({session})持有写入范围:{paths}": "Session {holder} ({session}) holds write claim: {paths}",
|
||||
"所需写入范围:{paths}": "Requested write claim: {paths}",
|
||||
"参数不符合该工具的约定,本次调用没有执行;请按工具说明补全或更正参数后重试": "The arguments break this tool's contract, so the call did not run; complete or correct them per the tool description and retry",
|
||||
"另一个会话持有所需的写入范围。本次操作未执行;请结束当前轮次,待该范围释放后再重试": "Another session holds the requested write claim. This operation did not run; end the current turn and retry after the claim is released",
|
||||
"反馈通道今日已满,请明天再试": "Feedback is at capacity for today - try again tomorrow",
|
||||
"这份反馈的回复次数已到上限,或回复太频繁了,请稍后再试": "This report has reached its reply limit, or you replied too often - try again later",
|
||||
@@ -43,6 +44,8 @@ export const EN_KERNEL: Record<string, string> = {
|
||||
"该服务器未能启动,开关已恢复原状": "That server would not start; the switch has been put back",
|
||||
"该服务器未能启动,且开关未能恢复——重启后将保持刚才设置的状态":
|
||||
"That server would not start, and the switch could not be put back - after a restart it will hold what you just set",
|
||||
"这个服务要启动的内容在你启用后变了,请先查看命令并重新启用":
|
||||
"What this server launches changed since you enabled it; review the command and enable it again",
|
||||
"开关未能保存:其存储文件无法读取或写入": "The switch was not stored: its file could not be read or written",
|
||||
// 待送达:条目、队列、这份存档各自会拒
|
||||
"该条已不在待送达队列中": "That entry is no longer in the pending queue",
|
||||
@@ -334,6 +337,10 @@ export const EN_KERNEL: Record<string, string> = {
|
||||
"这个 Studio 不是安装版(从源码启动),没有可以查看或切换的版本":
|
||||
"This Studio is not an installed build (it runs from source), so it has no version to show or change",
|
||||
"版本固定未能保存:{detail}": "The version hold was not saved: {detail}",
|
||||
"这不是一个已发布的版本号": "That is not a published version number",
|
||||
"这个版本没有发布更新内容": "This version has no published notes",
|
||||
"暂时取不到更新内容,请检查网络后重试": "What's new cannot be reached right now. Check the network and try again",
|
||||
"这个版本的更新内容超出了允许的大小,已拒绝读取": "This version's notes are larger than allowed and were not read",
|
||||
"已有一个版本切换正在进行,请等待其完成后重试":
|
||||
"A version change is already running — wait for it to finish and try again",
|
||||
"本次版本切换未能启动:{detail}": "This version change did not start: {detail}",
|
||||
|
||||
@@ -213,6 +213,8 @@ export const EN_REMOTE: Record<string, string> = {
|
||||
"显示局域网二维码": "Show local network QR code",
|
||||
"互联网连接需要登录同一账号,内容端到端加密;局域网直连只在可信网络中开启。": "Internet access requires the same account and is end-to-end encrypted; only enable direct access on a trusted local network.",
|
||||
"登录 Reasonix 账号后,可生成在外网也能使用的连接二维码。": "Sign in to Reasonix to create a QR code that also works away from this network.",
|
||||
"中转服务暂时无法连接,请检查网络或代理设置后重试。": "The relay cannot be reached right now. Check your network or proxy settings and try again.",
|
||||
"中转服务拒绝了这台设备的连接,请稍后重试。": "The relay refused this device's connection. Try again later.",
|
||||
"{time} 配对 · 最近 {seen}": "Paired {time} · last seen {seen}",
|
||||
"断开这台手机?它需要重新扫码才能再连上。": "Disconnect this phone? It will need to scan a new code to connect again.",
|
||||
"断开": "Disconnect",
|
||||
|
||||
@@ -707,6 +707,7 @@ export const EN_SETTINGS: Record<string, string> = {
|
||||
"启用的模型": "Enabled models",
|
||||
"目录只用于发现,不是白名单。直接输入服务商给出的原始模型 ID 即可。": "The catalogue is for discovery, not a whitelist. You can enter any exact model ID supplied by the service.",
|
||||
"阈值需至少为 1,000,并小于模型上下文窗口。": "The threshold must be at least 1,000 and below the model context window.",
|
||||
"被容量保护({p}%)限制,实际 {n} tokens": "Capped by the capacity guard ({p}%): runs at {n} tokens",
|
||||
"达到这个用量时开始整理": "Start maintenance when usage reaches this value",
|
||||
"常用阈值": "Common thresholds",
|
||||
"不要依赖接口猜测,请按模型文档填写": "Use the model documentation instead of endpoint guesses",
|
||||
@@ -837,6 +838,8 @@ export const EN_SETTINGS: Record<string, string> = {
|
||||
"1 个提示词": "1 prompt",
|
||||
"1 套配色": "1 palette",
|
||||
"将启动": "Will start",
|
||||
"启用之后它要启动的内容变了,或是由旧版本启用的。确认下面的命令后重新启用。":
|
||||
"What it launches changed since you enabled it, or an earlier version enabled it. Review the command below and enable it again.",
|
||||
"将连接至": "Will connect to",
|
||||
"一个常驻进程": "a long-running process",
|
||||
"工具 {names}": "tools {names}",
|
||||
@@ -927,4 +930,10 @@ export const EN_SETTINGS: Record<string, string> = {
|
||||
"倍窗口": "× window",
|
||||
"请输入 1 到 {n} 之间的整数。": "Enter a whole number from 1 to {n}.",
|
||||
"无法读取无进展设置。": "Could not read the progress settings.",
|
||||
"图标栏": "Icon rail",
|
||||
"会话侧栏左边的一列图标,直达用量、工具、扩展、记忆、远程、反馈、账号和设置。窗口窄到手机宽度时不显示。": "A column of icons left of the session sidebar that opens usage, tools, extensions, memory, remote, feedback, account and settings. It is not drawn when the window is as narrow as a phone.",
|
||||
"始终显示": "Always",
|
||||
"仅侧栏收起时显示": "Only when the sidebar is collapsed",
|
||||
"不显示": "Never",
|
||||
"关闭或收起时,这些页面仍可从设置和侧栏打开": "With it off or hidden, these pages still open from settings and the sidebar",
|
||||
};
|
||||
@@ -0,0 +1,15 @@
|
||||
export const INBOX_RECOVERED = "inbox_recovered";
|
||||
|
||||
// The typed payload an inbox-recovered notice carries as its detail, read as the
|
||||
// variables its sentence is worded from. A detail that is not one reads as none,
|
||||
// so the notice keeps the kernel's English.
|
||||
export function inboxRecoveredVars(detail: string | undefined): { n: number } | undefined {
|
||||
if (!detail) return undefined;
|
||||
try {
|
||||
const p = JSON.parse(detail) as { count?: unknown };
|
||||
if (typeof p.count === "number" && Number.isInteger(p.count) && p.count > 0) return { n: p.count };
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
@@ -31,6 +31,7 @@ const SAID: Record<string, string> = {
|
||||
"shell.parser_unavailable": "主机命令解析器不可用;请在主机恢复后重试",
|
||||
"shell.parser_timeout": "主机命令解析超时或被取消;请重试",
|
||||
"shell.command_line_too_long": "命令超过主机长度限制;请拆分命令或从文件读取长文本",
|
||||
"tool.arguments_invalid": "参数不符合该工具的约定,本次调用没有执行;请按工具说明补全或更正参数后重试",
|
||||
"workspace.write_conflict": "另一个会话持有所需的写入范围。本次操作未执行;请结束当前轮次,待该范围释放后再重试",
|
||||
// ── 忙:不是出错,是「现在不行」 ─────────────────────────────────
|
||||
"plan.decision_stale": "该决定已不符合当前状态:计划在你回答前已发生变更",
|
||||
@@ -297,6 +298,7 @@ const SAID: Record<string, string> = {
|
||||
// ── 能力开关:名字、这台机器的存档、以及服务器自己 ───────────────
|
||||
"mcp.unavailable": "该服务器未能启动,开关已恢复原状",
|
||||
"mcp.switch_not_undone": "该服务器未能启动,且开关未能恢复——重启后将保持刚才设置的状态",
|
||||
"mcp.approval_owed": "这个服务要启动的内容在你启用后变了,请先查看命令并重新启用",
|
||||
"activation.unavailable": "开关未能保存:其存储文件无法读取或写入",
|
||||
|
||||
// ── 待送达:条目、队列、这份存档各自会拒 ─────────────────────────
|
||||
@@ -423,6 +425,10 @@ const SAID: Record<string, string> = {
|
||||
// ── 版本:这个内核背后有没有一个可更新的 Studio ─────────────────
|
||||
"studio.no_install": "这个 Studio 不是安装版(从源码启动),没有可以查看或切换的版本",
|
||||
"studio.pin_rejected": "版本固定未能保存:{detail}",
|
||||
"studio.notes_bad_version": "这不是一个已发布的版本号",
|
||||
"studio.notes_absent": "这个版本没有发布更新内容",
|
||||
"studio.notes_unreachable": "暂时取不到更新内容,请检查网络后重试",
|
||||
"studio.notes_too_large": "这个版本的更新内容超出了允许的大小,已拒绝读取",
|
||||
"update.install_running": "已有一个版本切换正在进行,请等待其完成后重试",
|
||||
"update.install_rejected": "本次版本切换未能启动:{detail}",
|
||||
"update.restart_busy": "有 {n} 项任务正在运行,重启会中断它们",
|
||||
|
||||
@@ -14,12 +14,15 @@ export const NOTICE_TEXT: Record<string, string> = {
|
||||
permission_save_failed: "授权没能保存,只在本次会话内有效",
|
||||
project_programs_awaiting_approval: "这个项目自带的钩子等程序要你批准后才会运行",
|
||||
project_program_changed: "这个项目的程序在批准后被改动过,本次没有运行,需要重新批准",
|
||||
project_mcp_awaiting_approval: "这个项目声明的一个 MCP 服务要你批准后才会运行;详情里是它要启动的命令",
|
||||
project_mcp_changed: "一个已启用的项目 MCP 服务要启动的内容变了,本次没有运行,需要查看命令后重新启用",
|
||||
hook_unevaluable: "有一个钩子无法被评估(匹配串无效、无法启动或载荷无法序列化),为安全起见已拦截这次操作;到钩子设置里修复或移除该钩子",
|
||||
suspected_injection: "一条外部内容看起来在向智能体下指令,已提醒它只当资料看待",
|
||||
await_user: "等待你的输入",
|
||||
default_model_unavailable: "配置里保存的默认模型已不在已配置的供应商中,本次改用第一个可用的模型;在设置里重新选择默认模型即可替换,配置文件未被改动",
|
||||
approval_mode_unrecognized: "配置里的默认审批档位本版本不认识,已按每次询问处理;在界面里选一个档位即可替换",
|
||||
memory_migration_backup: "旧版记忆文件没能先备份,所以没有迁移,保持原样;详情里有具体原因",
|
||||
inbox_recovered: "已恢复 {n} 条未完成的指令。待发送已暂停,请先在输入框上方的队列里查看,再点“继续派发”",
|
||||
queue_paused_hold: "待发送已暂停,这条消息已排入队列,点“继续派发”后才会发送",
|
||||
unapplied_steer: "引导没有生效:这一轮在处理它之前就结束了。如果仍然需要,请再发送一次:",
|
||||
display_currency: "费用显示币种已设为 {mode}",
|
||||
|
||||
@@ -9,6 +9,7 @@ import "./styles/studio.css";
|
||||
import "./styles/feedback.css";
|
||||
import "./styles/community.css";
|
||||
import "./styles/chart.css";
|
||||
import "./styles/versions.css";
|
||||
import { App } from "./ui/App";
|
||||
import { SseHub } from "./port/hub";
|
||||
import { HttpError } from "./port/http_error";
|
||||
|
||||
@@ -26,6 +26,8 @@ export interface McpEntry {
|
||||
alwaysLoad?: boolean;
|
||||
inSchema?: boolean;
|
||||
launch?: string;
|
||||
// Why a pending server waits for the user, as the kernel's code.
|
||||
pendingReason?: string;
|
||||
}
|
||||
|
||||
// always puts a server's tools in every request; deferred reaches them through
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { PlanAction } from "./session";
|
||||
import { HttpError } from "./port";
|
||||
import type { AccountState, AgentPort, ChangeDiff, Completion, CompletionItem, DeviceGrant, VersionHub, ApprovalMode, ApprovalVerdict, Checkpoint, RewindPlan, RewindResult, RewindScope, HistoryMessage, HostTodo, BrowserTab, ModelEntry, Preset, ProviderSetup, RoleAssignments, RoleOverride, SessionEntry, SessionStatus, WalletReading, MemoryCatalog, MemoryEdit, UsageReport, MemoryEntry, WorkspaceInfo, WorkspaceChanges, Attachment, DroppedRef, Queue, QueueItem, Queued, ChipCall, NotifyPrefs, TrayPrefs, UsageQuery } from "./port";
|
||||
import type { AccountState, AgentPort, ChangeDiff, Completion, CompletionItem, DeviceGrant, VersionHub, VersionNotes, ApprovalMode, ApprovalVerdict, Checkpoint, RewindPlan, RewindResult, RewindScope, HistoryMessage, HostTodo, BrowserTab, ModelEntry, Preset, ProviderSetup, RoleAssignments, RoleOverride, SessionEntry, SessionStatus, WalletReading, MemoryCatalog, MemoryEdit, UsageReport, MemoryEntry, WorkspaceInfo, WorkspaceChanges, Attachment, DroppedRef, Queue, QueueItem, Queued, ChipCall, NotifyPrefs, TrayPrefs, UsageQuery } from "./port";
|
||||
import type { ExecutionGraphRead, TrajectoryRead, WireEvent } from "./wire";
|
||||
import { MockFeedback } from "./mock_feedback";
|
||||
import { SCRIPT, mockMsgIndex, mockTurnStart } from "./fixture";
|
||||
@@ -181,6 +181,9 @@ export class MockPort extends MockFeedback implements AgentPort {
|
||||
async markWelcomed(): Promise<void> { this.welcomed = true; }
|
||||
|
||||
async pinVersion(): Promise<void> {}
|
||||
async versionNotes(version: string): Promise<VersionNotes> {
|
||||
return { version, markdown: "", cached: false };
|
||||
}
|
||||
async acknowledgeLaunchHealth(): Promise<void> {} // booted from no update
|
||||
async goToVersion(): Promise<void> {
|
||||
throw new Error("演示模式不会真的安装版本");
|
||||
|
||||
@@ -241,7 +241,7 @@ export class MockHub implements HubPort {
|
||||
],
|
||||
devices: [],
|
||||
cloudDevices: [],
|
||||
cloudRemote: { online: false },
|
||||
cloudRemote: { online: false, reason: "signed_out" },
|
||||
};
|
||||
|
||||
shareStatus() {
|
||||
|
||||
@@ -22,7 +22,7 @@ import type { NetworkProbe, NetworkSettings } from "./network";
|
||||
import type { ApprovalDefault, ApprovalMode, WorkspaceTrust, ApprovalVerdict, BrowserTab, Checkpoint, HistoryMessage, HostTodo, JobEntry, Preset, RewindPlan, RewindResult, RewindScope, SessionEntry, SessionStatus, WalletLine, WalletReading, PlanAction } from "./session";
|
||||
import type { ContextBreakdown, ShellOption, ShellSettings } from "./shell";
|
||||
import type { SkillCatalog, SkillEntry } from "./skill";
|
||||
import type { UpdateProgress, VersionEntry, VersionHub } from "./version";
|
||||
import type { UpdateProgress, VersionEntry, VersionHub, VersionNotes } from "./version";
|
||||
import type { ChangeDiff, CommitFile, CommitProposal, CommitRequest, CommitResult, WorkspaceChange, WorkspaceChanges, WorkspaceEntry, WorkspaceFile, WorkspaceFiles, WorkspaceInfo } from "./workspace";
|
||||
|
||||
// The port is one contract; its subjects each keep their own file, the way the
|
||||
@@ -35,7 +35,7 @@ export type { AccountState, AccountUser, ApprovalDefault, ApprovalMode, Approval
|
||||
MemoryEntry, ModelEntry, ModelMode, ModelPrice, NetworkProbe, NetworkSettings, Preset, RewindPlan,
|
||||
RewindResult, RewindScope, RoleAssignments, RoleOverride, ScopeLayer, SessionEntry, SessionStatus,
|
||||
ShellOption, ShellSettings, SkillCatalog, SkillEntry, UpdateProgress, VersionEntry,
|
||||
VersionHub, WalletLine, WalletReading, ChangeDiff, CommitFile, CommitProposal, CommitRequest, CommitResult, WorkspaceChange, WorkspaceChanges, WorkspaceEntry, WorkspaceFile, WorkspaceFiles, WorkspaceInfo };
|
||||
VersionHub, VersionNotes, WalletLine, WalletReading, ChangeDiff, CommitFile, CommitProposal, CommitRequest, CommitResult, WorkspaceChange, WorkspaceChanges, WorkspaceEntry, WorkspaceFile, WorkspaceFiles, WorkspaceInfo };
|
||||
|
||||
import type { ExecutionGraphRead, TrajectoryRead, WireEvent } from "./wire";
|
||||
import type { PluginExport, PluginInstallRequest, PluginPackage, PluginPlan } from "./plugin";
|
||||
@@ -340,6 +340,8 @@ export interface AgentPort {
|
||||
removeProvider(name: string): Promise<void>;
|
||||
versions(): Promise<VersionHub>;
|
||||
pinVersion(version: string): Promise<void>;
|
||||
// A published release's notes. retry asks past the kernel's short memory of a failed fetch.
|
||||
versionNotes(version: string, retry?: boolean): Promise<VersionNotes>;
|
||||
// Installs a published version, forward or back — the same call either way,
|
||||
// because a rollback that took a second code path would be the less-tested
|
||||
// one. Resolves only on failure: a success ends with the process handing over
|
||||
|
||||
@@ -34,6 +34,7 @@ export interface CloudRemoteStatus {
|
||||
name?: string;
|
||||
online: boolean;
|
||||
error?: string;
|
||||
reason?: "signed_out" | "relay_unreachable" | "relay_refused";
|
||||
}
|
||||
|
||||
// A paired device's answer about itself. Mirrors serve.DeviceSelf.
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { PLAN_ACTIONS, type PlanAction } from "./session";
|
||||
import type { AccountState, AgentPort, Appearance, ChipCall, CompactionSettings, Completion, DeviceGrant, ProviderProbe, UpdateProgress, VersionHub, ApprovalMode, ApprovalVerdict, Checkpoint, RewindPlan, RewindResult, RewindScope, HistoryMessage, HostTodo, BrowserTab, ModelEntry, Preset, ProviderSetup, RoleAssignments, RoleOverride, SessionEntry, SessionStatus, WalletReading, HookDryRun, HookEntry, MemoryCatalog, MemoryEdit, MemoryEntry, UsageQuery, UsageReport, McpDraft, PluginExport, Queue, Queued, NotifyPrefs, TrayPrefs, WorkspaceInfo } from "./port";
|
||||
import type { AccountState, AgentPort, Appearance, ChipCall, CompactionSettings, Completion, DeviceGrant, ProviderProbe, UpdateProgress, VersionHub, VersionNotes, ApprovalMode, ApprovalVerdict, Checkpoint, RewindPlan, RewindResult, RewindScope, HistoryMessage, HostTodo, BrowserTab, ModelEntry, Preset, ProviderSetup, RoleAssignments, RoleOverride, SessionEntry, SessionStatus, WalletReading, HookDryRun, HookEntry, MemoryCatalog, MemoryEdit, MemoryEntry, UsageQuery, UsageReport, McpDraft, PluginExport, Queue, Queued, NotifyPrefs, TrayPrefs, WorkspaceInfo } from "./port";
|
||||
import { HttpError, type ChangeDiff, type CommitProposal, type CommitRequest, type CommitResult, type WorkspaceFile, type WorkspaceFiles, type WorkspaceChanges } from "./port";
|
||||
import { SseFeedback } from "./sse_feedback";
|
||||
import type { StoragePlan, StorageQuery, StorageState } from "./storage";
|
||||
@@ -241,6 +241,13 @@ export class SsePort extends SseFeedback implements AgentPort {
|
||||
);
|
||||
}
|
||||
|
||||
async versionNotes(version: string, retry = false): Promise<VersionNotes> {
|
||||
const path = `/studio/versions/${encodeURIComponent(version)}/notes${retry ? "?retry=1" : ""}`;
|
||||
const res = await fetch(path, { credentials: "same-origin" });
|
||||
if (!res.ok) await SsePort.fail(path, res);
|
||||
return (await res.json()) as VersionNotes;
|
||||
}
|
||||
|
||||
async pinVersion(version: string): Promise<void> {
|
||||
const res = await fetch("/studio/pin", {
|
||||
method: "POST",
|
||||
|
||||
@@ -3,10 +3,18 @@ export interface VersionEntry {
|
||||
version: string;
|
||||
tag: string;
|
||||
publishedAt: string;
|
||||
hasNotes: boolean;
|
||||
current: boolean;
|
||||
older: boolean;
|
||||
}
|
||||
|
||||
// One release's notes. cached says only where they came from.
|
||||
export interface VersionNotes {
|
||||
version: string;
|
||||
markdown: string;
|
||||
cached: boolean;
|
||||
}
|
||||
|
||||
// err rides alongside the data: an unreachable catalog must not hide which
|
||||
// version is running.
|
||||
export interface VersionHub {
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
import { useCallback, useLayoutEffect, useRef, useSyncExternalStore } from "react";
|
||||
|
||||
export type PaneView = "flow" | "analysis" | "browser";
|
||||
|
||||
interface Entry {
|
||||
owner: symbol;
|
||||
view: PaneView;
|
||||
}
|
||||
|
||||
// Which view each open pane is showing. A pane's own choice, not a preference:
|
||||
// it lives exactly as long as the pane instance that made it, so a pane
|
||||
// remounted for another conversation starts on the conversation again.
|
||||
const entries = new Map<string, Entry>();
|
||||
const listeners = new Set<() => void>();
|
||||
|
||||
function emit() {
|
||||
listeners.forEach((fn) => fn());
|
||||
}
|
||||
|
||||
function subscribe(fn: () => void) {
|
||||
listeners.add(fn);
|
||||
return () => void listeners.delete(fn);
|
||||
}
|
||||
|
||||
/** The view a pane is showing; "flow" for one that is not mounted. */
|
||||
export function paneView(id: string): PaneView {
|
||||
return entries.get(id)?.view ?? "flow";
|
||||
}
|
||||
|
||||
export function usePaneViewOf(id: string): PaneView {
|
||||
return useSyncExternalStore(subscribe, () => paneView(id), () => "flow");
|
||||
}
|
||||
|
||||
/** The pane's view and the one way to change it. The pane that mounts last
|
||||
* owns the id: when a key change mounts the new instance before the old one's
|
||||
* cleanup runs, the old cleanup finds it is no longer the owner and leaves the
|
||||
* entry alone. */
|
||||
export function usePaneView(id: string): [PaneView, (to: PaneView) => void] {
|
||||
const owner = useRef<symbol>(Symbol(id));
|
||||
const view = useSyncExternalStore<PaneView>(
|
||||
subscribe,
|
||||
() => {
|
||||
const entry = entries.get(id);
|
||||
return entry && entry.owner === owner.current ? entry.view : "flow";
|
||||
},
|
||||
() => "flow",
|
||||
);
|
||||
useLayoutEffect(() => {
|
||||
const me = owner.current;
|
||||
entries.set(id, { owner: me, view: "flow" });
|
||||
emit();
|
||||
return () => {
|
||||
if (entries.get(id)?.owner !== me) return;
|
||||
entries.delete(id);
|
||||
emit();
|
||||
};
|
||||
}, [id]);
|
||||
const show = useCallback(
|
||||
(to: PaneView) => {
|
||||
const entry = entries.get(id);
|
||||
if (!entry || entry.owner !== owner.current || entry.view === to) return;
|
||||
entries.set(id, { ...entry, view: to });
|
||||
emit();
|
||||
},
|
||||
[id],
|
||||
);
|
||||
return [view, show];
|
||||
}
|
||||
@@ -79,6 +79,75 @@ export function setHidesAmounts(on: boolean): void {
|
||||
amountListeners.forEach((fn) => fn());
|
||||
}
|
||||
|
||||
// "reduced" drops the layers that cost the GPU a frame each time the page
|
||||
// moves: frosted glass, the animated sky, the grain. Anything else is "full".
|
||||
const EFFECTS_KEY = "rx-effects";
|
||||
|
||||
export type EffectsMode = "full" | "reduced";
|
||||
|
||||
const effectsListeners = new Set<() => void>();
|
||||
|
||||
export function effectsMode(): EffectsMode {
|
||||
try {
|
||||
return localStorage.getItem(EFFECTS_KEY) === "reduced" ? "reduced" : "full";
|
||||
} catch {
|
||||
return "full";
|
||||
}
|
||||
}
|
||||
|
||||
export function onEffectsChange(fn: () => void): () => void {
|
||||
effectsListeners.add(fn);
|
||||
return () => {
|
||||
effectsListeners.delete(fn);
|
||||
};
|
||||
}
|
||||
|
||||
export function setEffectsMode(mode: EffectsMode): void {
|
||||
try {
|
||||
localStorage.setItem(EFFECTS_KEY, mode);
|
||||
} catch {
|
||||
/* a private window keeps the default, which is the same answer it gives */
|
||||
}
|
||||
effectsListeners.forEach((fn) => fn());
|
||||
}
|
||||
|
||||
// When the icon column beside the workspace rail is drawn: "on" always, "collapsed"
|
||||
// only while the workspace rail is closed, "off" never. A phone-width window
|
||||
// never draws it, whatever this says. "on" and "off" are the two values the
|
||||
// earlier on/off switch wrote, so a stored choice reads back as itself; anything
|
||||
// unreadable or unset is the default, "on".
|
||||
const NAV_RAIL_KEY = "rx-nav-rail";
|
||||
|
||||
export type NavRailMode = "on" | "collapsed" | "off";
|
||||
export const NAV_RAIL_MODES: readonly NavRailMode[] = ["on", "collapsed", "off"];
|
||||
|
||||
const navRailListeners = new Set<() => void>();
|
||||
|
||||
export function navRailMode(): NavRailMode {
|
||||
try {
|
||||
const raw = localStorage.getItem(NAV_RAIL_KEY);
|
||||
return raw === "collapsed" || raw === "off" ? raw : "on";
|
||||
} catch {
|
||||
return "on";
|
||||
}
|
||||
}
|
||||
|
||||
export function onNavRailChange(fn: () => void): () => void {
|
||||
navRailListeners.add(fn);
|
||||
return () => {
|
||||
navRailListeners.delete(fn);
|
||||
};
|
||||
}
|
||||
|
||||
export function setNavRailMode(mode: NavRailMode): void {
|
||||
try {
|
||||
localStorage.setItem(NAV_RAIL_KEY, mode);
|
||||
} catch {
|
||||
/* a private window keeps the default, which is the same answer it gives */
|
||||
}
|
||||
navRailListeners.forEach((fn) => fn());
|
||||
}
|
||||
|
||||
// How each foldable part of the transcript starts. "live" opens while the part
|
||||
// is still being written and folds once it is done; "failed" opens only a step
|
||||
// that failed; "changed" opens only what wrote a file. A block the reader opened
|
||||
|
||||
@@ -64,3 +64,20 @@ describe("the reserved prefix", () => {
|
||||
expect(go.match(/SubagentProgressPrefix\s*=\s*"([^"]+)"/)?.[1]).toBe(SUBAGENT_PROGRESS_PREFIX);
|
||||
});
|
||||
});
|
||||
|
||||
describe("a proxied sub-agent while its call is still open", () => {
|
||||
const proxied = { kind: "tool_dispatch", tool: { id: "c1", name: "use_capability", args: "{}" } } as SessionEvent;
|
||||
const marked = { kind: "tool_dispatch", tool: { id: "c1", name: "use_capability", args: "{}", refreshed: true, profile: prof } } as SessionEvent;
|
||||
const step = { kind: "tool_dispatch", tool: { id: "c1/s1", parentId: "c1", name: "read_file" } } as SessionEvent;
|
||||
|
||||
it("is no task before the kernel marks it, and a running one the moment it does", () => {
|
||||
expect(liveTasks(after([proxied, step]))).toBe(0);
|
||||
const s = after([proxied, marked, step]);
|
||||
expect(liveTasks(s)).toBe(1);
|
||||
expect(card(s).children).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("keeps the one card when the mark arrives", () => {
|
||||
expect(after([proxied, marked, step]).items.filter((i) => i.t === "tool")).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
@@ -48,7 +48,6 @@
|
||||
position: absolute; top: -32%; left: 54%; width: 54vw; aspect-ratio: 1;
|
||||
border-radius: 50%; filter: blur(84px);
|
||||
background: radial-gradient(circle, var(--ray, rgb(255 216 142 / .55)), transparent 68%);
|
||||
animation: sunpulse 28s ease-in-out infinite;
|
||||
}
|
||||
.sky .shafts {
|
||||
position: absolute; left: -20%; right: -20%; top: -30%; height: 150%;
|
||||
@@ -60,12 +59,6 @@
|
||||
transform: rotate(var(--r)); transform-origin: 50% 0%;
|
||||
-webkit-mask-image: linear-gradient(180deg, #000 0 30%, transparent 92%);
|
||||
mask-image: linear-gradient(180deg, #000 0 30%, transparent 92%);
|
||||
animation: breathe var(--dur) ease-in-out var(--del) infinite;
|
||||
}
|
||||
@keyframes sunpulse { 0%, 100% { opacity: .8 } 50% { opacity: 1 } }
|
||||
@keyframes breathe { 0%, 100% { opacity: .55 } 50% { opacity: 1 } }
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.sky .sun, .sky .shaft { animation: none }
|
||||
}
|
||||
|
||||
:root[data-sky] .app { background: transparent }
|
||||
@@ -79,14 +72,12 @@
|
||||
|
||||
:root[data-bg] .rail, :root[data-bg] .side,
|
||||
:root[data-bg] .nav, :root[data-bg] .chrome {
|
||||
background: color-mix(in srgb, var(--page) 20%, transparent);
|
||||
-webkit-backdrop-filter: blur(8px) saturate(1.35); backdrop-filter: blur(8px) saturate(1.35);
|
||||
background: color-mix(in srgb, var(--page) 62%, transparent);
|
||||
text-shadow: 0 1px 2px color-mix(in srgb, var(--page) 88%, transparent),
|
||||
0 0 6px color-mix(in srgb, var(--page) 70%, transparent);
|
||||
}
|
||||
:root[data-bg] .tabs {
|
||||
background: color-mix(in srgb, var(--surface) 22%, transparent);
|
||||
-webkit-backdrop-filter: blur(8px) saturate(1.35); backdrop-filter: blur(8px) saturate(1.35);
|
||||
background: color-mix(in srgb, var(--surface) 62%, transparent);
|
||||
text-shadow: 0 1px 2px color-mix(in srgb, var(--page) 88%, transparent);
|
||||
}
|
||||
|
||||
@@ -107,8 +98,10 @@
|
||||
|
||||
.app::after {
|
||||
content: ""; position: fixed; inset: 0; z-index: 9; pointer-events: none;
|
||||
background-image: var(--grain); opacity: var(--grain-a); mix-blend-mode: overlay;
|
||||
background-image: var(--grain); opacity: var(--grain-a);
|
||||
}
|
||||
:root:root:root[data-effects="reduced"] * { -webkit-backdrop-filter: none; backdrop-filter: none; text-shadow: none }
|
||||
:root[data-effects="reduced"] .app::after { display: none }
|
||||
.app {
|
||||
--chrome-h: 45px;
|
||||
--lights-w: 0px;
|
||||
@@ -160,7 +153,7 @@
|
||||
--chrome-h: 51px; --lights-w: 96px; --lights-h: var(--chrome-h);
|
||||
}
|
||||
[data-titlebar="app"][data-platform="darwin"] .app[data-rail="off"] .chrome {
|
||||
padding-left: var(--lights-w);
|
||||
padding-left: calc(var(--lights-w) - var(--nav-w));
|
||||
}
|
||||
[data-titlebar="app"][data-platform="darwin"] .app[data-rail="on"] .studio-rail-head {
|
||||
padding-top: var(--lights-h); -webkit-app-region: drag;
|
||||
@@ -535,8 +528,8 @@
|
||||
.call[data-pending] > .c { opacity: .7 }
|
||||
|
||||
.app {
|
||||
--nav-w: 52px; --rail-w: var(--rail-open); --side-w: 0px; --dock-w: var(--dock-open);
|
||||
transition: --rail-w var(--t-panel) cubic-bezier(.32, .72, 0, 1), --side-w var(--t-panel) cubic-bezier(.32, .72, 0, 1), --dock-w var(--t-panel) cubic-bezier(.32, .72, 0, 1);
|
||||
--nav-w: 0px; --rail-w: var(--rail-open); --side-w: 0px; --dock-w: var(--dock-open);
|
||||
transition: --nav-w var(--t-panel) cubic-bezier(.32, .72, 0, 1), --rail-w var(--t-panel) cubic-bezier(.32, .72, 0, 1), --side-w var(--t-panel) cubic-bezier(.32, .72, 0, 1), --dock-w var(--t-panel) cubic-bezier(.32, .72, 0, 1);
|
||||
}
|
||||
.app[data-rail="off"] { --rail-w: 0px }
|
||||
.app[data-browser="off"] { --dock-w: 0px }
|
||||
|
||||
@@ -145,7 +145,8 @@
|
||||
}
|
||||
|
||||
body { background: var(--page); }
|
||||
.app { --nav-w: 48px; --rail-open: 238px; background: var(--page); }
|
||||
.app { --rail-open: 238px; background: var(--page); }
|
||||
.app[data-nav="on"] { --nav-w: 48px; }
|
||||
.app::after { opacity: .012; }
|
||||
|
||||
/* Window bar */
|
||||
@@ -193,22 +194,17 @@ body { background: var(--page); }
|
||||
.chrome .thbtn:hover { background: var(--overlay); }
|
||||
|
||||
/* Primary navigation and workspace rail */
|
||||
.nav {
|
||||
padding: 8px 5px 10px;
|
||||
gap: 4px;
|
||||
background: color-mix(in srgb, var(--page) 92%, var(--surface));
|
||||
border-right-color: var(--hair);
|
||||
}
|
||||
.nav { position: fixed; z-index: 12; inset: 0 auto 0 0; width: var(--nav-w); overflow: hidden; padding: calc(var(--chrome-h) + 8px) 0 10px; gap: 4px; background: color-mix(in srgb, var(--page) 92%, var(--surface)); border-right-color: var(--hair); }
|
||||
.nav > * { flex: none; }
|
||||
.app:not([data-nav="on"]) .nav { border-right-width: 0; }
|
||||
[data-titlebar="app"] .nav { -webkit-app-region: drag; }
|
||||
[data-titlebar="app"] .nav button { -webkit-app-region: no-drag; }
|
||||
.navbtn .fbk-badge { position: absolute; top: 3px; right: 2px; min-width: 14px; height: 14px; padding: 0 4px; display: inline-grid; place-items: center; border-radius: var(--r-pill); background: var(--accent); color: var(--accent-fg); font: var(--w-med) 9px/1 var(--ui); }
|
||||
.navgrp { gap: 3px; }
|
||||
.navbtn { width: 36px; height: 36px; border-radius: 8px; }
|
||||
.navbtn:hover { background: var(--overlay); box-shadow: none; }
|
||||
.navbtn[aria-current] { color: var(--text); background: var(--overlay); }
|
||||
.navbtn[aria-current]::before {
|
||||
left: -5px;
|
||||
top: 10px;
|
||||
bottom: 10px;
|
||||
background: var(--text);
|
||||
}
|
||||
.navbtn[aria-current]::before { left: -5px; top: 10px; bottom: 10px; background: var(--text); }
|
||||
.navhint { left: calc(100% + 8px); }
|
||||
.rail {
|
||||
background: color-mix(in srgb, var(--page) 94%, var(--surface));
|
||||
@@ -461,14 +457,12 @@ body { background: var(--page) !important; }
|
||||
:root[data-sky] .app { background: transparent !important; }
|
||||
|
||||
.app {
|
||||
--nav-w: 0px !important;
|
||||
--side-open: 308px !important;
|
||||
--chrome-h: 54px;
|
||||
background: var(--page) !important;
|
||||
}
|
||||
.app::after { opacity: .018; }
|
||||
.cols { grid-template-columns: 0 var(--rail-w) minmax(0, 1fr) var(--side-w); }
|
||||
.nav { display: none !important; }
|
||||
.cols { grid-template-columns: var(--nav-w) var(--rail-w) minmax(0, 1fr) var(--side-w); }
|
||||
.main { grid-column: 3; }
|
||||
.side { grid-column: 4; }
|
||||
|
||||
@@ -476,7 +470,7 @@ body { background: var(--page) !important; }
|
||||
.rail {
|
||||
position: fixed;
|
||||
z-index: 12;
|
||||
inset: 0 auto 0 0;
|
||||
inset: 0 auto 0 var(--nav-w);
|
||||
width: var(--rail-w);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -489,7 +483,7 @@ body { background: var(--page) !important; }
|
||||
.railscroll { min-height: 0; }
|
||||
.chrome {
|
||||
width: auto;
|
||||
margin-left: var(--rail-w);
|
||||
margin-left: calc(var(--nav-w) + var(--rail-w));
|
||||
padding: 0 18px 0 20px;
|
||||
gap: 11px;
|
||||
background: var(--surface) !important;
|
||||
@@ -861,10 +855,7 @@ body { background: var(--page) !important; }
|
||||
.acts-pop > button > small { flex: none; color: var(--faint); font: 500 9px/1 var(--mono); }
|
||||
.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 0;
|
||||
background: transparent; border: 0; box-shadow: none;
|
||||
}
|
||||
.flow .call[data-k="me"] { 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; }
|
||||
@@ -893,6 +884,9 @@ body { background: var(--page) !important; }
|
||||
.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 .user-images { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: 8px; margin-bottom: 8px; }
|
||||
.flow .user-image { display: grid; grid-template: minmax(0, 1fr) / minmax(0, 1fr); place-items: center; width: 240px; max-width: 100%; height: 160px; border: 1px solid var(--border); border-radius: 8px; background: var(--surface); color: var(--muted); }
|
||||
.flow .user-image img { width: 100%; height: 100%; object-fit: contain; border-radius: inherit; }
|
||||
.flow .call[data-k="me"] .out .txt {
|
||||
margin-inline-start: auto; padding: 12px 15px; border: 1px solid var(--border); border-radius: 12px;
|
||||
background: var(--raised); box-shadow: inset 0 1px rgb(255 255 255 / .024);
|
||||
@@ -1504,7 +1498,7 @@ body { background: var(--page) !important; }
|
||||
|
||||
@media (max-width: 680px) {
|
||||
.app { --rail-open: min(84vw, 300px) !important; }
|
||||
.chrome { margin-left: 0; }
|
||||
.chrome { margin-left: var(--nav-w); }
|
||||
.rail { box-shadow: 18px 0 50px -28px #000; }
|
||||
.app[data-rail="off"] .rail { pointer-events: none; }
|
||||
.flow,
|
||||
@@ -1696,7 +1690,6 @@ body { background: var(--page) !important; }
|
||||
.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; }
|
||||
@@ -1855,9 +1848,7 @@ body { background: var(--page) !important; }
|
||||
background-color: transparent !important;
|
||||
}
|
||||
:root[data-bg] :is(.rail, .chrome) {
|
||||
background: color-mix(in srgb, var(--page) 72%, transparent) !important;
|
||||
-webkit-backdrop-filter: blur(12px) saturate(1.15);
|
||||
backdrop-filter: blur(12px) saturate(1.15);
|
||||
background: color-mix(in srgb, var(--page) 80%, transparent) !important;
|
||||
}
|
||||
|
||||
.hero .rmark {
|
||||
@@ -2465,6 +2456,7 @@ body { font-size: 14px; line-height: 1.75; }
|
||||
}
|
||||
.studio-model-menu,
|
||||
.studio-effort-menu { width: 310px; max-width: calc(100vw - 24px); }
|
||||
.studio-model-menu { width: 340px; max-height: min(calc((100vh - 160px) / var(--zoom, 1)), 640px); }
|
||||
.studio-effort-menu {
|
||||
width: 350px;
|
||||
padding: 8px;
|
||||
@@ -2481,15 +2473,18 @@ body { font-size: 14px; line-height: 1.75; }
|
||||
}
|
||||
.studio-picker-title b { color: var(--text); font: 600 11px/1 var(--ui); }
|
||||
.studio-picker-title small { color: var(--ghost); font: 500 9px/1 var(--ui); }
|
||||
.studio-model-menu .mfind { top: -8px; margin: -3px -8px 5px; width: calc(100% + 16px); padding-inline: 17px; border-radius: 0; }
|
||||
.studio-model-menu .mfind { top: -8px; margin: -3px -8px 5px; width: calc(100% + 16px); padding-inline: 17px; border-radius: 0; background: inherit; }
|
||||
.portmenu .mi { min-height: 48px; align-items: center; gap: 9px; padding: 7px 9px; border-radius: 7px; }
|
||||
.studio-model-menu .mi { min-height: 34px; padding-block: 4px; }
|
||||
.studio-model-menu .mi.head { position: sticky; top: 27px; z-index: 1; background: inherit; }
|
||||
.mi-mono { flex: none; display: grid; place-items: center; width: 18px; height: 18px; border-radius: 5px; background: color-mix(in srgb, var(--tone) 16%, transparent); color: color-mix(in srgb, var(--tone) 65%, var(--text)); font: 600 10px/1 var(--ui); }
|
||||
.portmenu .mi .lb { color: var(--text); font: 600 11px/1.35 var(--ui); }
|
||||
.portmenu .mi .ds { margin-top: 3px; color: var(--ghost); font: 500 9px/1.45 var(--ui); }
|
||||
.portmenu .mi .dot { display: none; }
|
||||
.portmenu .mi[data-on] { background: var(--hair); }
|
||||
.portmenu .mi[data-on]::before { display: none; }
|
||||
.portmenu .mi[data-on]::after { content: "✓"; align-self: center; margin-left: 8px; color: var(--text); font: 600 11px/1 var(--ui); }
|
||||
.portmenu .mi.head { min-height: 34px; align-items: center; padding-bottom: 8px; margin-bottom: 3px; border-bottom: 1px solid var(--border); border-radius: 0; }
|
||||
.portmenu .mi.head { gap: 8px; min-height: 34px; align-items: center; padding-bottom: 8px; margin-bottom: 3px; border-bottom: 1px solid var(--border); border-radius: 0; }
|
||||
.portmenu .mi.head .lb { color: var(--text); font-size: 11px; }
|
||||
.portmenu .mi.head .rt { color: var(--ghost); font: 500 9px/1 var(--ui); }
|
||||
.portmenu .mi.head:last-child { min-height: 32px; margin: 4px 0 0; padding-top: 9px; padding-bottom: 4px; border-top: 1px solid var(--border); border-bottom: 0; }
|
||||
@@ -2510,9 +2505,9 @@ body { font-size: 14px; line-height: 1.75; }
|
||||
padding: 8px 9px;
|
||||
}
|
||||
.studio-effort-menu .mi:not(.head) .tx { min-width: 0; }
|
||||
.studio-effort-menu .studio-item-line { display: flex; align-items: baseline; gap: 6px; min-width: 0; }
|
||||
.studio-effort-menu .studio-item-meta { color: var(--ghost); font: 500 9px/1 var(--mono); }
|
||||
.studio-effort-menu .studio-item-badge {
|
||||
:is(.studio-effort-menu, .studio-model-menu) .studio-item-line { display: flex; align-items: baseline; gap: 6px; min-width: 0; }
|
||||
:is(.studio-effort-menu, .studio-model-menu) .studio-item-meta { color: var(--ghost); font: 500 9px/1 var(--mono); }
|
||||
:is(.studio-effort-menu, .studio-model-menu) .studio-item-badge {
|
||||
padding: 2px 5px;
|
||||
border-radius: 4px;
|
||||
background: var(--border);
|
||||
@@ -3306,8 +3301,6 @@ body { font-size: 14px; line-height: 1.75; }
|
||||
.pane > .tabs {
|
||||
background: color-mix(in srgb, var(--surface) 94%, transparent);
|
||||
border-bottom-color: var(--border);
|
||||
-webkit-backdrop-filter: blur(14px) saturate(1.02);
|
||||
backdrop-filter: blur(14px) saturate(1.02);
|
||||
}
|
||||
.pane > .tabs .tab { color: var(--muted); }
|
||||
.pane > .tabs .tab:hover { color: var(--text); background: var(--overlay); }
|
||||
@@ -3333,9 +3326,7 @@ body { font-size: 14px; line-height: 1.75; }
|
||||
.studio-user-foot .acctrow:hover,
|
||||
.studio-settings:hover { background: var(--accent-wash); color: var(--text); }
|
||||
:root[data-bg] .railfoot {
|
||||
background: color-mix(in srgb, var(--surface) 72%, transparent) !important;
|
||||
-webkit-backdrop-filter: blur(12px) saturate(1.12);
|
||||
backdrop-filter: blur(12px) saturate(1.12);
|
||||
background: color-mix(in srgb, var(--surface) 80%, transparent) !important;
|
||||
}
|
||||
:root[data-theme="light"][data-bg] :is(.rail, .chrome) {
|
||||
background: color-mix(in srgb, var(--page) 86%, transparent) !important;
|
||||
|
||||
@@ -23,6 +23,7 @@
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
@property --nav-w { syntax: "<length>"; inherits: true; initial-value: 0px }
|
||||
@property --rail-w { syntax: "<length>"; inherits: true; initial-value: 264px }
|
||||
@property --side-w { syntax: "<length>"; inherits: true; initial-value: 316px }
|
||||
@property --dock-w { syntax: "<length>"; inherits: true; initial-value: 560px }
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
.vrow .ds { grid-row: 1 }
|
||||
.vrow .vn { grid-column: 2; grid-row: 1; justify-self: end; text-decoration: none }
|
||||
.vnotes { grid-column: 1 / -1; margin: 0 0 8px; padding: 10px 12px; border-left: 2px solid var(--hair); font-size: var(--fs-ui); overflow-wrap: anywhere }
|
||||
.vnotes .md-noimg { color: var(--muted) }
|
||||
@container versions (max-width: 600px) {
|
||||
.vrow .vn { grid-column: 1 / -1; grid-row: auto; justify-self: start; margin-top: 4px }
|
||||
@media (hover: hover) {
|
||||
.vrow[data-open] .sa:not([data-keep]) { position: static; translate: none; grid-column: 1 / -1; grid-row: auto; margin-top: 4px; max-width: 100% }
|
||||
.vrow[data-open]:has(> .sa:not([data-keep])):is(:hover, :focus-within) :is(.ds, .sc) { visibility: visible }
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,8 @@ import type { HubPort, RuntimeView, TreeWorkspace } from "../port/hub";
|
||||
import { Chrome } from "./Chrome";
|
||||
import { useLaunchHealth } from "./launchhealth";
|
||||
import { Nav } from "./Nav";
|
||||
import { useNavRail } from "./navrail";
|
||||
import { feedbackEntryTab } from "./feedbackentry";
|
||||
import { useLinkRouting } from "./links";
|
||||
import { Pane, type PaneReport } from "./Pane";
|
||||
import { clearDraftForSession } from "./drafts";
|
||||
@@ -163,7 +165,7 @@ export function App({ hub }: { hub: HubPort }) {
|
||||
hub
|
||||
.tree()
|
||||
.then(setTree)
|
||||
.catch(() => setTree([]))
|
||||
.catch(() => {})
|
||||
.finally(() => setTreeRead(true)),
|
||||
[hub],
|
||||
);
|
||||
@@ -310,7 +312,7 @@ export function App({ hub }: { hub: HubPort }) {
|
||||
}, [setup, welcomed]);
|
||||
|
||||
const running = report.run === "running";
|
||||
const { theme, setTheme, scheme, contrast, setContrast, weight, setWeight, look, onLook, pack, reloadThemes } =
|
||||
const { theme, setTheme, scheme, contrast, setContrast, weight, setWeight, effects, look, onLook, pack, reloadThemes } =
|
||||
usePaint(hub, runtimes, running, fail);
|
||||
// A pane with no session file has never been written to — the empty one every
|
||||
// window opens with. Opening a conversation takes it over instead of parking
|
||||
@@ -362,6 +364,7 @@ export function App({ hub }: { hub: HubPort }) {
|
||||
const needsProject = treeRead && !claimed && tree.every((ws) => !ws.remembered);
|
||||
|
||||
useFoldAway("rail", setRail);
|
||||
const navRail = useNavRail(rail);
|
||||
useDrawerCloses(setRail, active, settings);
|
||||
useEffect(() => onRoomWidth((width) => setDockLimit(dockMax(width))), []);
|
||||
const shownDockW = Math.min(dockW, dockLimit);
|
||||
@@ -550,6 +553,7 @@ export function App({ hub }: { hub: HubPort }) {
|
||||
className="app"
|
||||
data-run={report.run}
|
||||
data-rail={rail ? "on" : "off"}
|
||||
data-nav={navRail.shown ? "on" : "off"}
|
||||
data-browser={browser ? "on" : "off"}
|
||||
data-plan={report.status?.plan ? "on" : "off"}
|
||||
data-apv={report.status?.toolApprovalMode ?? "ask"}
|
||||
@@ -578,14 +582,19 @@ export function App({ hub }: { hub: HubPort }) {
|
||||
hub={hub} onError={fail}
|
||||
/>
|
||||
|
||||
{pack?.sky && <Sky />}
|
||||
{pack?.sky && effects !== "reduced" && <Sky />}
|
||||
|
||||
<div className="cols">
|
||||
<Nav
|
||||
at={settings === false ? null : settings === true ? "" : settings}
|
||||
onGo={showPrefs}
|
||||
onHome={hidePrefs}
|
||||
/>
|
||||
{navRail.drawn && (
|
||||
<Nav
|
||||
at={settings === false ? null : settings === true ? "" : settings}
|
||||
onGo={showPrefs}
|
||||
onHome={hidePrefs}
|
||||
onFeedback={() => setFeedback(feedbackEntryTab(feedbackUnread))}
|
||||
feedbackUnread={feedbackUnread}
|
||||
shown={navRail.shown}
|
||||
/>
|
||||
)}
|
||||
<Sidebar
|
||||
hub={hub}
|
||||
collapsed={!rail}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { ApplyNote } from "./Group";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState, type CSSProperties } from "react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore, type CSSProperties } from "react";
|
||||
import { effectsMode, onEffectsChange, setEffectsMode, type EffectsMode } from "../state/prefs";
|
||||
import type { AgentPort, Appearance as Look, ThemePack } from "../port/port";
|
||||
import { MONO_FAMILIES, UI_FAMILIES, installed, readSizeOf, readSteps } from "./look";
|
||||
import { STORAGE as LANG_KEY, t } from "../i18n";
|
||||
@@ -8,6 +9,7 @@ import { reason } from "../i18n/kernel";
|
||||
import { Notifications } from "./Notifications";
|
||||
import { Folding } from "./Folding";
|
||||
import { WindowSection } from "./WindowSection";
|
||||
import { NavRailSection } from "./NavRailSection";
|
||||
import { ThemeImport } from "./ThemeImport";
|
||||
import { useThemeInventory } from "./useThemeInventory";
|
||||
|
||||
@@ -75,6 +77,11 @@ const MEASURES: [string, string, string][] = [
|
||||
["full", "铺满窗口", "正文跟随窗口宽度,宽屏上不留两侧空白"],
|
||||
];
|
||||
|
||||
const EFFECTS: [EffectsMode, string, string][] = [
|
||||
["full", "完整", "背景模糊、动态天空与颗粒质感全部开启"],
|
||||
["reduced", "节能", "关闭背景模糊、动态天空与颗粒质感,集成显卡或老旧电脑上更省电"],
|
||||
];
|
||||
|
||||
const CONTRASTS: [string, string, string][] = [
|
||||
["", "跟随系统", "系统已开启「增强对比度」时使用最强档"],
|
||||
["soft", "柔和", "正文不易刺眼,长时间阅读更省力"],
|
||||
@@ -122,6 +129,7 @@ function useCrop(url: string | undefined, box: HTMLElement | null) {
|
||||
|
||||
export function Appearance({ port, theme, onTheme, contrast, onContrast, weight, onWeight, reloadThemes, look, onLook }: Props) {
|
||||
const { packs, unread, load } = useThemeInventory(port);
|
||||
const effects = useSyncExternalStore(onEffectsChange, effectsMode);
|
||||
|
||||
// Activating repaints through App's own theme effect, so this only refreshes
|
||||
// the list and asks App to re-read which pack is active.
|
||||
@@ -213,6 +221,7 @@ export function Appearance({ port, theme, onTheme, contrast, onContrast, weight,
|
||||
look.fontUi ? t("字体") : "",
|
||||
weight ? t("文字粗细") : "",
|
||||
contrast ? t("文字对比度") : "",
|
||||
effects === "reduced" ? t("视觉效果") : "",
|
||||
].filter(Boolean);
|
||||
|
||||
return (
|
||||
@@ -523,6 +532,7 @@ export function Appearance({ port, theme, onTheme, contrast, onContrast, weight,
|
||||
|
||||
|
||||
<WindowSection port={port} />
|
||||
<NavRailSection />
|
||||
<Folding />
|
||||
<Notifications port={port} />
|
||||
|
||||
@@ -534,7 +544,7 @@ export function Appearance({ port, theme, onTheme, contrast, onContrast, weight,
|
||||
<summary>
|
||||
<span className="tx">
|
||||
<span className="lb">{t("高级外观")}</span>
|
||||
<span className="ds">{changed.length ? t("已修改:{list}", { list: changed.join(" · ") }) : t("字体、文字粗细、文字对比度")}</span>
|
||||
<span className="ds">{changed.length ? t("已修改:{list}", { list: changed.join(" · ") }) : t("字体、文字粗细、文字对比度、视觉效果")}</span>
|
||||
</span>
|
||||
</summary>
|
||||
<section className="grp" id="set-font" data-setting="font">
|
||||
@@ -572,6 +582,23 @@ export function Appearance({ port, theme, onTheme, contrast, onContrast, weight,
|
||||
<ApplyNote id="weight" />
|
||||
</section>
|
||||
|
||||
<section className="grp" id="set-effects" data-setting="effects">
|
||||
<div className="grp-hd">
|
||||
<h3>{t("视觉效果")}</h3>
|
||||
</div>
|
||||
<p className="hint">{t("窗口持续占用显卡时,改为「节能」:界面不变,只去掉磨砂玻璃、动态天空和颗粒这几层。")}</p>
|
||||
<div className="grp-items">
|
||||
<div className="seg" data-text role="group" aria-label={t("视觉效果")}>
|
||||
{EFFECTS.map(([id, name, why]) => (
|
||||
<button key={id} data-action="appearance.effects" data-value={id} aria-pressed={effects === id} title={t(why)} onClick={() => setEffectsMode(id)}>
|
||||
{t(name)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<ApplyNote id="effects" />
|
||||
</section>
|
||||
|
||||
<section className="grp" id="set-contrast" data-setting="contrast">
|
||||
<div className="grp-hd">
|
||||
<h3>{t("文字对比度")}</h3>
|
||||
|
||||
@@ -74,4 +74,20 @@ describe("capacity-based context maintenance", () => {
|
||||
expect((screen.getByRole("textbox") as HTMLInputElement).value).toBe("90000");
|
||||
expect(screen.getByText(/经济维护阈值会先到/)).toBeTruthy();
|
||||
});
|
||||
|
||||
it("says on the threshold field itself when the capacity guard caps it", async () => {
|
||||
await open(port({ soft_limit_tokens: 850000, ratio: 0.5, trigger: 500000 }));
|
||||
expect((screen.getByRole("textbox") as HTMLInputElement).value).toBe("850000");
|
||||
const row = screen.getByRole("textbox").closest(".threshold-row")!;
|
||||
expect(row.textContent).toContain("500k");
|
||||
expect(row.textContent).toContain("50%");
|
||||
expect(row.textContent).not.toContain("达到这个用量时开始整理");
|
||||
});
|
||||
|
||||
it("keeps the plain hint when the threshold is the one that applies", async () => {
|
||||
await open(port({ soft_limit_tokens: 90000, trigger: 90000 }));
|
||||
const row = screen.getByRole("textbox").closest(".threshold-row")!;
|
||||
expect(row.textContent).toContain("达到这个用量时开始整理");
|
||||
expect(row.textContent).not.toContain("容量保护");
|
||||
});
|
||||
});
|
||||
@@ -91,6 +91,7 @@ export function Compaction({ port, onChanged }: { port: AgentPort; onChanged: ()
|
||||
const capacity = Math.round(win * box.ratio);
|
||||
const off = win === 0;
|
||||
const economicWins = !off && mode !== "capacity" && box.trigger < capacity;
|
||||
const capped = !off && box.soft_limit_tokens > 0 && box.trigger < box.soft_limit_tokens;
|
||||
const pct = off || !used ? 0 : Math.min((used / box.trigger) * 100, 100);
|
||||
|
||||
return (
|
||||
@@ -158,7 +159,11 @@ export function Compaction({ port, onChanged }: { port: AgentPort; onChanged: ()
|
||||
<div className="lrow threshold-row">
|
||||
<span className="tx">
|
||||
<label className="lb" htmlFor={field}>{t("阈值")}</label>
|
||||
<span className="ds">{t("达到这个用量时开始整理")}</span>
|
||||
<span className="ds">
|
||||
{capped
|
||||
? t("被容量保护({p}%)限制,实际 {n} tokens", { p: String(Math.round(box.ratio * 100)), n: tokens(box.trigger) })
|
||||
: t("达到这个用量时开始整理")}
|
||||
</span>
|
||||
</span>
|
||||
<div className="threshold-control">
|
||||
<span className="unit-field">
|
||||
|
||||
@@ -715,6 +715,8 @@ export function Composer({ port, status, running, quote, restore, focus, onSubmi
|
||||
current={status?.modelRef}
|
||||
items={modelMenu(models, providerOrder)}
|
||||
menuClassName="studio-model-menu"
|
||||
searchAlways
|
||||
searchPlaceholder={t("搜索模型或服务商…")}
|
||||
menuTitle={<><b>{t("选择模型")}</b><small>{t("用于后续任务")}</small></>}
|
||||
onOpen={loadModels}
|
||||
pending={busy["model"]}
|
||||
|
||||
@@ -20,6 +20,18 @@ afterEach(() => {
|
||||
|
||||
const png = (name = "a.png", size = 64) => new File([new Uint8Array(size)], name, { type: "image/png" });
|
||||
|
||||
function pauseImageReads() {
|
||||
const original = FileReader.prototype.readAsDataURL;
|
||||
const pending: (() => Promise<void>)[] = [];
|
||||
const spy = vi.spyOn(FileReader.prototype, "readAsDataURL").mockImplementation(function (this: FileReader, file: Blob) {
|
||||
pending.push(() => new Promise((resolve) => {
|
||||
this.addEventListener("loadend", () => resolve(), { once: true });
|
||||
original.call(this, file);
|
||||
}));
|
||||
});
|
||||
return { pending, restore: () => spy.mockRestore() };
|
||||
}
|
||||
|
||||
function setup(over: Partial<FeedbackEnv> = {}, tab: "send" | "mine" = "send") {
|
||||
const port = new MockPort() as unknown as AgentPort;
|
||||
const base = port.feedbackEnv.bind(port);
|
||||
@@ -91,6 +103,69 @@ describe("feedback form", () => {
|
||||
});
|
||||
|
||||
describe("screenshots", () => {
|
||||
it("does not attach an old pending image to a fresh report", async () => {
|
||||
const reads = pauseImageReads();
|
||||
try {
|
||||
const { port } = setup();
|
||||
const spy = vi.spyOn(port, "sendFeedback");
|
||||
await fill();
|
||||
await userEvent.upload(document.querySelector<HTMLInputElement>('input[type="file"]')!, png("previous.png"));
|
||||
expect(reads.pending).toHaveLength(1);
|
||||
await userEvent.click(send());
|
||||
await userEvent.click(await screen.findByRole("button", { name: "再写一条" }));
|
||||
await userEvent.type(body(), "另一件事");
|
||||
await act(reads.pending[0]!);
|
||||
await userEvent.click(send());
|
||||
await screen.findByText("已收到你的反馈");
|
||||
expect(spy.mock.calls[1]![0].images).toEqual([]);
|
||||
} finally {
|
||||
reads.restore();
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps a pending image in the same draft after a failed send so retry can include it", async () => {
|
||||
const reads = pauseImageReads();
|
||||
try {
|
||||
const { port } = setup();
|
||||
const spy = vi.spyOn(port, "sendFeedback").mockRejectedValueOnce(new Error("offline"));
|
||||
await fill();
|
||||
await userEvent.upload(document.querySelector<HTMLInputElement>('input[type="file"]')!, png("retry.png"));
|
||||
await userEvent.click(send());
|
||||
await screen.findByRole("button", { name: "重试发送" });
|
||||
await act(reads.pending[0]!);
|
||||
expect(screen.getByAltText("retry.png")).toBeTruthy();
|
||||
await userEvent.click(send());
|
||||
await screen.findByText("已收到你的反馈");
|
||||
expect(spy.mock.calls[1]![0].images.map((image) => image.name)).toEqual(["retry.png"]);
|
||||
} finally {
|
||||
reads.restore();
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps the new report's completed image when an old image finishes later", async () => {
|
||||
const reads = pauseImageReads();
|
||||
try {
|
||||
const { port } = setup();
|
||||
const spy = vi.spyOn(port, "sendFeedback");
|
||||
await fill();
|
||||
await userEvent.upload(document.querySelector<HTMLInputElement>('input[type="file"]')!, png("previous.png"));
|
||||
await userEvent.click(send());
|
||||
await userEvent.click(await screen.findByRole("button", { name: "再写一条" }));
|
||||
await userEvent.type(body(), "另一件事");
|
||||
await userEvent.upload(document.querySelector<HTMLInputElement>('input[type="file"]')!, png("current.png"));
|
||||
await act(reads.pending[1]!);
|
||||
expect(screen.getByAltText("current.png")).toBeTruthy();
|
||||
await act(reads.pending[0]!);
|
||||
expect(screen.queryByAltText("previous.png")).toBeNull();
|
||||
expect(screen.getByAltText("current.png")).toBeTruthy();
|
||||
await userEvent.click(send());
|
||||
await screen.findByText("已收到你的反馈");
|
||||
expect(spy.mock.calls[1]![0].images.map((image) => image.name)).toEqual(["current.png"]);
|
||||
} finally {
|
||||
reads.restore();
|
||||
}
|
||||
});
|
||||
|
||||
it("adds from the picker, previews, and removes", async () => {
|
||||
setup();
|
||||
await ready();
|
||||
@@ -383,6 +458,16 @@ describe("dialog", () => {
|
||||
opener.remove();
|
||||
});
|
||||
|
||||
it("moves focus into the body once the environment has loaded, even if a frame fires before the commit", async () => {
|
||||
vi.spyOn(window, "requestAnimationFrame").mockImplementation((cb) => {
|
||||
cb(0);
|
||||
return 0;
|
||||
});
|
||||
setup();
|
||||
await ready();
|
||||
await waitFor(() => expect(document.activeElement).toBe(body()));
|
||||
});
|
||||
|
||||
it("moves between the two tabs with the arrow keys", async () => {
|
||||
setup();
|
||||
await ready();
|
||||
|
||||
@@ -58,6 +58,7 @@ export function FeedbackForm({ port, onMine, onClose, onFile }: Props) {
|
||||
const field = useRef<HTMLTextAreaElement>(null);
|
||||
const submit = useRef<HTMLButtonElement>(null);
|
||||
const done = useRef<HTMLHeadingElement>(null);
|
||||
const shotEpoch = useRef(0);
|
||||
const heldShots = useRef(shots);
|
||||
heldShots.current = shots;
|
||||
|
||||
@@ -68,15 +69,16 @@ export function FeedbackForm({ port, onMine, onClose, onFile }: Props) {
|
||||
.then((e) => {
|
||||
setEnv(e);
|
||||
setName((prev) => prev || e.displayName);
|
||||
requestAnimationFrame(() => {
|
||||
if (document.activeElement?.closest('[role="tablist"]')) field.current?.focus();
|
||||
});
|
||||
})
|
||||
.catch((e) => setEnvFailure(feedbackFailure(e)));
|
||||
}, [port]);
|
||||
|
||||
useEffect(load, [load]);
|
||||
|
||||
useEffect(() => {
|
||||
if (env && document.activeElement?.closest('[role="tablist"]')) field.current?.focus();
|
||||
}, [env]);
|
||||
|
||||
useEffect(() => {
|
||||
if (phase !== "sent") holdDraft({ category, body, contact, shots });
|
||||
}, [category, body, contact, shots, phase]);
|
||||
@@ -105,7 +107,9 @@ export function FeedbackForm({ port, onMine, onClose, onFile }: Props) {
|
||||
const { taken, refused: no } = admit(heldShots.current.length, files, limits);
|
||||
setRefused(no);
|
||||
if (taken.length === 0) return;
|
||||
const epoch = shotEpoch.current;
|
||||
const read = await Promise.allSettled(taken.map(readShot));
|
||||
if (epoch !== shotEpoch.current) return;
|
||||
const ok = read.flatMap((r) => (r.status === "fulfilled" ? [r.value] : []));
|
||||
setShots((prev) => [...prev, ...ok].slice(0, limits.images));
|
||||
},
|
||||
@@ -156,6 +160,7 @@ export function FeedbackForm({ port, onMine, onClose, onFile }: Props) {
|
||||
locale: document.documentElement.lang,
|
||||
images: payload(shots),
|
||||
});
|
||||
shotEpoch.current++;
|
||||
dropDraft();
|
||||
setSent(receipt);
|
||||
setPhase("sent");
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useEscape } from "./dismiss";
|
||||
import { t } from "../i18n";
|
||||
import type { AgentPort, HookCatalog, HookDryRun, HookEntry } from "../port/port";
|
||||
@@ -71,6 +71,12 @@ interface Props {
|
||||
onChanged: () => void;
|
||||
}
|
||||
|
||||
interface Trial {
|
||||
rule: HookEntry;
|
||||
result?: HookDryRun;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export function Hooks({ port, onChanged }: Props) {
|
||||
const [cat, setCat] = useState<HookCatalog | null>(null);
|
||||
const [scope, setScope] = useState<"user" | "project">("user");
|
||||
@@ -78,7 +84,8 @@ export function Hooks({ port, onChanged }: Props) {
|
||||
useEscape(expert, () => setExpert(false));
|
||||
const [busy, setBusy] = useState("");
|
||||
const [error, setError] = useState("");
|
||||
const [tried, setTried] = useState<Record<string, HookDryRun>>({});
|
||||
const [tried, setTried] = useState<Record<string, Trial>>({});
|
||||
const mine = useMemo(() => cat?.hooks.filter((h) => h.scope === (scope === "user" ? "global" : "project")) ?? [], [cat, scope]);
|
||||
|
||||
const reload = () => {
|
||||
port
|
||||
@@ -93,7 +100,6 @@ export function Hooks({ port, onChanged }: Props) {
|
||||
|
||||
if (!cat) return <div className="empty">{t("无法读取 hooks 配置。")}</div>;
|
||||
|
||||
const mine = cat.hooks.filter((h) => h.scope === (scope === "user" ? "global" : "project"));
|
||||
const plugin = cat.hooks.filter((h) => h.scope === "plugin");
|
||||
const broken = cat.sources.filter((s) => s.status === "malformed" || s.status === "unreadable");
|
||||
|
||||
@@ -121,13 +127,16 @@ export function Hooks({ port, onChanged }: Props) {
|
||||
};
|
||||
|
||||
const tryOne = async (h: HookEntry, key: string) => {
|
||||
const trial: Trial = { rule: h };
|
||||
setTried((t) => ({ ...t, [key]: trial }));
|
||||
setBusy(key);
|
||||
setError("");
|
||||
try {
|
||||
const res = await port.dryRunHook(h);
|
||||
setTried((t) => ({ ...t, [key]: res }));
|
||||
const result = await port.dryRunHook(h);
|
||||
setTried((t) => t[key] === trial ? { ...t, [key]: { ...trial, result } } : t);
|
||||
} catch (e) {
|
||||
setError(reason(e));
|
||||
const error = reason(e);
|
||||
setTried((t) => t[key] === trial ? { ...t, [key]: { ...trial, error } } : t);
|
||||
} finally {
|
||||
setBusy("");
|
||||
}
|
||||
@@ -210,7 +219,7 @@ function Expert({
|
||||
rules: HookEntry[];
|
||||
plugin: HookEntry[];
|
||||
busy: string;
|
||||
tried: Record<string, HookDryRun>;
|
||||
tried: Record<string, Trial>;
|
||||
onSave: (next: HookEntry[]) => void;
|
||||
onTry: (h: HookEntry, key: string) => void;
|
||||
}) {
|
||||
@@ -226,7 +235,7 @@ function Expert({
|
||||
{draft.map((h, i) => {
|
||||
const meta = info(h.event);
|
||||
const key = `${scope}-${i}`;
|
||||
const res = tried[key];
|
||||
const trial = tried[key]?.rule === h ? tried[key] : undefined;
|
||||
return (
|
||||
<div className="rule" key={i} data-blocking={meta?.blocking ? "" : undefined}>
|
||||
<div className="line">
|
||||
@@ -269,7 +278,8 @@ function Expert({
|
||||
button that checks syntax and one that might delete a file. */}
|
||||
<span className="note">{t("将实际执行该命令")}</span>
|
||||
</div>
|
||||
{res && <DryRun res={res} blocking={!!meta?.blocking} />}
|
||||
{trial?.result && <DryRun res={trial.result} blocking={!!meta?.blocking} />}
|
||||
{trial?.error && <div className="why">{trial.error}</div>}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -18,10 +18,10 @@ function loader() {
|
||||
|
||||
let Markdown = loader();
|
||||
|
||||
export function LazyMarkdown({ text, streaming, local }: { text: string; streaming?: boolean; local?: LocalRefs }) {
|
||||
export function LazyMarkdown({ text, streaming, local, images }: { text: string; streaming?: boolean; local?: LocalRefs; images?: boolean }) {
|
||||
return (
|
||||
<Suspense fallback={<div className="md" style={{ whiteSpace: "pre-wrap" }}>{text}</div>}>
|
||||
<Markdown text={text} streaming={streaming} local={local} />
|
||||
<Markdown text={text} streaming={streaming} local={local} images={images} />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
@@ -166,7 +166,7 @@ export interface LocalRefs {
|
||||
|
||||
// live is the tail of a message still arriving; tail alone is only the last
|
||||
// block, which a settled message has too.
|
||||
const Block = memo(function Block({ src, math, emoji, code, tail, live, local }: { src: string; math: Plugin | null; emoji: Plugin | null; code: Plugin | null; tail?: boolean; live?: boolean; local?: LocalRefs }) {
|
||||
const Block = memo(function Block({ src, math, emoji, code, tail, live, local, images }: { src: string; math: Plugin | null; emoji: Plugin | null; code: Plugin | null; tail?: boolean; live?: boolean; local?: LocalRefs; images: boolean }) {
|
||||
// Inside the memo, so a settled block normalises once instead of per chunk.
|
||||
const body = normalizeMath(tail ? balanceFences(src) : src);
|
||||
return (
|
||||
@@ -200,9 +200,12 @@ const Block = memo(function Block({ src, math, emoji, code, tail, live, local }:
|
||||
</a>
|
||||
);
|
||||
},
|
||||
img: ({ src, alt, title }) => (
|
||||
<img src={(local && typeof src === "string" && local.image(src)) || (src as string | undefined)} alt={alt} title={title} />
|
||||
),
|
||||
img: ({ src, alt, title }) =>
|
||||
images ? (
|
||||
<img src={(local && typeof src === "string" && local.image(src)) || (src as string | undefined)} alt={alt} title={title} />
|
||||
) : (
|
||||
<span className="md-noimg">{alt}</span>
|
||||
),
|
||||
pre: ({ children }) => (
|
||||
<CodeBlock lang={langOf(children)} source={textOf(children)} live={live}>{children}</CodeBlock>
|
||||
),
|
||||
@@ -218,7 +221,9 @@ const Block = memo(function Block({ src, math, emoji, code, tail, live, local }:
|
||||
);
|
||||
});
|
||||
|
||||
export function Markdown({ text, streaming, local }: { text: string; streaming?: boolean; local?: LocalRefs }) {
|
||||
/** images=false is for text nobody in the conversation wrote: a picture
|
||||
* address in it is a request the reader never chose to make. */
|
||||
export function Markdown({ text, streaming, local, images = true }: { text: string; streaming?: boolean; local?: LocalRefs; images?: boolean }) {
|
||||
const shown = useRevealed(text, streaming);
|
||||
const math = usePlugin(MATH.test(text), KATEX);
|
||||
const emoji = usePlugin(EMOJI.test(text), EMOJIS);
|
||||
@@ -237,9 +242,9 @@ export function Markdown({ text, streaming, local }: { text: string; streaming?:
|
||||
// handing a listing over would hand over something that was never said.
|
||||
<div className="md" data-live={streaming ? "" : undefined}>
|
||||
{parts.map((p, i) => (
|
||||
<Block key={i} src={p} math={math} emoji={emoji} code={code} local={local} />
|
||||
<Block key={i} src={p} math={math} emoji={emoji} code={code} local={local} images={images} />
|
||||
))}
|
||||
<Block src={shown.slice(at)} math={math} emoji={emoji} code={code} local={local} tail live={streaming} />
|
||||
<Block src={shown.slice(at)} math={math} emoji={emoji} code={code} local={local} images={images} tail live={streaming} />
|
||||
{streaming && <span className="caret" />}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -416,7 +416,7 @@ export function MarketGroup({ port, onInstalled, onViewInstalled, account, onSig
|
||||
if (!handle) return;
|
||||
setDraft({ port, handle, pkg }); setView("publish");
|
||||
}} />}
|
||||
{at === "publish" && handle && <PublishForm port={port} handle={handle} initial={draft?.pkg} onMine={() => { setDraft(null); setView("mine"); }} />}
|
||||
{at === "publish" && handle && <PublishForm port={port} handle={handle} initial={draft?.pkg} onApplying={applyingChanged} onMine={() => { setDraft(null); setView("mine"); }} />}
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -41,7 +41,7 @@ interface Draft {
|
||||
|
||||
const EMPTY: Draft = { kind: "skill", name: "", source: "", summary: "", description: "", repoUrl: "", version: "", tags: [], tagInput: "", private: false };
|
||||
|
||||
interface PublishProps { port: AgentPort; handle: string; onMine: () => void; initial?: MarketPackage }
|
||||
interface PublishProps { port: AgentPort; handle: string; onMine: () => void; onApplying?: (applying: boolean) => void; initial?: MarketPackage }
|
||||
|
||||
export function PublishForm(props: PublishProps) {
|
||||
const [owner, setOwner] = useState({ port: props.port, handle: props.handle, generation: 0 });
|
||||
@@ -53,7 +53,7 @@ export function PublishForm(props: PublishProps) {
|
||||
|
||||
// The form only collects; which sources are publishable and what the registry
|
||||
// accepts are the kernel's and the registry's answers, shown as they come back.
|
||||
function PublishDraft({ port, handle, onMine, initial }: PublishProps) {
|
||||
function PublishDraft({ port, handle, onMine, onApplying, initial }: PublishProps) {
|
||||
const [d, setD] = useState<Draft>(() => initial ? {
|
||||
...EMPTY, kind: initial.kind, name: initial.name, summary: initial.summary,
|
||||
description: initial.description, repoUrl: initial.repoUrl, tags: [...initial.tags],
|
||||
@@ -64,6 +64,11 @@ function PublishDraft({ port, handle, onMine, initial }: PublishProps) {
|
||||
const [done, setDone] = useState<MarketPublished | null>(null);
|
||||
const set = (k: keyof Draft) => (e: { target: { value: string } }) => setD({ ...d, [k]: e.target.value });
|
||||
|
||||
useEffect(() => {
|
||||
onApplying?.(busy);
|
||||
return () => onApplying?.(false);
|
||||
}, [busy, onApplying]);
|
||||
|
||||
const submit = async () => {
|
||||
setBusy(true);
|
||||
setError("");
|
||||
|
||||
@@ -22,6 +22,8 @@ export interface MenuItem {
|
||||
// through twice.
|
||||
header?: boolean;
|
||||
disabled?: boolean;
|
||||
// A letter chip leading the row or heading, standing in for a vendor mark.
|
||||
mono?: { letter: string; tone: number };
|
||||
// A switch rather than a choice: picking it flips this state, and the row
|
||||
// says which state it is in instead of being marked as the current value.
|
||||
toggle?: boolean;
|
||||
@@ -55,13 +57,17 @@ interface Props {
|
||||
// Opening is when the reader asks what the choices are, so a menu whose rows
|
||||
// come from elsewhere re-reads them here rather than showing its mount's copy.
|
||||
onOpen?: () => void;
|
||||
// The field is the menu's way in rather than its answer to length: a model
|
||||
// list is searched for by name however few entries it has.
|
||||
searchAlways?: boolean;
|
||||
searchPlaceholder?: string;
|
||||
}
|
||||
|
||||
// data-* rides through to the item that raises the pick, the way Switch and Seg
|
||||
// carry theirs: the action's identity is written at the call site, and the
|
||||
// answer this menu gives is the item's own value.
|
||||
export function Picker({
|
||||
label, items, current, onPick, place, align = "start", className, title, pending, triggerAction, ariaPressed, wrapClassName, menuClassName, menuTitle, onOpen, ...id
|
||||
label, items, current, onPick, place, align = "start", className, title, pending, triggerAction, ariaPressed, wrapClassName, menuClassName, menuTitle, onOpen, searchAlways, searchPlaceholder, ...id
|
||||
}: Props & { [K in `data-${string}`]?: string }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [query, setQuery] = useState("");
|
||||
@@ -71,11 +77,12 @@ export function Picker({
|
||||
const find = useRef<HTMLInputElement>(null);
|
||||
|
||||
const choosable = items.filter((it) => !it.header && !it.plain).length;
|
||||
const filtering = choosable > FILTER_FROM;
|
||||
const filtering = searchAlways || choosable > FILTER_FROM;
|
||||
const shown = useMemo(() => (filtering ? matching(items, query) : items), [filtering, items, query]);
|
||||
// What Enter takes while the field still has focus. Only shown once the
|
||||
// query narrows something, or every menu would open pre-selected.
|
||||
const lead = query ? shown.find((it) => !it.header)?.value : undefined;
|
||||
const lead = query ? shown.find((it) => !it.header && !it.plain)?.value : undefined;
|
||||
const none = filtering && !shown.some((it) => !it.header && !it.plain);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
@@ -84,6 +91,7 @@ export function Picker({
|
||||
}
|
||||
if (filtering) find.current?.focus();
|
||||
else menu.current?.querySelector<HTMLElement>("button.mi")?.focus();
|
||||
menu.current?.querySelector<HTMLElement>("button.mi[data-on]")?.scrollIntoView({ block: "nearest" });
|
||||
// The menu is not inside the wrapper any more, so without asking it too a
|
||||
// press on one of its own rows would read as a press outside.
|
||||
const onDown = (e: MouseEvent) => {
|
||||
@@ -140,6 +148,11 @@ export function Picker({
|
||||
// Headings are divs and never take focus, so walking the buttons is what
|
||||
// keeps one arrow press from landing on nothing.
|
||||
const arrows = (e: React.KeyboardEvent) => {
|
||||
const typed = e.key.length === 1 && e.key !== " " && !e.ctrlKey && !e.metaKey && !e.altKey;
|
||||
if (filtering && typed && e.target !== find.current) {
|
||||
find.current?.focus();
|
||||
return;
|
||||
}
|
||||
if (e.key !== "ArrowDown" && e.key !== "ArrowUp") return;
|
||||
const all = [...(menu.current?.querySelectorAll<HTMLElement>("button.mi") ?? [])];
|
||||
if (!all.length) return;
|
||||
@@ -152,6 +165,11 @@ export function Picker({
|
||||
return;
|
||||
}
|
||||
const to = e.key === "ArrowDown" ? i + 1 : i - 1;
|
||||
if (to < 0 && filtering) {
|
||||
e.preventDefault();
|
||||
find.current?.focus();
|
||||
return;
|
||||
}
|
||||
if (to < 0 || to >= all.length) return;
|
||||
e.preventDefault();
|
||||
all[to].focus();
|
||||
@@ -200,7 +218,7 @@ export function Picker({
|
||||
ref={find}
|
||||
className="mfind"
|
||||
value={query}
|
||||
placeholder={t("筛选 {n} 项", { n: choosable })}
|
||||
placeholder={searchPlaceholder ?? t("筛选 {n} 项", { n: choosable })}
|
||||
aria-label={t("筛选")}
|
||||
spellCheck={false}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
@@ -211,11 +229,13 @@ export function Picker({
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{none && <div className="mnone">{t("没有匹配的项")}</div>}
|
||||
{shown.map((it, i) => (
|
||||
<Fragment key={it.value}>
|
||||
{it.divide && i > 0 && <div className="div" />}
|
||||
{it.header ? (
|
||||
<div className="mi head">
|
||||
{it.mono && <Mono {...it.mono} />}
|
||||
<span className="lb">{it.label}</span>
|
||||
{it.right && <span className="rt">{it.right}</span>}
|
||||
</div>
|
||||
@@ -233,7 +253,7 @@ export function Picker({
|
||||
data-recommended={it.recommended ? "" : undefined}
|
||||
onClick={() => take(it.value)}
|
||||
>
|
||||
<span className="dot" />
|
||||
{it.mono ? <Mono {...it.mono} /> : <span className="dot" />}
|
||||
<span className="tx">
|
||||
<span className="studio-item-line">
|
||||
<span className="lb">{it.label}</span>
|
||||
@@ -253,7 +273,6 @@ export function Picker({
|
||||
)}
|
||||
</Fragment>
|
||||
))}
|
||||
{filtering && shown.length === 0 && <div className="mnone">{t("没有匹配的项")}</div>}
|
||||
</div>,
|
||||
document.body,
|
||||
)}
|
||||
@@ -261,6 +280,10 @@ export function Picker({
|
||||
);
|
||||
}
|
||||
|
||||
function Mono({ letter, tone }: { letter: string; tone: number }) {
|
||||
return <i className="mi-mono" data-tone={tone} style={{ "--tone": `var(--cat-${tone + 1})` } as React.CSSProperties} aria-hidden="true">{letter}</i>;
|
||||
}
|
||||
|
||||
// Group order is kept rather than ranked by relevance: a list that reorders
|
||||
// itself as you type moves the row out from under the cursor.
|
||||
function matching(items: MenuItem[], query: string): MenuItem[] {
|
||||
@@ -277,7 +300,7 @@ function matching(items: MenuItem[], query: string): MenuItem[] {
|
||||
// Actions are not data — filtering them out would take "打开其他目录…"
|
||||
// away exactly when the query found nothing and the user needs it.
|
||||
if (it.plain) return false;
|
||||
const hay = `${head}|${normalize(`${it.label} ${it.right ?? ""} ${it.desc ?? ""}`)}`;
|
||||
const hay = `${head}|${normalize(`${it.label} ${it.right ?? ""} ${it.meta ?? ""} ${it.desc ?? ""}`)}`;
|
||||
return words.every((w) => hay.includes(w));
|
||||
});
|
||||
return items.filter((it, i) => {
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
import { useCallback, useRef, useState, type CSSProperties } from "react";
|
||||
import { t } from "../i18n";
|
||||
import type { ContextBreakdown, JobEntry, McpEntry } from "../port/port";
|
||||
import type { Metrics } from "../state/session_types";
|
||||
import { DeckChips, type Deck } from "./DeckChips";
|
||||
import { ContextSummaryCard } from "./ContextSummaryCard";
|
||||
import { useDismiss } from "./dismiss";
|
||||
import type { Task } from "./panels/Agents";
|
||||
import { Spark } from "./Spark";
|
||||
import { StudioIcon } from "./StudioIcon";
|
||||
import { setHidesAmounts } from "../state/prefs";
|
||||
import { MASK, type Wallet } from "./wallet";
|
||||
import type { SpeedSummary } from "./speed";
|
||||
|
||||
/** The numbers about this turn, in one row under the composer. */
|
||||
export function MeterRail({ tps, trail, running, speed, metrics, ctx, mcp, cost, wallet, hideAmounts, tasks, jobs, onSettings, onCancelJob }: {
|
||||
tps: number;
|
||||
trail: number[];
|
||||
running: boolean;
|
||||
speed: SpeedSummary;
|
||||
metrics: Pick<Metrics, "hit" | "miss">;
|
||||
ctx: ContextBreakdown | null;
|
||||
mcp: McpEntry[];
|
||||
cost: string;
|
||||
wallet: Wallet;
|
||||
hideAmounts: boolean;
|
||||
tasks: Task[];
|
||||
jobs: JobEntry[];
|
||||
onSettings: (section?: string) => void;
|
||||
onCancelJob: (id: string) => Promise<void>;
|
||||
}) {
|
||||
const [meterOpen, setMeterOpen] = useState(false);
|
||||
const meterRef = useRef<HTMLDivElement>(null);
|
||||
const closeMeter = useCallback(() => setMeterOpen(false), []);
|
||||
useDismiss(meterOpen, meterRef, closeMeter);
|
||||
const [deck, setDeck] = useState<Deck>("");
|
||||
const cacheTokens = metrics.hit + metrics.miss;
|
||||
const cacheRate = cacheTokens > 0 ? Math.round((metrics.hit / cacheTokens) * 100) : null;
|
||||
const contextPercent = ctx && ctx.window > 0 ? Math.min(100, Math.round((ctx.used / ctx.window) * 100)) : 0;
|
||||
return (
|
||||
<div className="studio-meterrail" ref={meterRef} aria-label={t("运行统计")}>
|
||||
<div className="studio-speed-anchor">
|
||||
<button
|
||||
className="studio-meter-static studio-meter-speed"
|
||||
type="button"
|
||||
aria-describedby="studio-speed-detail"
|
||||
aria-label={t("查看生成速度详情")}
|
||||
>
|
||||
<Spark points={trail} w={44} h={13} />
|
||||
<b>{tps > 0 ? tps.toFixed(1) : "—"}</b><span>tok/s</span><i data-live={running ? "" : undefined} aria-hidden="true" />
|
||||
</button>
|
||||
<div className="studio-speed-detail" id="studio-speed-detail" role="tooltip">
|
||||
<header><b>{t("生成速度")}</b><small>{running ? t("实时更新") : t("最近一轮")}</small></header>
|
||||
<dl>
|
||||
<div><dt>{t("当前速度")}</dt><dd>{tps > 0 ? `${tps.toFixed(1)} tok/s` : "—"}</dd></div>
|
||||
<div><dt>{t("整轮平均")}</dt><dd>{speed.average > 0 ? `${speed.average.toFixed(1)} tok/s` : "—"}</dd></div>
|
||||
<div><dt>{t("本轮输出")}</dt><dd>{speed.output > 0 ? t("{n} tokens", { n: speed.output.toLocaleString() }) : "—"}</dd></div>
|
||||
<div><dt>{t("模型耗时")}</dt><dd>{speed.modelSeconds > 0 ? `${speed.modelSeconds.toFixed(1)}s` : "—"}</dd></div>
|
||||
</dl>
|
||||
<p>{t("当前速度按最近 4 秒流式文本估算;整轮平均使用服务商返回的输出 Token 除以模型回合耗时。")}</p>
|
||||
</div>
|
||||
</div>
|
||||
<span className="studio-meter-static studio-meter-cache" title={cacheRate === null ? t("尚无缓存数据") : t("命中 {hit} · 未命中 {miss}", { hit: metrics.hit.toLocaleString(), miss: metrics.miss.toLocaleString() })}>
|
||||
<span>{t("缓存")}</span><b>{cacheRate === null ? "—" : `${cacheRate}%`}</b>
|
||||
</span>
|
||||
{ctx && ctx.window > 0 && (
|
||||
<div className="studio-context-anchor" data-open={meterOpen ? "" : undefined}>
|
||||
<button className="studio-meter-context" data-action="metrics.details" data-value="context" aria-expanded={meterOpen} aria-haspopup="dialog" aria-label={t("查看上下文与压缩")} onClick={() => setMeterOpen((open) => !open)}>
|
||||
<span className="studio-context-ring" style={{ "--fill": `${contextPercent}%` } as CSSProperties} aria-hidden="true" />
|
||||
<span>{t("上下文")}</span><b>{contextPercent}%</b>
|
||||
</button>
|
||||
<ContextSummaryCard
|
||||
className="studio-context-summary"
|
||||
context={ctx}
|
||||
mcp={mcp}
|
||||
percent={contextPercent}
|
||||
onManage={() => { closeMeter(); onSettings("ext"); }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{cost && <span className="studio-meter-cost"><span>{t("本轮")}</span><b>{hideAmounts ? MASK : cost}</b></span>}
|
||||
<button className="studio-meter-mask" type="button" data-action="metrics.hide-amounts" aria-pressed={hideAmounts} aria-label={hideAmounts ? t("显示金额") : t("隐藏金额")} title={hideAmounts ? t("显示金额") : t("隐藏金额")} onClick={() => setHidesAmounts(!hideAmounts)}><StudioIcon name={hideAmounts ? "eyeoff" : "eye"} /></button>
|
||||
{wallet.kind === "read" && <button className="studio-meter-wallet" data-short={wallet.reading.available ? undefined : "true"} title={wallet.reading.available ? undefined : t("余额不足")} data-action="settings.section" data-value="usage" aria-label={wallet.reading.available ? t("查看钱包余额") : `${t("查看钱包余额")}, ${t("余额不足")}`} onClick={() => onSettings("usage")}><StudioIcon name="wallet" /><b>{hideAmounts ? MASK : wallet.reading.display}</b></button>}
|
||||
<DeckChips tasks={tasks} jobs={jobs} open={deck} onOpen={setDeck} onCancelJob={onCancelJob} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,6 @@
|
||||
import { t } from "../i18n";
|
||||
import { FeedbackBadge } from "./feedbackentry";
|
||||
import { StudioIcon } from "./StudioIcon";
|
||||
|
||||
// Drawn on the same 16-unit grid at the same 1.5 stroke as Sym's marks, and for
|
||||
// the same reason: an icon set pulled in from outside reads as a different hand
|
||||
@@ -34,9 +36,13 @@ interface Props {
|
||||
at: string | null;
|
||||
onGo: (sec?: string) => void;
|
||||
onHome: () => void;
|
||||
onFeedback: () => void;
|
||||
feedbackUnread: number;
|
||||
/** False keeps the column mounted but out of the way and out of reach. */
|
||||
shown: boolean;
|
||||
}
|
||||
|
||||
export function Nav({ at, onGo, onHome }: Props) {
|
||||
export function Nav({ at, onGo, onHome, onFeedback, feedbackUnread, shown }: Props) {
|
||||
const btn = ([key, label, sec]: [keyof typeof ICON, string, string | null]) => (
|
||||
<button
|
||||
key={key}
|
||||
@@ -52,9 +58,16 @@ export function Nav({ at, onGo, onHome }: Props) {
|
||||
</button>
|
||||
);
|
||||
return (
|
||||
<nav className="nav" aria-label={t("主导航")}>
|
||||
<nav className="nav" aria-label={t("主导航")} aria-hidden={shown ? undefined : true} inert={!shown}>
|
||||
<div className="navgrp">{TO.map(btn)}</div>
|
||||
<div className="navgrp foot">{FOOT.map(btn)}</div>
|
||||
<div className="navgrp foot">
|
||||
<button className="navbtn" data-action="feedback.open" onClick={onFeedback}>
|
||||
<StudioIcon name="feedback" aria-hidden="true" />
|
||||
<span className="navhint" role="tooltip">{t("发送反馈")}</span>
|
||||
<FeedbackBadge unread={feedbackUnread} />
|
||||
</button>
|
||||
{FOOT.map(btn)}
|
||||
</div>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { useSyncExternalStore } from "react";
|
||||
import { t } from "../i18n";
|
||||
import { NAV_RAIL_MODES, navRailMode, onNavRailChange, setNavRailMode, type NavRailMode } from "../state/prefs";
|
||||
import { Group } from "./Group";
|
||||
|
||||
const LABEL: Record<NavRailMode, string> = { on: "始终显示", collapsed: "仅侧栏收起时显示", off: "不显示" };
|
||||
|
||||
export function NavRailSection() {
|
||||
const mode = useSyncExternalStore(onNavRailChange, navRailMode, navRailMode);
|
||||
return (
|
||||
<Group id="navrail" title={t("图标栏")} hint={t("会话侧栏左边的一列图标,直达用量、工具、扩展、记忆、远程、反馈、账号和设置。窗口窄到手机宽度时不显示。")}>
|
||||
<div className="seg" data-text role="group" aria-label={t("图标栏")}>
|
||||
{NAV_RAIL_MODES.map((m) => (
|
||||
<button key={m} data-action="appearance.nav-rail" data-value={m} aria-pressed={mode === m} onClick={() => setNavRailMode(m)}>
|
||||
{t(LABEL[m])}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<p className="note">{t("关闭或收起时,这些页面仍可从设置和侧栏打开")}</p>
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
@@ -1,20 +1,16 @@
|
||||
import { memo, useCallback, useEffect, useMemo, useReducer, useRef, useState, type CSSProperties, type ReactNode } from "react";
|
||||
import { memo, useCallback, useEffect, useMemo, useReducer, useRef, useState } from "react";
|
||||
import { money } from "../i18n/format";
|
||||
import { reason } from "../i18n/kernel";
|
||||
import { t } from "../i18n";
|
||||
import { hasPendingDecision, posture, runState } from "./decisions";
|
||||
import { createPortal } from "react-dom";
|
||||
import { HttpError, type AgentPort, type Checkpoint, type ChipCall, type ContextBreakdown, type JobEntry, type McpEntry, type SessionStatus, type WorkspaceChanges } from "../port/port";
|
||||
import type { RuntimeView } from "../port/hub";
|
||||
import type { Checkpoint, ContextBreakdown, JobEntry, McpEntry, SessionStatus, WorkspaceChanges } from "../port/port";
|
||||
import type { TrajectoryRead } from "../port/wire";
|
||||
import { chipLabel, currentStep, fromHistory, initialState, localId, quoteAmount, reduce, stepDone, stepLabel } from "../state/session";
|
||||
import { chipLabel, fromHistory, initialState, quoteAmount, reduce } from "../state/session";
|
||||
import { pairCheckpoints } from "../state/checkpoints";
|
||||
import { DeckChips, type Deck } from "./DeckChips";
|
||||
import { Plan } from "./Plan";
|
||||
import { useReplyActions } from "./reply";
|
||||
import { useGateActions } from "./gates";
|
||||
import { useQueueActions } from "./queueactions";
|
||||
import { RunTokens } from "./RunTokens";
|
||||
import { useRewindActions } from "./rewind";
|
||||
import { initialTraj, reduceTraj } from "../state/trajectory";
|
||||
import { Transcript } from "./Transcript";
|
||||
@@ -24,18 +20,19 @@ import { Composer } from "./Composer";
|
||||
import { draftKey } from "./drafts";
|
||||
import { Queue, waiting } from "./Queue";
|
||||
import { SlottedView } from "./SlottedView";
|
||||
import { key as slotKey, placement } from "./slots";
|
||||
import { key as slotKey } from "./slots";
|
||||
import { Metrics } from "./Metrics";
|
||||
import { railOf } from "./panels/derive";
|
||||
import { MASK, accountOf, useHidesAmounts, useWallet } from "./wallet";
|
||||
import { setHidesAmounts } from "../state/prefs";
|
||||
import { swapping } from "./swap";
|
||||
import { PaneNav, type PaneView } from "./PaneNav";
|
||||
import { accountOf, useHidesAmounts, useWallet } from "./wallet";
|
||||
import { PaneNav } from "./PaneNav";
|
||||
import { useShowView } from "./showview";
|
||||
import { useSubmit } from "./usesubmit";
|
||||
import { useSurfaceSlots } from "./usesurfaceslots";
|
||||
import { MeterRail } from "./MeterRail";
|
||||
import { PlanFold } from "./PlanFold";
|
||||
import { RunLine } from "./RunLine";
|
||||
import type { PaneProps, PaneReport } from "./panetypes";
|
||||
import { useRate, useTrail } from "./num";
|
||||
import { Spark } from "./Spark";
|
||||
import { StudioIcon } from "./StudioIcon";
|
||||
import { useDismiss } from "./dismiss";
|
||||
import { ContextSummaryCard } from "./ContextSummaryCard";
|
||||
import { DOCK, Gutter } from "./Gutter";
|
||||
import { Find } from "./Find";
|
||||
import { useFind } from "./usefind";
|
||||
@@ -44,34 +41,21 @@ import { useBrowserTabs } from "./BrowserPanel";
|
||||
import { useRevealAgentPages, useRevealBrowserOpen } from "./browserreveal";
|
||||
import { WorkbenchPanel } from "./WorkbenchPanel";
|
||||
import { refreshTodos } from "../state/restore";
|
||||
import { RMark } from "./RMark";
|
||||
import { speedOf } from "./speed";
|
||||
import { RuntimeBar } from "./RuntimeBar";
|
||||
import { PostureNote } from "./PostureNote";
|
||||
import { useStatusPoll } from "./useStatusPoll";
|
||||
import { useCheckpointRefresh } from "./useCheckpointRefresh";
|
||||
import { LiveWork, useLiveWork } from "../state/foldpref";
|
||||
|
||||
// PaneReport is what the window's own chrome needs from whichever pane has
|
||||
// focus: everything else about a session stays inside the pane that owns it.
|
||||
export interface PaneReport {
|
||||
status: SessionStatus | null;
|
||||
title: string;
|
||||
steer: number;
|
||||
run: string;
|
||||
// Whether the turn is actually moving or waiting on you. "halt" cannot answer
|
||||
// this: a reopened history sits at halt too, and closing that costs nothing.
|
||||
live: boolean;
|
||||
cost: string;
|
||||
contextPercent: number | null;
|
||||
context: ContextBreakdown | null;
|
||||
mcp: McpEntry[];
|
||||
wallet: string;
|
||||
}
|
||||
export type { PaneReport };
|
||||
|
||||
// A shared constant, not `?? []`: a fresh empty array every render reads as a
|
||||
// changed prop to the rail below it.
|
||||
const NO_JOBS: JobEntry[] = [];
|
||||
|
||||
type SessionRead = { kind: "pending" } | { kind: "settled"; status: SessionStatus | null };
|
||||
|
||||
const totalsOf = (st: SessionStatus) => ({
|
||||
kind: "__totals",
|
||||
hit: st.cacheHit,
|
||||
@@ -82,52 +66,13 @@ const totalsOf = (st: SessionStatus) => ({
|
||||
incompleteReason: st.sessionCostQuote?.incompleteReason,
|
||||
});
|
||||
|
||||
interface Props {
|
||||
port: AgentPort;
|
||||
rt: RuntimeView;
|
||||
title: string;
|
||||
active: boolean;
|
||||
// Where the metrics rail lives. Only the focused pane renders into it, so the
|
||||
// column stays on the window's edge instead of appearing between two panes.
|
||||
sideHost: HTMLElement | null;
|
||||
side: boolean;
|
||||
onFocus: () => void;
|
||||
// Every pane reports, not just the focused one: a tab has to show that the
|
||||
// conversation behind it is still working.
|
||||
onReport: (id: string, report: PaneReport) => void;
|
||||
// Off-screen panes stay mounted — their stream, transcript and scroll
|
||||
// position are exactly what a tab switch must not throw away.
|
||||
visible: boolean;
|
||||
onSessionChanged: () => void;
|
||||
onTurnDone?: (id: string) => void;
|
||||
// Bumped when something outside this pane changed a setting that belongs to
|
||||
// its session. /status is polled only while a turn runs, so without this the
|
||||
// pane keeps reporting the posture it had when it opened.
|
||||
pulse: number;
|
||||
findPulse: number;
|
||||
onSettings: (section?: string) => void;
|
||||
// 这个窗口还没有人选过的项目文件夹。空转录是唯一说得出这句话的地方 —— 那里
|
||||
// 本来就在替一段还没开始的对话说明它该怎么开始。
|
||||
needsProject: boolean;
|
||||
onOpenProject: () => void;
|
||||
onKeepHere: () => void;
|
||||
// A prop, not a document read: this pane is memoised past an attribute flip.
|
||||
theme: string;
|
||||
// Rides on `.app`: that is where the divider writes while a drag is in flight.
|
||||
dockW: number;
|
||||
dockMax: number;
|
||||
onDockW: (w: number) => void;
|
||||
manualBrowser?: boolean;
|
||||
onManualBrowser?: (on: boolean) => void;
|
||||
// The window's failed-request notice, handed only to the pane in front.
|
||||
alert?: ReactNode;
|
||||
}
|
||||
|
||||
function PaneView({ port, rt, title, active, visible, sideHost, side, onFocus, onReport, onSessionChanged, onTurnDone, pulse, findPulse, onSettings, needsProject, onOpenProject, onKeepHere, theme, dockW, dockMax, onDockW, manualBrowser = false, onManualBrowser, alert }: Props) {
|
||||
function PaneView({ port, rt, title, active, visible, sideHost, side, onFocus, onReport, onSessionChanged, onTurnDone, pulse, findPulse, onSettings, needsProject, onOpenProject, onKeepHere, theme, dockW, dockMax, onDockW, manualBrowser = false, onManualBrowser, alert }: PaneProps) {
|
||||
const [s, dispatch] = useReducer(reduce, initialState);
|
||||
const [traj, trajDispatch] = useReducer(reduceTraj, initialTraj);
|
||||
const [status, setStatus] = useState<SessionStatus | null>(null);
|
||||
const [tab, setTab] = useState<PaneView>("flow");
|
||||
const [sessionRead, setSessionRead] = useState<SessionRead>({ kind: "pending" });
|
||||
const status = sessionRead.kind === "settled" ? sessionRead.status : null;
|
||||
const ready = sessionRead.kind === "settled";
|
||||
const [tab, showView] = useShowView(rt.id, onManualBrowser);
|
||||
const [pinned, setPinned] = useState(true);
|
||||
const [jump, setJump] = useState(0);
|
||||
const [mcp, setMcp] = useState<McpEntry[]>([]);
|
||||
@@ -135,7 +80,6 @@ function PaneView({ port, rt, title, active, visible, sideHost, side, onFocus, o
|
||||
const [tree, setTree] = useState<WorkspaceChanges | null>(null);
|
||||
const [ctx, setCtx] = useState<ContextBreakdown | null>(null);
|
||||
const [checkpoints, setCheckpoints] = useState<Checkpoint[]>([]);
|
||||
const [slots, setSlots] = useState<Record<string, string>>({});
|
||||
const pages = useBrowserTabs(port, s.browserTabsMoved);
|
||||
const revealBrowser = useCallback(() => onManualBrowser?.(true), [onManualBrowser]);
|
||||
useRevealAgentPages(pages, active && !rt.host, revealBrowser);
|
||||
@@ -146,10 +90,6 @@ function PaneView({ port, rt, title, active, visible, sideHost, side, onFocus, o
|
||||
// for exactly the horizontal/vertical space the analysis view explains.
|
||||
const docked = manualBrowser && tab === "flow";
|
||||
const workbench = docked || tab === "browser";
|
||||
const [meterOpen, setMeterOpen] = useState(false);
|
||||
const meterRef = useRef<HTMLDivElement>(null);
|
||||
const closeMeter = useCallback(() => setMeterOpen(false), []);
|
||||
useDismiss(meterOpen, meterRef, closeMeter);
|
||||
const flow = useRef<HTMLDivElement>(null);
|
||||
const running = s.running || !!status?.running; // A paired device can join after turn_started.
|
||||
// Elapsed is a clock reading and belongs on the tick. Throughput is not: it
|
||||
@@ -189,7 +129,8 @@ function PaneView({ port, rt, title, active, visible, sideHost, side, onFocus, o
|
||||
// answers are word-for-word the previous one. Swapping in an equal object
|
||||
// would repaint the rail and the composer for no news at all.
|
||||
const applyStatus = useCallback((next: SessionStatus) => {
|
||||
setStatus((prev) => (prev && JSON.stringify(prev) === JSON.stringify(next) ? prev : next));
|
||||
setSessionRead((prev) => (prev.kind === "settled" && prev.status && JSON.stringify(prev.status) === JSON.stringify(next)
|
||||
? prev : { kind: "settled", status: next }));
|
||||
}, []);
|
||||
|
||||
const refreshStatus = useCallback(() => port.status().then(applyStatus).catch(() => {}), [port, applyStatus]);
|
||||
@@ -248,7 +189,6 @@ function PaneView({ port, rt, title, active, visible, sideHost, side, onFocus, o
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
port.trajectory().then((r) => alive && replayTrajectory(r)).catch(() => {});
|
||||
port.checkpoints().then((cps) => alive && setCheckpoints(cps)).catch(() => {});
|
||||
// The record and the numbers over it are two reads, not one. /status can go
|
||||
// to the network — the provider's wallet endpoint rides it — and pairing the
|
||||
// two made the conversation wait on a round trip that has nothing to do with
|
||||
@@ -260,13 +200,15 @@ function PaneView({ port, rt, title, active, visible, sideHost, side, onFocus, o
|
||||
});
|
||||
port.status().then((st) => {
|
||||
if (!alive) return;
|
||||
setStatus(st);
|
||||
applyStatus(st);
|
||||
dispatch(totalsOf(st) as never);
|
||||
}).catch(() => {
|
||||
if (alive) setSessionRead((prev) => prev.kind === "pending" ? { kind: "settled", status: null } : prev);
|
||||
});
|
||||
return () => {
|
||||
alive = false;
|
||||
};
|
||||
}, [port]);
|
||||
}, [port, applyStatus]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!running) {
|
||||
@@ -307,8 +249,6 @@ function PaneView({ port, rt, title, active, visible, sideHost, side, onFocus, o
|
||||
return history;
|
||||
}, [port, applyStatus, refreshWallet, onSessionChanged, replayTrajectory]);
|
||||
|
||||
const { onPrepareRewind, onCommitRewind, onUndoRewind, onPrepareFileRevert, onCommitFileRevert } = useRewindActions(port, reloadSession);
|
||||
|
||||
// Both of these read only the user and tool cards, so they key off the
|
||||
// revision rather than the items array: a streamed answer leaves every card
|
||||
// they look at untouched, and recomputing them per chunk is the whole reason
|
||||
@@ -318,13 +258,6 @@ function PaneView({ port, rt, title, active, visible, sideHost, side, onFocus, o
|
||||
const paired = useMemo(() => pairCheckpoints(s.items, checkpoints), [s.revision, checkpoints]);
|
||||
const rail = useMemo(() => railOf(s.items, s.executions, s.subagentPhase), [s.revision, s.executions, s.subagentPhase]);
|
||||
|
||||
// Sub-agents and background processes: built as panels, never drawn.
|
||||
const [deck, setDeck] = useState<Deck>("");
|
||||
// The list belongs above the line that narrates the turn, because that is
|
||||
// what it is about. Plan was built and only ever mounted inside an inspector
|
||||
// nothing renders, which is why it had never been seen.
|
||||
const todoOpen = s.plan.length > 0 && tab === "flow";
|
||||
const [todoShown, setTodoShown] = useState(false);
|
||||
const jobs = status?.jobs ?? NO_JOBS;
|
||||
const counts = useMemo(() => {
|
||||
let steps = 0;
|
||||
@@ -339,13 +272,12 @@ function PaneView({ port, rt, title, active, visible, sideHost, side, onFocus, o
|
||||
// An MCP server connects lazily and fails at first use, so a turn boundary is
|
||||
// also when its status can have changed — no timer of its own needed.
|
||||
useEffect(() => {
|
||||
reloadMcp();
|
||||
// A finished turn is exactly when the kernel has one more checkpoint.
|
||||
port.checkpoints().then(setCheckpoints).catch(() => {});
|
||||
}, [reloadMcp, port, status?.sessionPath, running]);
|
||||
if (ready) reloadMcp();
|
||||
}, [ready, reloadMcp, status?.sessionPath, running]);
|
||||
useCheckpointRefresh(port, status?.sessionPath, running, setCheckpoints);
|
||||
// A call that may write can have moved the tree before the turn ends.
|
||||
const refreshTree = useCallback(() => void port.changes().then(setTree).catch(() => setTree(null)), [port]);
|
||||
useEffect(refreshTree, [refreshTree, status?.sessionPath, running, counts.wrote]);
|
||||
useEffect(() => { if (ready || counts.wrote) refreshTree(); }, [ready, refreshTree, status?.sessionPath, running, counts.wrote]);
|
||||
|
||||
// One turn can be dozens of model round trips — the session this was measured
|
||||
// on ran thirty, from 9k tokens to 57k. Reading the gauge only at the turn
|
||||
@@ -359,8 +291,9 @@ function PaneView({ port, rt, title, active, visible, sideHost, side, onFocus, o
|
||||
const folds = s.items.reduce((n, i) => n + (i.t === "compaction" && i.done ? 1 : 0), 0);
|
||||
const roundTrips = s.metrics.hit + s.metrics.miss;
|
||||
useEffect(() => {
|
||||
if (!ready && !roundTrips && !folds) return;
|
||||
port.context().then(setCtx).catch(() => setCtx(null));
|
||||
}, [port, roundTrips, folds, status?.sessionPath, running]);
|
||||
}, [ready, port, roundTrips, folds, status?.sessionPath, running]);
|
||||
|
||||
// The sidebar has to hear about this pane's session twice: when the first
|
||||
// turn mints the file (before that there is no row to show) and when the turn
|
||||
@@ -374,90 +307,19 @@ function PaneView({ port, rt, title, active, visible, sideHost, side, onFocus, o
|
||||
// not echo, so a live turn has to re-read it rather than infer from events.
|
||||
useStatusPoll(running && visible, refreshStatus);
|
||||
|
||||
useEffect(() => {
|
||||
void port.surfaceSlots().then(setSlots).catch(() => setSlots({}));
|
||||
}, [port]);
|
||||
const { slots, moveSurface, atComposer, inRail } = useSurfaceSlots(port, s.views, fail);
|
||||
|
||||
// Where the user put each extension surface. Updated in place: a move is the
|
||||
// user's own action, so waiting for a round trip would read as a dead click.
|
||||
const moveSurface = useCallback(
|
||||
async (ext: { pluginId: string; surfaceId: string }, slot: string) => {
|
||||
const id = `${ext.pluginId}:${ext.surfaceId}`;
|
||||
setSlots((prev) => {
|
||||
const next = { ...prev };
|
||||
if (slot) next[id] = slot;
|
||||
else delete next[id];
|
||||
return next;
|
||||
});
|
||||
await port.assignSurface(id, slot).catch(fail);
|
||||
},
|
||||
[port, fail],
|
||||
);
|
||||
// Split once per change rather than per frame: a new array each render is a
|
||||
// changed prop, and that alone would keep the rail re-rendering all turn.
|
||||
const [atComposer, inRail] = useMemo(() => {
|
||||
const at: typeof s.views = [];
|
||||
const rail: typeof s.views = [];
|
||||
for (const v of s.views) (placement(v, slots) === "composer-trailing" ? at : rail).push(v);
|
||||
return [at, rail];
|
||||
}, [s.views, slots]);
|
||||
const submit = useSubmit({ port, running, dispatch, trajDispatch, refreshStatus, fail });
|
||||
|
||||
// The wire never echoes what was typed, so the row is the client's to add —
|
||||
// and its to take back when the line did not leave. Reporting the refusal is
|
||||
// not enough on its own: the transcript would keep showing a turn that never
|
||||
// happened, and the composer was emptied on the way in.
|
||||
const submit = useCallback(
|
||||
async (text: string, chips?: ChipCall) => {
|
||||
const steering = running;
|
||||
const id = localId();
|
||||
dispatch({ kind: "__user", text, pending: steering, id } as never);
|
||||
trajDispatch({ kind: "__user", text });
|
||||
try {
|
||||
if (steering) {
|
||||
// The row is already on screen; the receipt is what gives it a name
|
||||
// to be taken back by while it waits at the tool boundary.
|
||||
const queued = chips ? await port.queueFollowup(text, chips) : await port.steer(text);
|
||||
if (queued?.itemId) dispatch({ kind: "__queued", id, itemId: queued.itemId, queued: chips || queued.paused ? "followup" : "steer", paused: queued.paused } as never);
|
||||
} else {
|
||||
await submitOrQueue(text, id, chips);
|
||||
}
|
||||
return true;
|
||||
} catch (e) {
|
||||
dispatch({ kind: "__unsent", id } as never);
|
||||
fail(e);
|
||||
return false;
|
||||
}
|
||||
},
|
||||
[port, running, refreshStatus, fail],
|
||||
);
|
||||
|
||||
const { queue, restored, onQueueEdit, onQueueMove, onQueueRetry, onQueueRefresh, onQueuePause, onQueueRead, onQueueSendNow, onQueueCancel } = useQueueActions({
|
||||
const { queue, restored, onRestoreText, onQueueEdit, onQueueMove, onQueueRetry, onQueueRefresh, onQueuePause, onQueueRead, onQueueSendNow, onQueueCancel } = useQueueActions({
|
||||
port,
|
||||
dispatch,
|
||||
fail,
|
||||
moved: s.queueMoved,
|
||||
sessionState: sessionRead.kind,
|
||||
sessionPath: status?.sessionPath,
|
||||
});
|
||||
|
||||
// The kernel refuses a submit it cannot start with a code, not a sentence:
|
||||
// the words are fine, the timing is not. Queueing them is what that code
|
||||
// asks for, and showing anyone the refusal instead is how a race between
|
||||
// "the turn is done" on screen and the turn actually landing became an error
|
||||
// nobody could act on.
|
||||
const submitOrQueue = useCallback(
|
||||
async (text: string, id: string, chips?: ChipCall) => {
|
||||
try {
|
||||
const held = await port.submit(text, chips);
|
||||
if (held?.paused && held.itemId) dispatch({ kind: "__queued", id, itemId: held.itemId, queued: "followup", paused: true } as never);
|
||||
refreshStatus();
|
||||
} catch (e) {
|
||||
if (!(e instanceof HttpError) || e.reason?.code !== "busy.session_running") throw e;
|
||||
const queued = await port.queueFollowup(text, chips);
|
||||
if (queued?.itemId) dispatch({ kind: "__queued", id, itemId: queued.itemId, queued: "followup", paused: queued.paused } as never);
|
||||
}
|
||||
},
|
||||
[port, refreshStatus],
|
||||
);
|
||||
const { onPrepareRewind, onCommitRewind, onUndoRewind, onPrepareFileRevert, onCommitFileRevert } = useRewindActions(port, reloadSession, onRestoreText);
|
||||
|
||||
const { onApprove, onFullAccess, onPlan, onForget, onExtInvoke, onExtSubmit, onAnswer } = useGateActions({
|
||||
port,
|
||||
@@ -467,25 +329,6 @@ function PaneView({ port, rt, title, active, visible, sideHost, side, onFocus, o
|
||||
onRevise: () => setAskFocus((n) => n + 1),
|
||||
});
|
||||
|
||||
// Every route into a view goes through one place, so the menu never grows a
|
||||
// motion language of its own: it says which view, and this says how a pane
|
||||
// changes from one to another.
|
||||
// The workbench is the one view that sits beside the conversation rather than
|
||||
// over it, because it is read against what was said. Picking it from the nav
|
||||
// opens the same dock the globe does; there was no reason for one control to
|
||||
// take the conversation away and the other to keep it.
|
||||
const showView = useCallback(
|
||||
(to: PaneView) => {
|
||||
if (to === "browser") {
|
||||
onManualBrowser?.(true);
|
||||
swapping(() => setTab("flow"), "tab");
|
||||
return;
|
||||
}
|
||||
if (to === "flow") onManualBrowser?.(false);
|
||||
swapping(() => setTab(to), "tab");
|
||||
},
|
||||
[onManualBrowser],
|
||||
);
|
||||
const find = useFind(s.items, findPulse, active, useCallback(() => showView("flow"), [showView]));
|
||||
|
||||
const onRunDetail = useCallback(() => showView("analysis"), [showView]);
|
||||
@@ -502,8 +345,6 @@ function PaneView({ port, rt, title, active, visible, sideHost, side, onFocus, o
|
||||
const blocked = hasPendingDecision(status);
|
||||
const run = runState({ blocked, running, hasItems: s.items.length > 0, terminal: s.terminal });
|
||||
const cost = money(s.metrics.cost, s.metrics.currency);
|
||||
const cacheTokens = s.metrics.hit + s.metrics.miss;
|
||||
const cacheRate = cacheTokens > 0 ? Math.round((s.metrics.hit / cacheTokens) * 100) : null;
|
||||
const contextPercent = ctx && ctx.window > 0 ? Math.min(100, Math.round((ctx.used / ctx.window) * 100)) : null;
|
||||
const walletDisplay = wallet.kind === "read" ? wallet.reading.display : "";
|
||||
|
||||
@@ -549,7 +390,7 @@ function PaneView({ port, rt, title, active, visible, sideHost, side, onFocus, o
|
||||
|
||||
<PaneShown.Provider value={shown}>
|
||||
<LiveWork.Provider value={live}>
|
||||
<Transcript
|
||||
<Transcript port={port}
|
||||
reply={reply}
|
||||
onResend={onResend}
|
||||
items={s.items}
|
||||
@@ -635,40 +476,8 @@ function PaneView({ port, rt, title, active, visible, sideHost, side, onFocus, o
|
||||
</span>
|
||||
{/* Everything stacked above the input box shares one ceiling, so no
|
||||
child of this region may grow without bound. */}
|
||||
{todoOpen && (
|
||||
<div className="studio-todo" data-open={todoShown ? "" : undefined}>
|
||||
{/* One line by default. The composer already carries the run line,
|
||||
the outbox and its own toolbar; a plan opened over all of them
|
||||
pushes the box a person types in off the bottom of a laptop. */}
|
||||
<button
|
||||
type="button"
|
||||
className="studio-todo-sum"
|
||||
data-action="plan.fold"
|
||||
aria-expanded={todoShown}
|
||||
onClick={() => setTodoShown((on) => !on)}
|
||||
>
|
||||
<StudioIcon name="list" />
|
||||
<b>{t("计划")}</b>
|
||||
<span>{stepLabel(s.plan[currentStep(s.plan)] ?? s.plan[0])}</span>
|
||||
<small>{s.plan.filter(stepDone).length}/{s.plan.length}</small>
|
||||
<StudioIcon name="down" className="studio-todo-fold" />
|
||||
</button>
|
||||
{todoShown && <div className="studio-todo-body"><Plan steps={s.plan} /></div>}
|
||||
</div>
|
||||
)}
|
||||
{tab === "flow" && (
|
||||
<div
|
||||
className="studio-runstate"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
data-running={running && !blocked ? "" : undefined}
|
||||
data-waiting={blocked ? "" : undefined}
|
||||
data-idle={running || blocked ? undefined : ""}
|
||||
>
|
||||
<span className="studio-runlabel"><RMark /><span>{t(chipLabel(s, running))}</span></span>
|
||||
<RunTokens sent={sent} received={received} estimated={s.outLive > 0} />
|
||||
</div>
|
||||
)}
|
||||
<PlanFold plan={s.plan} shown={tab === "flow"} />
|
||||
{tab === "flow" && <RunLine label={chipLabel(s, running)} running={running} blocked={blocked} sent={sent} received={received} estimated={s.outLive > 0} />}
|
||||
<div className="composeaux">
|
||||
<Queue
|
||||
queue={queue}
|
||||
@@ -702,51 +511,11 @@ function PaneView({ port, rt, title, active, visible, sideHost, side, onFocus, o
|
||||
</div>
|
||||
{alert && <div className="cmpalert">{alert}</div>}
|
||||
<Composer port={port} status={status} running={running} quote={quote} restore={restored} focus={askFocus} onSubmit={submit} onChanged={refreshStatus} onError={fail} onSettings={onSettings} changeCount={tree?.repo ? tree.changes.length : 0} pulse={pulse} draftKey={draftKey(rt.host ?? "", rt.root, rt.sessionPath || status?.sessionPath || "")} />
|
||||
<div className="studio-meterrail" ref={meterRef} aria-label={t("运行统计")}>
|
||||
<div className="studio-speed-anchor">
|
||||
<button
|
||||
className="studio-meter-static studio-meter-speed"
|
||||
type="button"
|
||||
aria-describedby="studio-speed-detail"
|
||||
aria-label={t("查看生成速度详情")}
|
||||
>
|
||||
<Spark points={trail} w={44} h={13} />
|
||||
<b>{tps > 0 ? tps.toFixed(1) : "—"}</b><span>tok/s</span><i data-live={running ? "" : undefined} aria-hidden="true" />
|
||||
</button>
|
||||
<div className="studio-speed-detail" id="studio-speed-detail" role="tooltip">
|
||||
<header><b>{t("生成速度")}</b><small>{running ? t("实时更新") : t("最近一轮")}</small></header>
|
||||
<dl>
|
||||
<div><dt>{t("当前速度")}</dt><dd>{tps > 0 ? `${tps.toFixed(1)} tok/s` : "—"}</dd></div>
|
||||
<div><dt>{t("整轮平均")}</dt><dd>{speed.average > 0 ? `${speed.average.toFixed(1)} tok/s` : "—"}</dd></div>
|
||||
<div><dt>{t("本轮输出")}</dt><dd>{speed.output > 0 ? t("{n} tokens", { n: speed.output.toLocaleString() }) : "—"}</dd></div>
|
||||
<div><dt>{t("模型耗时")}</dt><dd>{speed.modelSeconds > 0 ? `${speed.modelSeconds.toFixed(1)}s` : "—"}</dd></div>
|
||||
</dl>
|
||||
<p>{t("当前速度按最近 4 秒流式文本估算;整轮平均使用服务商返回的输出 Token 除以模型回合耗时。")}</p>
|
||||
</div>
|
||||
</div>
|
||||
<span className="studio-meter-static studio-meter-cache" title={cacheRate === null ? t("尚无缓存数据") : t("命中 {hit} · 未命中 {miss}", { hit: s.metrics.hit.toLocaleString(), miss: s.metrics.miss.toLocaleString() })}>
|
||||
<span>{t("缓存")}</span><b>{cacheRate === null ? "—" : `${cacheRate}%`}</b>
|
||||
</span>
|
||||
{ctx && ctx.window > 0 && (
|
||||
<div className="studio-context-anchor" data-open={meterOpen ? "" : undefined}>
|
||||
<button className="studio-meter-context" data-action="metrics.details" data-value="context" aria-expanded={meterOpen} aria-haspopup="dialog" aria-label={t("查看上下文与压缩")} onClick={() => setMeterOpen((open) => !open)}>
|
||||
<span className="studio-context-ring" style={{ "--fill": `${Math.min(100, Math.round((ctx.used / ctx.window) * 100))}%` } as CSSProperties} aria-hidden="true" />
|
||||
<span>{t("上下文")}</span><b>{Math.min(100, Math.round((ctx.used / ctx.window) * 100))}%</b>
|
||||
</button>
|
||||
<ContextSummaryCard
|
||||
className="studio-context-summary"
|
||||
context={ctx}
|
||||
mcp={mcp}
|
||||
percent={Math.min(100, Math.round((ctx.used / ctx.window) * 100))}
|
||||
onManage={() => { closeMeter(); onSettings("ext"); }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{cost && <span className="studio-meter-cost"><span>{t("本轮")}</span><b>{hideAmounts ? MASK : cost}</b></span>}
|
||||
<button className="studio-meter-mask" type="button" data-action="metrics.hide-amounts" aria-pressed={hideAmounts} aria-label={hideAmounts ? t("显示金额") : t("隐藏金额")} title={hideAmounts ? t("显示金额") : t("隐藏金额")} onClick={() => setHidesAmounts(!hideAmounts)}><StudioIcon name={hideAmounts ? "eyeoff" : "eye"} /></button>
|
||||
{wallet.kind === "read" && <button className="studio-meter-wallet" data-short={wallet.reading.available ? undefined : "true"} title={wallet.reading.available ? undefined : t("余额不足")} data-action="settings.section" data-value="usage" aria-label={wallet.reading.available ? t("查看钱包余额") : `${t("查看钱包余额")}, ${t("余额不足")}`} onClick={() => onSettings("usage")}><StudioIcon name="wallet" /><b>{hideAmounts ? MASK : wallet.reading.display}</b></button>}
|
||||
<DeckChips tasks={rail.tasks} jobs={jobs} open={deck} onOpen={setDeck} onCancelJob={(id) => port.cancelJob(id).then(refreshStatus, fail)} />
|
||||
</div>
|
||||
<MeterRail
|
||||
tps={tps} trail={trail} running={running} speed={speed} metrics={s.metrics} ctx={ctx} mcp={mcp} cost={cost}
|
||||
wallet={wallet} hideAmounts={hideAmounts} tasks={rail.tasks} jobs={jobs} onSettings={onSettings}
|
||||
onCancelJob={(id) => port.cancelJob(id).then(refreshStatus, fail)}
|
||||
/>
|
||||
{/* Below the box, under a ceiling of their own. Both arrive unbidden and
|
||||
both are dismissed one at a time, so nothing else bounds how many can
|
||||
be on screen at once. */}
|
||||
|
||||
@@ -88,6 +88,22 @@ it("shows the account-gated Internet QR before the optional LAN pairing code", a
|
||||
expect(offerShare).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each([
|
||||
[undefined, "登录 Reasonix 账号后,可生成在外网也能使用的连接二维码。"],
|
||||
["signed_out", "登录 Reasonix 账号后,可生成在外网也能使用的连接二维码。"],
|
||||
["relay_unreachable", "中转服务暂时无法连接,请检查网络或代理设置后重试。"],
|
||||
["relay_refused", "中转服务拒绝了这台设备的连接,请稍后重试。"],
|
||||
] as const)("explains why Internet access is unavailable: %s", async (reason, note) => {
|
||||
const remote = { ...status(false), cloudRemote: { online: false, reason, error: "account: not signed in" } };
|
||||
const hub = { shareStatus: vi.fn(async () => remote), offerShare: vi.fn() } as unknown as HubPort;
|
||||
render(<PhonePop hub={hub} />);
|
||||
await userEvent.click(await screen.findByRole("button", { name: "设备访问" }));
|
||||
expect(await screen.findByText(note)).toBeTruthy();
|
||||
if (reason === "relay_unreachable" || reason === "relay_refused") {
|
||||
expect(screen.queryByText("登录 Reasonix 账号后,可生成在外网也能使用的连接二维码。")).toBeNull();
|
||||
}
|
||||
});
|
||||
|
||||
it("labels Internet controllers by their ordinal, not their list position", async () => {
|
||||
const now = new Date().toISOString();
|
||||
const cloud = (id: string, ordinal: number) => ({ id, name: "Web Studio", connectedAt: now, lastSeen: now, ordinal });
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { t } from "../i18n";
|
||||
import { reason } from "../i18n/kernel";
|
||||
import type { HubPort } from "../port/hub";
|
||||
import type { CloudDevice, PairedDevice } from "../port/share";
|
||||
import type { CloudDevice, CloudRemoteStatus, PairedDevice } from "../port/share";
|
||||
import { copyText } from "./CopyButton";
|
||||
import { useDismiss } from "./dismiss";
|
||||
import { clock, deviceLabel, type Share, useShare } from "./PhoneAccess";
|
||||
@@ -109,6 +109,20 @@ export function cloudPresenceNote(prev: CloudDevice[], next: CloudDevice[]): str
|
||||
return disconnected ? t("Web Studio 已断开") : "";
|
||||
}
|
||||
|
||||
function cloudRemoteNote(status: CloudRemoteStatus | undefined): string {
|
||||
if (status?.online) {
|
||||
return t("互联网连接需要登录同一账号,内容端到端加密;局域网直连只在可信网络中开启。");
|
||||
}
|
||||
switch (status?.reason) {
|
||||
case "relay_unreachable":
|
||||
return t("中转服务暂时无法连接,请检查网络或代理设置后重试。");
|
||||
case "relay_refused":
|
||||
return t("中转服务拒绝了这台设备的连接,请稍后重试。");
|
||||
default:
|
||||
return t("登录 Reasonix 账号后,可生成在外网也能使用的连接二维码。");
|
||||
}
|
||||
}
|
||||
|
||||
/** A line under the button when a phone comes or goes, for as long as it takes
|
||||
* to read. Not while the card is open: the list there already shows it. */
|
||||
function usePresenceNote(devices: PairedDevice[] | undefined, open: boolean): string {
|
||||
@@ -306,9 +320,7 @@ function PhoneCard({ share, failure }: { share: Share; failure: string }) {
|
||||
)}
|
||||
|
||||
<p className="pc-note">
|
||||
{st.cloudRemote?.online
|
||||
? t("互联网连接需要登录同一账号,内容端到端加密;局域网直连只在可信网络中开启。")
|
||||
: t("登录 Reasonix 账号后,可生成在外网也能使用的连接二维码。")}
|
||||
{cloudRemoteNote(st.cloudRemote)}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import { useState } from "react";
|
||||
import { t } from "../i18n";
|
||||
import { currentStep, stepDone, stepLabel, type PlanStep } from "../state/session";
|
||||
import { Plan } from "./Plan";
|
||||
import { StudioIcon } from "./StudioIcon";
|
||||
|
||||
/** The list belongs above the line that narrates the turn, because that is
|
||||
* what it is about. Mounted whether or not it is shown, so the fold survives a
|
||||
* visit to another view. */
|
||||
export function PlanFold({ plan, shown }: { plan: PlanStep[]; shown: boolean }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
if (plan.length === 0 || !shown) return null;
|
||||
return (
|
||||
<div className="studio-todo" data-open={open ? "" : undefined}>
|
||||
{/* One line by default. The composer already carries the run line,
|
||||
the outbox and its own toolbar; a plan opened over all of them
|
||||
pushes the box a person types in off the bottom of a laptop. */}
|
||||
<button
|
||||
type="button"
|
||||
className="studio-todo-sum"
|
||||
data-action="plan.fold"
|
||||
aria-expanded={open}
|
||||
onClick={() => setOpen((on) => !on)}
|
||||
>
|
||||
<StudioIcon name="list" />
|
||||
<b>{t("计划")}</b>
|
||||
<span>{stepLabel(plan[currentStep(plan)] ?? plan[0])}</span>
|
||||
<small>{plan.filter(stepDone).length}/{plan.length}</small>
|
||||
<StudioIcon name="down" className="studio-todo-fold" />
|
||||
</button>
|
||||
{open && <div className="studio-todo-body"><Plan steps={plan} /></div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -98,6 +98,7 @@ export function Queue({ queue, running, onRead, onEdit, onMove, onCancel, onSend
|
||||
// shut, because the row's own text is the only thing that may fill it.
|
||||
const [unread, setUnread] = useState<{ id: string; why: string } | null>(null);
|
||||
const box = useRef<HTMLTextAreaElement>(null);
|
||||
const readEpoch = useRef(0);
|
||||
|
||||
// The body arrives after the click, so focus waits for the field to exist.
|
||||
useEffect(() => {
|
||||
@@ -108,17 +109,20 @@ export function Queue({ queue, running, onRead, onEdit, onMove, onCancel, onSend
|
||||
async (id: string) => {
|
||||
// Opening on the preview would put a cut-off line in the box and save it
|
||||
// back as the whole instruction.
|
||||
const epoch = ++readEpoch.current;
|
||||
setUnread(null);
|
||||
let body: string;
|
||||
try {
|
||||
body = await onRead(id);
|
||||
} catch (e) {
|
||||
if (epoch !== readEpoch.current) return;
|
||||
// A read that failed is not an empty instruction. Filling the box with
|
||||
// "" would have the user retype a line they never saw, and the save
|
||||
// replaces the whole entry with it.
|
||||
setUnread({ id, why: reason(e) });
|
||||
return;
|
||||
}
|
||||
if (epoch !== readEpoch.current) return;
|
||||
setDraft(body);
|
||||
setEditing(id);
|
||||
},
|
||||
@@ -212,7 +216,10 @@ export function Queue({ queue, running, onRead, onEdit, onMove, onCancel, onSend
|
||||
onChange={(e) => setDraft(e.target.value)}
|
||||
onBlur={commit}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Escape") setEditing("");
|
||||
if (e.key === "Escape") {
|
||||
e.preventDefault();
|
||||
setEditing("");
|
||||
}
|
||||
// Enter commits; the line is one instruction, and a queue
|
||||
// row is not where a paragraph gets composed.
|
||||
if (e.key === "Enter" && !e.shiftKey && !touchKeyboard() && !e.nativeEvent.isComposing) {
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import { t } from "../i18n";
|
||||
import { RMark } from "./RMark";
|
||||
import { RunTokens } from "./RunTokens";
|
||||
|
||||
export function RunLine({ label, running, blocked, sent, received, estimated }: {
|
||||
label: string;
|
||||
running: boolean;
|
||||
blocked: boolean;
|
||||
sent: number;
|
||||
received: number;
|
||||
estimated: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className="studio-runstate"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
data-running={running && !blocked ? "" : undefined}
|
||||
data-waiting={blocked ? "" : undefined}
|
||||
data-idle={running || blocked ? undefined : ""}
|
||||
>
|
||||
<span className="studio-runlabel"><RMark /><span>{t(label)}</span></span>
|
||||
<RunTokens sent={sent} received={received} estimated={estimated} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -46,6 +46,13 @@ describe("what the runtime has to say about itself", () => {
|
||||
expect(screen.getByRole("status").textContent).toContain("recovered a forked session");
|
||||
});
|
||||
|
||||
it("words a recovered inbox from its count, not the payload", () => {
|
||||
render(<RuntimeBar notices={[notice({ code: "inbox_recovered", text: "Recovered 3 pending instruction(s).", detail: '{"count":3}' })]} onSeen={() => {}} />);
|
||||
const bar = screen.getByRole("status");
|
||||
expect(bar.textContent).toContain("已恢复 3 条未完成的指令");
|
||||
expect(bar.textContent).not.toContain("count");
|
||||
});
|
||||
|
||||
it("hands back the id of the one dismissed", async () => {
|
||||
const onSeen = vi.fn();
|
||||
render(<RuntimeBar notices={[notice(), notice({ id: "r2" })]} onSeen={onSeen} />);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { t } from "../i18n";
|
||||
import { NOTICE_TEXT } from "../i18n/notices";
|
||||
import { EXTENSION_SKIPPED, extensionSkippedVars } from "../i18n/extension_skipped";
|
||||
import { INBOX_RECOVERED, inboxRecoveredVars } from "../i18n/inbox_recovered";
|
||||
import type { RuntimeNotice } from "../state/session";
|
||||
import type { SessionState } from "../state/session_types";
|
||||
import { stallShown } from "../state/stall";
|
||||
@@ -24,11 +25,12 @@ export function RuntimeBar({ notices, onSeen, watch, onStall, onSettings }: {
|
||||
{stall && onStall && <StallBar stall={stall} onAct={onStall} />}
|
||||
{notices.map((n) => {
|
||||
const skipped = n.code === EXTENSION_SKIPPED ? extensionSkippedVars(n.detail) : undefined;
|
||||
const said = n.code && (n.code !== EXTENSION_SKIPPED || skipped) ? NOTICE_TEXT[n.code] : undefined;
|
||||
const recovered = n.code === INBOX_RECOVERED ? inboxRecoveredVars(n.detail) : undefined;
|
||||
const said = n.code && (n.code !== EXTENSION_SKIPPED || skipped) && (n.code !== INBOX_RECOVERED || recovered) ? NOTICE_TEXT[n.code] : undefined;
|
||||
return (
|
||||
<div key={n.id} className="rtbar" data-lvl={n.level} role="status">
|
||||
<span className="t">{said ? t(said, skipped) : n.text}</span>
|
||||
{n.detail && !skipped && n.code !== EXTENSION_SKIPPED && <span className="why">{n.detail}</span>}
|
||||
<span className="t">{said ? t(said, skipped ?? recovered) : n.text}</span>
|
||||
{n.detail && !skipped && !recovered && n.code !== EXTENSION_SKIPPED && n.code !== INBOX_RECOVERED && <span className="why">{n.detail}</span>}
|
||||
{skipped && onSettings && (
|
||||
<button data-action="settings.section" data-value="ext" onClick={() => onSettings("ext")}>{t("打开「工具与集成」")}</button>
|
||||
)}
|
||||
|
||||
@@ -21,52 +21,45 @@ export function useRuntimeReload(port: AgentPort, onDone: () => void) {
|
||||
const currentConnection = useRef(connection);
|
||||
currentConnection.current = connection;
|
||||
const attempts = useRef(0);
|
||||
const [state, setState] = useState<State>("");
|
||||
const [note, setNote] = useState("");
|
||||
const [feedback, setFeedback] = useState<{ state: State; note: string }>({ state: "", note: "" });
|
||||
const { state, note } = feedback;
|
||||
if (connection.port !== port) {
|
||||
setConnection({ port });
|
||||
setState("");
|
||||
setNote("");
|
||||
setFeedback({ state: "", note: "" });
|
||||
}
|
||||
|
||||
const go = useCallback(async () => {
|
||||
const attempt = ++attempts.current;
|
||||
setState("run");
|
||||
setNote(t("正在重启常驻进程,重新扫描技能、命令和钩子…"));
|
||||
setFeedback({ state: "run", note: t("正在重启常驻进程,重新扫描技能、命令和钩子…") });
|
||||
try {
|
||||
await port.reloadExtensions();
|
||||
if (currentConnection.current !== connection || attempts.current !== attempt) return;
|
||||
setState("ok");
|
||||
setNote(t("已生效,下一轮开始用新的扩展"));
|
||||
setFeedback({ state: "ok", note: t("已生效,下一轮开始用新的扩展") });
|
||||
onDone();
|
||||
} catch (e) {
|
||||
if (currentConnection.current !== connection || attempts.current !== attempt) return;
|
||||
// A refusal is the kernel's, and it knows why: a turn in flight, a
|
||||
// background job, a session that moved. Say its reason, not a generic one.
|
||||
setState("bad");
|
||||
setNote(reason(e));
|
||||
setFeedback({ state: "bad", note: reason(e) });
|
||||
}
|
||||
}, [port, onDone, connection]);
|
||||
|
||||
useEffect(() => {
|
||||
if (state !== "ok") return;
|
||||
if (feedback.state !== "ok") return;
|
||||
const id = setTimeout(() => {
|
||||
setState("");
|
||||
setNote("");
|
||||
setFeedback({ state: "", note: "" });
|
||||
}, SETTLED_MS);
|
||||
return () => clearTimeout(id);
|
||||
}, [state]);
|
||||
}, [feedback]);
|
||||
|
||||
return {
|
||||
applied: () => {
|
||||
attempts.current++;
|
||||
setState("ok");
|
||||
setNote(t("已生效,下一轮开始用新的扩展"));
|
||||
setFeedback({ state: "ok", note: t("已生效,下一轮开始用新的扩展") });
|
||||
},
|
||||
report: (message: string) => {
|
||||
attempts.current++;
|
||||
setState("bad");
|
||||
setNote(t("更改已保存,运行时未重载:{reason}。请用「重载运行时」重试。", { reason: message }));
|
||||
setFeedback({ state: "bad", note: t("更改已保存,运行时未重载:{reason}。请用「重载运行时」重试。", { reason: message }) });
|
||||
},
|
||||
action: (
|
||||
<button className="act reload" data-action="extensions.reload" data-s={state || undefined} disabled={state === "run"} onClick={go}>
|
||||
|
||||
@@ -22,6 +22,12 @@ const MCP_STATE: Record<string, string> = {
|
||||
pending: "待授权 · 来自项目声明",
|
||||
};
|
||||
|
||||
// Why a pending server waits, keyed by the kernel's code. The state label
|
||||
// already says a project declared it; a code with no entry adds nothing.
|
||||
const PENDING_WHY: Record<string, string> = {
|
||||
changed_since_enabled: "启用之后它要启动的内容变了,或是由旧版本启用的。确认下面的命令后重新启用。",
|
||||
};
|
||||
|
||||
// A tag only when the schema carries the server's tools or config asks it to:
|
||||
// deferred is the default, and a badge on every row is a badge on none.
|
||||
function loadTag(m: McpEntry): { text: string; pending: boolean } | null {
|
||||
@@ -173,12 +179,21 @@ export function ServerRow({
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
const why = failed || m.error;
|
||||
// Approving a repository's server approves what it runs, so the line it
|
||||
// launches sits beside the switch that approves it.
|
||||
const launch = m.state === "pending" && !!m.launch && (
|
||||
<div className="srv-ab" data-launch>
|
||||
<span className="ds">{t("将启动")} · <code>{m.launch}</code></span>
|
||||
</div>
|
||||
);
|
||||
const pendingWhy = m.state === "pending" && m.pendingReason ? PENDING_WHY[m.pendingReason] : undefined;
|
||||
const why = failed || (pendingWhy ? t(pendingWhy) : m.error);
|
||||
if (!tools.length) {
|
||||
return (
|
||||
<div className="srv mcp-server" data-st={m.state} data-local={m.localOverride ? "" : undefined} aria-busy={!!busy}>
|
||||
<div className="srv-hd">{head}</div>
|
||||
{about}
|
||||
{launch}
|
||||
{why && <div className="why">{why}</div>}
|
||||
{confirm}
|
||||
</div>
|
||||
@@ -190,6 +205,7 @@ export function ServerRow({
|
||||
<details className="srv mcp-server" data-st={m.state} data-local={m.localOverride ? "" : undefined} aria-busy={!!busy} open={confirming || undefined}>
|
||||
<summary>{head}</summary>
|
||||
{about}
|
||||
{launch}
|
||||
{why && <div className="why">{why}</div>}
|
||||
{confirm}
|
||||
{load}
|
||||
|
||||
@@ -162,20 +162,21 @@ export function Settings({ hub, onError, port, networkPort, networkHost, status,
|
||||
.catch((e) => { if (current()) { setSkills([]); setExtErrors((errors) => ({ ...errors, skills: reason(e) })); } });
|
||||
void Promise.allSettled([mcpRead, packageRead, skillRead]).then(() => { if (current()) setExtRefreshing(false); });
|
||||
}, [port, scopeAt]);
|
||||
const currentExt = useRef({ port, reloadExt, onChanged });
|
||||
currentExt.current = { port, reloadExt, onChanged };
|
||||
const afterExtChange = useCallback(() => {
|
||||
if (currentExt.current.port !== port) return;
|
||||
currentExt.current.reloadExt();
|
||||
currentExt.current.onChanged();
|
||||
}, [port]);
|
||||
const reload = useRuntimeReload(port, afterExtChange);
|
||||
|
||||
// Adding or removing a source changes what the picker above can offer, so
|
||||
// the list is reloadable rather than read once at mount.
|
||||
const homePort = networkPort ?? port;
|
||||
const loadModels = useModelCatalog(port, homePort, setModels);
|
||||
|
||||
const currentExt = useRef({ port, reloadExt, loadModels, onChanged });
|
||||
currentExt.current = { port, reloadExt, loadModels, onChanged };
|
||||
const afterExtChange = useCallback(() => {
|
||||
if (currentExt.current.port !== port) return;
|
||||
currentExt.current.reloadExt();
|
||||
currentExt.current.loadModels();
|
||||
currentExt.current.onChanged();
|
||||
}, [port]);
|
||||
const reload = useRuntimeReload(port, afterExtChange);
|
||||
|
||||
// Three sections whose row used to report nothing. Loaded here rather than in
|
||||
// reloadExt because none of them moves with the scope the extension lists are
|
||||
// read at, and dropped on failure: a table of contents that says "—" where a
|
||||
|
||||
@@ -1,798 +0,0 @@
|
||||
import { Fragment, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { t } from "../i18n";
|
||||
import { listenAction } from "./listen";
|
||||
import { useRuntimeReload } from "./RuntimeReload";
|
||||
import { HttpError } from "../port/port";
|
||||
import type { AccountState, AgentPort, Appearance as Look, CapabilityScope, McpEntry, ModelEntry, PluginPackage, SessionStatus, SkillEntry } from "../port/port";
|
||||
import { arrowTabs } from "./tablist";
|
||||
import { bytes, tokens as fmtTokens } from "../i18n/format";
|
||||
import { ICON, NAV, SECTION_NAME, SETTINGS, settingMatches } from "./prefsnav";
|
||||
import { Group, SCOPE_SAID } from "./Group";
|
||||
import type { Section } from "./prefsnav";
|
||||
import { AddServer } from "./AddServer";
|
||||
import { PhoneAccess } from "./PhoneAccess";
|
||||
import { Remotes } from "./Remotes";
|
||||
import type { HubPort } from "../port/hub";
|
||||
import type { RemoteHost } from "../port/remote";
|
||||
import { AddPlugin } from "./AddPlugin";
|
||||
import { Packages } from "./Packages";
|
||||
import { ExtTabs, MarketGroup } from "./Market";
|
||||
import { InstalledLocation } from "./InstalledLocation";
|
||||
import { Switch } from "./Switch";
|
||||
import { ServerRow } from "./ServerRow";
|
||||
import { SkillRow } from "./SkillRow";
|
||||
import { Hooks } from "./Hooks";
|
||||
import { Network } from "./Network";
|
||||
import { Shell as ShellPicker } from "./Shell";
|
||||
import { Rules } from "./Rules";
|
||||
import { ConfigTrouble } from "./ConfigTrouble";
|
||||
import { Compaction } from "./Compaction";
|
||||
import { Sandbox } from "./Sandbox";
|
||||
import { BrowserTools } from "./BrowserTools";
|
||||
import { ProgressWatch } from "./ProgressWatch";
|
||||
import { Account } from "./Account";
|
||||
import { Backup } from "./Backup";
|
||||
import { Providers } from "./Providers";
|
||||
import { activeKind, groupVendors } from "./Models";
|
||||
import { ModelUsage } from "./ModelUsage";
|
||||
<<<<<<< HEAD
|
||||
import { useModelCatalog } from "./useModelCatalog";
|
||||
=======
|
||||
import { useRoles } from "./useRoles";
|
||||
>>>>>>> 1c1231892 (fix(settings): say when a subagent_models entry outranks the subagent model)
|
||||
import { KIND_LABEL } from "./vendors";
|
||||
import { planProtocolSwitch } from "./protocolswitch";
|
||||
import { Boundary } from "./Boundary";
|
||||
import { Path } from "./Path";
|
||||
import { About } from "./About";
|
||||
import { Memory } from "./Memory";
|
||||
import { DEFAULT_DAYS } from "./Usage";
|
||||
import { UsageSettings } from "./UsageSettings";
|
||||
import { Storage } from "./Storage";
|
||||
import { Appearance, SCHEMES } from "./Appearance";
|
||||
import { ScopeBar } from "./CapabilityScope";
|
||||
import { SAVED_NOT_APPLIED, reason } from "../i18n/kernel";
|
||||
import { SettingsHeading } from "./SettingsHeading";
|
||||
import { APPROVALS, approvalName, approvalNote } from "./approvals";
|
||||
|
||||
// What still lives in the old desktop app. Bots are not on the roadmap, so
|
||||
// they are not a promise to keep here either. Signing in and reading
|
||||
// versions landed here; downloading and applying an update did not, and naming
|
||||
// only that half keeps this list a fact rather than a promise.
|
||||
// Everything that used to live here has a home now, so the 「高级」 tab filters
|
||||
// itself out below. Keep the list rather than the tab: the next thing that is
|
||||
// real but not built yet belongs in one line here, not in a half-made panel.
|
||||
const ELSEWHERE: string[] = [];
|
||||
|
||||
// The user's question is "is it there and does it work", so the state is the
|
||||
// label. A failed server keeps its error on the row that names it.
|
||||
const NET_MODE: Record<string, string> = { auto: "跟随系统", env: "环境变量", custom: "手动", off: "直连" };
|
||||
|
||||
interface Props {
|
||||
// The window's own port, not a pane's: remote machines belong to the window,
|
||||
// and their endpoints sit above every pane's prefix.
|
||||
hub: HubPort;
|
||||
onError: (e: unknown) => void;
|
||||
port: AgentPort;
|
||||
// Network belongs to a machine rather than a pane; host names it when that
|
||||
// machine is not this one.
|
||||
networkPort?: AgentPort;
|
||||
networkHost?: string;
|
||||
status: SessionStatus | null;
|
||||
theme: string;
|
||||
reloadThemes: () => void;
|
||||
onTheme: (t: string) => void;
|
||||
contrast: string;
|
||||
weight: string;
|
||||
onWeight: (v: string) => void;
|
||||
look: Look;
|
||||
onLook: (look: Look) => void;
|
||||
onContrast: (c: string) => void;
|
||||
onClose: () => void;
|
||||
onChanged: () => void;
|
||||
onSessionsRecovered?: () => void;
|
||||
at?: string;
|
||||
account: AccountState | null;
|
||||
accountUnread: string;
|
||||
reloadAccount: () => void;
|
||||
workspaceRoot?: string;
|
||||
}
|
||||
export function Settings({ hub, onError, port, networkPort, networkHost, status, theme, onTheme, contrast, onContrast, weight, onWeight, look, onLook, onClose, onChanged, onSessionsRecovered, reloadThemes, at: opened, account: acct, accountUnread, reloadAccount, workspaceRoot }: Props) {
|
||||
const [openedSection, openedAnchor = ""] = (opened ?? "").split(":", 2);
|
||||
const [at, setAt] = useState<Section>((openedSection as Section) || "session");
|
||||
// 搜索的语料是这张表,不是屏幕上的 DOM:分区是按需挂载的,读 DOM 就只搜得到
|
||||
// 当前这一页,而为了搜索把每一页都挂起来会让隐藏页面开始发请求。
|
||||
const [query, setQuery] = useState("");
|
||||
const [landed, setLanded] = useState(openedAnchor);
|
||||
// Opened from the composer's effort menu: the running source's editor opens
|
||||
// on its reasoning fields. The model ref's first segment is the source name.
|
||||
const declare = openedAnchor === "effort-declare" ? status?.modelRef?.split("/")[0] : undefined;
|
||||
const [models, setModels] = useState<ModelEntry[]>([]);
|
||||
const { roles, overrides, loadRoles } = useRoles(port);
|
||||
const [protocol, setProtocol] = useState<Record<string, string>>({});
|
||||
const [mcp, setMcp] = useState<McpEntry[]>([]);
|
||||
const [scope, setScope] = useState<CapabilityScope | null>(null);
|
||||
const [scopes, setScopes] = useState<CapabilityScope[]>([]);
|
||||
// Which project the extension tab is answering for. Empty is the running one,
|
||||
// so the common case stays exactly what it was.
|
||||
const [scopeAt, setScopeAt] = useState("");
|
||||
const [skills, setSkills] = useState<SkillEntry[]>([]);
|
||||
const [implicit, setImplicit] = useState(true);
|
||||
const [live, setLive] = useState(true);
|
||||
const [busy, setBusy] = useState("");
|
||||
const [note, setNote] = useState<{ text: string; unapplied: boolean } | null>(null);
|
||||
const setFailed = useCallback((text: string) => setNote(text ? { text, unapplied: false } : null), []);
|
||||
const [adding, setAdding] = useState(false);
|
||||
const [remoteBook, setRemoteBook] = useState<RemoteHost[] | null>(null);
|
||||
const [packages, setPackages] = useState<PluginPackage[]>([]);
|
||||
const [addingPkg, setAddingPkg] = useState(false);
|
||||
const [extTab, setExtTab] = useState<"installed" | "market">(openedAnchor === "market" ? "market" : "installed");
|
||||
const [installedTarget, setInstalledTarget] = useState<{ kind: string; name: string } | null>(null);
|
||||
const [extRefreshing, setExtRefreshing] = useState(false);
|
||||
const [extErrors, setExtErrors] = useState<Partial<Record<"mcp" | "packages" | "skills", string>>>({});
|
||||
const extRefresh = useRef(0);
|
||||
const [updatingPkg, setUpdatingPkg] = useState({ name: "", applying: false });
|
||||
const applyingChanged = useCallback((applying: boolean) => setUpdatingPkg((p) => ({ ...p, applying })), []);
|
||||
const [hookCount, setHookCount] = useState(0);
|
||||
const [netMode, setNetMode] = useState("");
|
||||
const [memCount, setMemCount] = useState(0);
|
||||
const [ruleCount, setRuleCount] = useState(0);
|
||||
const root = useRef<HTMLDivElement>(null);
|
||||
const veiled = useRef(false);
|
||||
const reloadExt = useCallback(() => {
|
||||
const refresh = ++extRefresh.current;
|
||||
const current = () => extRefresh.current === refresh;
|
||||
setExtRefreshing(true);
|
||||
setExtErrors({});
|
||||
const where = scopeAt || undefined;
|
||||
port.capabilityScopes().then((c) => { if (current()) setScopes(c); }).catch(() => { if (current()) setScopes([]); });
|
||||
const mcpRead = port.mcp(where).then((c) => {
|
||||
if (!current()) return;
|
||||
setMcp(c.servers);
|
||||
setScope(c.scope);
|
||||
setLive(c.live !== false);
|
||||
}).catch((e) => { if (current()) { setMcp([]); setExtErrors((errors) => ({ ...errors, mcp: reason(e) })); } });
|
||||
const packageRead = port.plugins().then((c) => { if (current()) setPackages(c); }).catch((e) => { if (current()) { setPackages([]); setExtErrors((errors) => ({ ...errors, packages: reason(e) })); } });
|
||||
port.hooks().then((c) => { if (current()) setHookCount(c.hooks.length); }).catch(() => { if (current()) setHookCount(0); });
|
||||
port.network().then((n) => { if (current()) setNetMode(t(NET_MODE[n.mode] ?? n.mode)); }).catch(() => { if (current()) setNetMode(""); });
|
||||
port.memories().then((c) => { if (current()) setMemCount(c.memories.length); }).catch(() => { if (current()) setMemCount(0); });
|
||||
port.permissions().then((p) => { if (current()) setRuleCount(p.deny.length + p.ask.length + p.allow.length); }).catch(() => { if (current()) setRuleCount(0); });
|
||||
const skillRead = port.skills(where).then((c) => {
|
||||
if (!current()) return;
|
||||
setSkills(c.skills);
|
||||
setImplicit(c.implicit);
|
||||
})
|
||||
.catch((e) => { if (current()) { setSkills([]); setExtErrors((errors) => ({ ...errors, skills: reason(e) })); } });
|
||||
void Promise.allSettled([mcpRead, packageRead, skillRead]).then(() => { if (current()) setExtRefreshing(false); });
|
||||
}, [port, scopeAt]);
|
||||
const currentExt = useRef({ port, reloadExt, onChanged });
|
||||
currentExt.current = { port, reloadExt, onChanged };
|
||||
const afterExtChange = useCallback(() => {
|
||||
if (currentExt.current.port !== port) return;
|
||||
currentExt.current.reloadExt();
|
||||
currentExt.current.onChanged();
|
||||
}, [port]);
|
||||
const reload = useRuntimeReload(port, afterExtChange);
|
||||
|
||||
// Adding or removing a source changes what the picker above can offer, so
|
||||
// the list is reloadable rather than read once at mount.
|
||||
const homePort = networkPort ?? port;
|
||||
const loadModels = useModelCatalog(port, homePort, setModels);
|
||||
|
||||
// Three sections whose row used to report nothing. Loaded here rather than in
|
||||
// reloadExt because none of them moves with the scope the extension lists are
|
||||
// read at, and dropped on failure: a table of contents that says "—" where a
|
||||
// number failed to arrive has invented a fact about the section.
|
||||
const [tally, setTally] = useState<Partial<Record<Section, string>>>({});
|
||||
useEffect(() => {
|
||||
const put = (k: Section, v: string) => setTally((prev) => ({ ...prev, [k]: v }));
|
||||
port.storage().then((st) => put("storage", bytes(st.roots.reduce((n, r) => n + (r.bytes || 0), 0)))).catch(() => {});
|
||||
// The same window Usage itself opens on, or the list and the page it opens
|
||||
// would report two different totals for one word.
|
||||
port.usage(DEFAULT_DAYS).then((u) => put("usage", fmtTokens(u.tokens))).catch(() => {});
|
||||
port.versions().then((v) => put("versions", v.current || "dev")).catch(() => {});
|
||||
}, [port]);
|
||||
|
||||
// Focus lands once, when the pane opens, and goes back where it came from on
|
||||
// the way out. onClose is a fresh arrow on every parent render, so re-running
|
||||
// this with it would pull focus out of whatever the user is holding — a
|
||||
// native select closes its dropdown the instant it is blurred, which reads as
|
||||
// the menu refusing to open. Closing used to leave focus on <body>, which
|
||||
// left a reader on the keyboard with no position at all.
|
||||
useEffect(() => {
|
||||
const from = document.activeElement as HTMLElement | null;
|
||||
root.current?.focus();
|
||||
return () => {
|
||||
if (from?.isConnected) from.focus();
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const onKey = (e: Event) => (e as KeyboardEvent).key === "Escape" && !updatingPkg.applying && onClose();
|
||||
return listenAction(window, "keydown", { action: "settings.close", listener: onKey });
|
||||
}, [onClose, updatingPkg.applying]);
|
||||
|
||||
useEffect(() => {
|
||||
loadModels();
|
||||
loadRoles();
|
||||
reloadExt();
|
||||
}, [loadModels, loadRoles, reloadExt]);
|
||||
|
||||
// Read once for the tab's own count. Remotes keeps its own copy: it also
|
||||
// needs the ssh_config candidates, which a tab label has no use for.
|
||||
useEffect(() => {
|
||||
hub
|
||||
.remoteHosts()
|
||||
.then(setRemoteBook)
|
||||
.catch(() => setRemoteBook(null));
|
||||
}, [hub]);
|
||||
|
||||
// A refused switch has to say so. The kernel turns one down while a turn or a
|
||||
// background job is running, and swallowing that leaves the click looking like
|
||||
// the row simply does not work.
|
||||
const run = async (what: string, fn: () => Promise<void>) => {
|
||||
setBusy(what);
|
||||
setNote(null);
|
||||
try {
|
||||
await fn();
|
||||
onChanged();
|
||||
} catch (e) {
|
||||
setNote({ text: reason(e), unapplied: e instanceof HttpError && SAVED_NOT_APPLIED.includes(e.reason?.code ?? "") });
|
||||
} finally {
|
||||
setBusy("");
|
||||
}
|
||||
};
|
||||
|
||||
// The levels the selected model's endpoint actually accepts. A fixed list
|
||||
// here offered every model six of them and let the user set depths the
|
||||
// provider then ignored or rejected.
|
||||
const vendors = groupVendors(models);
|
||||
const kindFor = (key: string) => {
|
||||
const v = vendors.find((x) => x.key === key);
|
||||
return v ? activeKind(v, status?.modelRef) : "";
|
||||
};
|
||||
const switchProtocol = (key: string, kind: string) => {
|
||||
const plan = planProtocolSwitch(vendors.find((x) => x.key === key), models, status?.modelRef, kind);
|
||||
if (plan.do === "stay") {
|
||||
setFailed(t("{door} 不提供 {model},请在下方选择该来源支持的模型。", {
|
||||
door: t(KIND_LABEL[kind] ?? kind),
|
||||
model: plan.model,
|
||||
}));
|
||||
return;
|
||||
}
|
||||
setProtocol((p) => ({ ...p, [key]: kind }));
|
||||
if (plan.do === "switch") run(plan.ref, () => port.setModel(plan.ref));
|
||||
};
|
||||
const efforts = models.find((m) => m.ref === status?.modelRef)?.efforts ?? [];
|
||||
const preset = t("自动");
|
||||
const approval = approvalName(status?.toolApprovalMode, "—");
|
||||
const broken = mcp.filter((m) => m.state === "failed").length;
|
||||
// A package owns what it brought, and its own row already lists it. What is
|
||||
// left is what the user added by hand, which is the only thing these two
|
||||
// lists can act on without contradicting the package above them. Ownership
|
||||
// is read off the packages themselves: a live server reports the config
|
||||
// layer it was merged from, which is not the same question.
|
||||
const owned = new Set(packages.flatMap((p) => p.mcpServers?.map((s) => s.name) ?? []));
|
||||
const looseMcp = mcp.filter((m) => !owned.has(m.name));
|
||||
const looseSkills = skills.filter((s) => !s.plugin);
|
||||
const looseOn = looseSkills.filter((s) => s.enabled).length;
|
||||
// Null means this kernel does not do remote panes at all, which is what
|
||||
// takes the whole tab out rather than showing an empty one.
|
||||
const online = (remoteBook ?? []).filter((h) => h.status === "connected" || h.status === "degraded").length;
|
||||
const remoteTally = remoteBook === null ? "" : online ? t("{n} 台在线", { n: online }) : remoteBook.length ? `${remoteBook.length}` : "";
|
||||
// The table of contents is also the status board: the value that matters for
|
||||
// each section rides on its own row, so the risky one is legible from here.
|
||||
const nav: Record<Section, string> = {
|
||||
session: status?.plan ? t("计划模式") : preset,
|
||||
model: status?.modelRef?.split("/").pop() ?? "—",
|
||||
providers: models.length ? t("{n} 个服务", { n: groupVendors(models).length }) : "",
|
||||
tools: ruleCount ? `${approval} · ${ruleCount}` : approval,
|
||||
hooks: hookCount ? t("{n} 条", { n: hookCount }) : t("关"),
|
||||
ext: broken ? t("{n} 个异常", { n: broken }) : packages.length ? t("{n} 个包", { n: packages.length }) : `${looseMcp.length + looseOn}`,
|
||||
network: netMode,
|
||||
remote: remoteTally,
|
||||
memory: memCount ? t("{n} 条", { n: memCount }) : "",
|
||||
usage: tally.usage ?? "",
|
||||
storage: tally.storage ?? "",
|
||||
account: acct === null ? "" : acct.signedIn ? (acct.user?.label ?? t("已登录")) : t("未登录"),
|
||||
versions: tally.versions ?? "",
|
||||
appearance: t(SCHEMES.find(([id]) => id === theme)?.[1] ?? ""),
|
||||
advanced: ELSEWHERE.length ? `${ELSEWHERE.length}` : "",
|
||||
};
|
||||
const danger = (id: Section) =>
|
||||
(id === "tools" && status?.toolApprovalMode === "yolo") || (id === "ext" && broken > 0);
|
||||
// Null remote means this kernel does not do remote panes; advanced holds
|
||||
// whatever has moved out of the config file and has not moved in yet.
|
||||
const shown = (id: Section) =>
|
||||
(id !== "remote" || remoteBook !== null) && (id !== "advanced" || ELSEWHERE.length > 0);
|
||||
|
||||
// 搜索命中的是这张表里的条目,包括当前没有挂载的那些分区;能不能显示由 shown
|
||||
// 决定,和它有没有被渲染过无关。
|
||||
const found = useMemo(() => {
|
||||
const q = query.trim().toLowerCase();
|
||||
if (!q) return null;
|
||||
return SETTINGS.filter((e) => shown(e.section) && settingMatches(e, q));
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [query, remoteBook]);
|
||||
// 落点:跳过去之后短暂标一下「就是这里」。这是导航反馈,不是状态变化。
|
||||
const go = (section: Section, anchor: string) => {
|
||||
setAt(section);
|
||||
setQuery("");
|
||||
setLanded(anchor);
|
||||
if (section === "ext") setExtTab(anchor === "market" ? "market" : "installed");
|
||||
};
|
||||
useEffect(() => {
|
||||
if (!landed) return;
|
||||
const el = root.current?.querySelector<HTMLElement>(`#set-${CSS.escape(landed)}`);
|
||||
// 折叠起来的块在 DOM 里,但不在屏幕上 —— 滚过去会停在一片空白上。要开哪一
|
||||
// 个由结构回答:不需要再维护一张「哪些设置是高级」的表,也不需要把落点穿到
|
||||
// 每个页面里去。
|
||||
for (let d = el?.closest("details"); d; d = d.parentElement?.closest("details") ?? null) d.open = true;
|
||||
const behavior = matchMedia("(prefers-reduced-motion: reduce)").matches ? "auto" : "smooth";
|
||||
const aim = () => el?.scrollIntoView({ block: "start", behavior });
|
||||
aim();
|
||||
// The page is still growing under the scroll: theme packs and the font list
|
||||
// arrive late and push the anchor below a target already locked in. A jump
|
||||
// owns its landing until it lapses, so re-aim for as long as it is owed.
|
||||
const grew = new ResizeObserver(aim);
|
||||
const col = root.current?.querySelector(".prefs-col");
|
||||
if (col) grew.observe(col);
|
||||
// 「就是这里」是一次导航反馈,不是这个设置变了 —— 所以它只是元素上短暂的
|
||||
// 一个标记,不进 React 状态,也不用往每个块穿一个纯表现的 prop。
|
||||
el?.setAttribute("data-landed", "");
|
||||
const done = setTimeout(() => setLanded(""), 900);
|
||||
return () => {
|
||||
clearTimeout(done);
|
||||
grew.disconnect();
|
||||
el?.removeAttribute("data-landed");
|
||||
};
|
||||
}, [landed, at]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="prefs"
|
||||
ref={root}
|
||||
tabIndex={-1}
|
||||
onMouseDown={(e) => { veiled.current = e.target === e.currentTarget; }}
|
||||
// Both ends of the press have to land on the veil. A drag that started
|
||||
// inside the sheet and let go outside is a selection, not a dismissal.
|
||||
onMouseUp={(e) => { if (veiled.current && e.target === e.currentTarget && !updatingPkg.applying) onClose(); }}
|
||||
data-action-mouseup="settings.close"
|
||||
>
|
||||
<div className="prefs-sheet" role="dialog" aria-modal="true" aria-labelledby="prefs-title">
|
||||
<div className="prefs-hd">
|
||||
<h1 id="prefs-title">{t("设置")}</h1>
|
||||
{/* 原来这里挂着一句「改动立刻生效;需要重建运行时的……」。它对三档里的
|
||||
两档说了话,而且要读的人自己判断手上这一项属于哪一档 —— 那句话现在
|
||||
落在每一个设置块自己身上。 */}
|
||||
<input
|
||||
className="prefs-find"
|
||||
value={query}
|
||||
// Typing filters and Escape takes the filter back: two events on one
|
||||
// box, both operating the same one thing.
|
||||
data-action-change="settings.search"
|
||||
data-action-keydown="settings.search"
|
||||
placeholder={t("搜索设置…")}
|
||||
aria-label={t("搜索设置")}
|
||||
spellCheck={false}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
// 一层一层退:有搜索就先退搜索,没有才退整个设置。
|
||||
if (e.key === "Escape" && query) {
|
||||
e.stopPropagation();
|
||||
setQuery("");
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<button className="btn sm" data-action="settings.close" disabled={updatingPkg.applying} onClick={onClose}>
|
||||
{t("关闭")} <span className="esc">Esc</span>
|
||||
</button>
|
||||
{found !== null && (
|
||||
<div className="prefs-found" role="listbox" aria-label={t("搜索设置")}>
|
||||
{found.length === 0 ? (
|
||||
<p className="none">{t("没有匹配的设置")}</p>
|
||||
) : (
|
||||
found.map((e) => (
|
||||
<button
|
||||
key={e.anchor}
|
||||
role="option"
|
||||
aria-selected={false} disabled={updatingPkg.applying}
|
||||
data-action="settings.section"
|
||||
data-value={e.section}
|
||||
data-target={e.anchor}
|
||||
onClick={() => go(e.section, e.anchor)}
|
||||
>
|
||||
<span className="where">{t(SECTION_NAME[e.section] ?? e.section)}</span>
|
||||
<span className="what">{t(e.title)}</span>
|
||||
{/* Search is where a setting is met with no page around it,
|
||||
so it is where ownership is worth most: "代理" turning up
|
||||
under 网络 says nothing about whether it is this project's
|
||||
proxy or this machine's. Same catalogue as the block. */}
|
||||
<span className="cost">
|
||||
{[
|
||||
t(SCOPE_SAID[e.scope]),
|
||||
e.apply === "runtime-rebuild" ? t("重建运行时") : e.apply === "restart" ? t("重启后生效") : "",
|
||||
].filter(Boolean).join(" · ")}
|
||||
</span>
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<ConfigTrouble port={port} onRepaired={onChanged} />
|
||||
|
||||
<div className="prefs-body">
|
||||
<nav className="prefs-nav" role="tablist" aria-label={t("设置分类")} onKeyDown={arrowTabs}>
|
||||
{NAV.map(([group, items]) => {
|
||||
const rows = items.filter(([id]) => shown(id));
|
||||
// A heading over nothing is worse than no heading: remote is absent
|
||||
// on a kernel without it, advanced until something moves in.
|
||||
if (rows.length === 0) return null;
|
||||
return (
|
||||
<Fragment key={group}>
|
||||
<div className="navsec">{t(group)}</div>
|
||||
{/* One tab stop for the list, arrows move inside it: twelve
|
||||
stops made Tab the wrong way through this screen. */}
|
||||
{rows.map(([id, name]) => (
|
||||
<button key={id} id={`prefs-${id}`} role="tab" title={t(name)}
|
||||
data-action="settings.section" data-value={id}
|
||||
tabIndex={at === id ? 0 : -1}
|
||||
aria-selected={at === id} disabled={updatingPkg.applying} onClick={() => setAt(id)}>
|
||||
<svg viewBox="0 0 16 16" aria-hidden="true">{ICON[id]}</svg>
|
||||
<span className="nm">{t(name)}</span>
|
||||
<span className="nv" title={nav[id] || undefined} data-danger={danger(id) ? "" : undefined}>
|
||||
{nav[id]}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</Fragment>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
|
||||
<div className="prefs-main" role="tabpanel" aria-labelledby={`prefs-${at}`} data-sec={at}>
|
||||
<Boundary
|
||||
fallback={
|
||||
<div className="find" data-lvl="err" role="alert">
|
||||
<span className="t">{t("这个设置分区出错了")}</span>
|
||||
<span className="why">{t("其他分区与当前会话不受影响;关闭设置后重新打开可重试。")}</span>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{/* Keyed on the section: replacing this column is the navigation,
|
||||
and it is the one thing here whose mount really is the cause. */}
|
||||
<div className="prefs-col" key={at}>
|
||||
<SettingsHeading section={at} value={nav[at]} danger={danger(at)} />
|
||||
{/* Every refused write on every page lands in one state, so it is
|
||||
said in one place. Copied onto the pages somebody remembered, it
|
||||
was missing from the third that writes it: 创建隔离副本 was
|
||||
refused by the kernel and the screen showed nothing at all. */}
|
||||
{note && (
|
||||
<div className="find" data-lvl="warn" role={note.unapplied ? "status" : "alert"}>
|
||||
<span className="t">{t(note.unapplied ? "已保存,尚未生效" : "操作未完成")}</span>
|
||||
<span className="why">{note.text}</span>
|
||||
</div>
|
||||
)}
|
||||
{at === "session" && (
|
||||
<>
|
||||
<Group id="preset" title={t("完成标准")} now={preset} hint={t("系统根据任务风险自动决定需要的验证程度,不再要求手动选择均衡或交付。")}>
|
||||
<p className="note">{t("日常问答保持轻量;涉及文件修改、代码执行或明确交付物时自动增加验证与复核。")}</p>
|
||||
</Group>
|
||||
{/* An on/off that reverses by clicking again is a switch, not two
|
||||
options — the same control the recipes and the sandbox's
|
||||
network egress use. */}
|
||||
<Group id="plan-mode" title={t("计划模式")} hint={t("开启时 agent 无法获得写权限。该限制由工具本身实施,不依赖提示词中的约定。")}>
|
||||
<div className="lrow">
|
||||
<span className="tx">
|
||||
<span className="lb">{t("仅生成计划,不写入")}</span>
|
||||
<span className="ds">
|
||||
{t(status?.plan ? "仅生成只读计划,经你批准后由内核自动关闭" : "正常执行:已批准的写入操作直接执行")}
|
||||
</span>
|
||||
</span>
|
||||
<Switch
|
||||
data-action="plan.mode"
|
||||
on={status?.plan === true}
|
||||
busy={busy === "plan"}
|
||||
label={t("计划模式")}
|
||||
onClick={() => run("plan", () => port.setPlanMode(!status?.plan))}
|
||||
/>
|
||||
</div>
|
||||
</Group>
|
||||
<ProgressWatch port={port} />
|
||||
<Group id="session-dir" title={t("会话写入位置")}>
|
||||
<div className="kv">
|
||||
<span className="k">{t("工作目录")}</span>
|
||||
<span className="v"><Path of={status?.cwd ?? "—"} /></span>
|
||||
</div>
|
||||
<p className="note">
|
||||
{t("在左栏管理文件夹:底部添加,展开后可新建会话。会话归属于创建它的文件夹,不会移动到其他位置。")}
|
||||
</p>
|
||||
{/* A thing that happens once, not a state to sit in — so it is
|
||||
a button. As an option row it carried a selected look it can
|
||||
never have. */}
|
||||
<div className="lrow">
|
||||
<span className="tx">
|
||||
<span className="lb">{t("创建隔离副本")}</span>
|
||||
<span className="ds">{t("在 Git worktree 中创建独立副本,修改不会影响当前分支")}</span>
|
||||
</span>
|
||||
<button className="act" data-action="workspace.isolate" disabled={!!busy} onClick={() => run("isolate", () => port.isolateWorkspace())}>
|
||||
{t(busy === "isolate" ? "启用中…" : "创建")}
|
||||
</button>
|
||||
</div>
|
||||
</Group>
|
||||
</>
|
||||
)}
|
||||
|
||||
{at === "providers" && (
|
||||
<Group id="providers" title={t("模型服务")}
|
||||
hint={t("选择一个服务查看和修改;地址、密钥与模型列表保存后生效。自定义中转站的协议与模型会在连接后自动探测。")}>
|
||||
<Providers port={port} onChanged={() => { loadModels(); onChanged(); }} onFailed={setFailed} protocol={protocol}
|
||||
activeKindFor={(a) => kindFor(a.key)}
|
||||
declare={declare}
|
||||
onProtocol={(a, kind) => switchProtocol(a.key, kind)} />
|
||||
</Group>
|
||||
)}
|
||||
|
||||
{at === "model" && (
|
||||
<>
|
||||
<Group id="model" title={t("按用途选择模型")} now={nav.model}
|
||||
hint={t("默认模型用于当前对话和大多数任务,其他用途默认跟随它;只有要为某件事换一个模型时才改。切换会保留对话并重建运行时,任务执行期间无法修改。")}>
|
||||
<ModelUsage models={models} roles={roles} main={status?.modelRef} busy={busy} protocol={protocol}
|
||||
onMain={(ref) => run(ref, async () => {
|
||||
await port.setModel(ref);
|
||||
await homePort.setDefaultModel(ref);
|
||||
// The row reads the catalogue's default, so the list has to
|
||||
// be re-read or the controlled select snaps back.
|
||||
loadModels();
|
||||
})}
|
||||
onRole={(role, ref) => run(`role:${role}`, () => port.setRole(role, ref).finally(loadRoles))}
|
||||
overrides={overrides} onClearOverride={(role, key) => run(`role:${role}`, () => port.clearRoleOverride(role, key).finally(loadRoles))} />
|
||||
</Group>
|
||||
{efforts.length > 0 ? (
|
||||
<Group id="effort" title={t("推理强度")} hint={t("以下档位由当前模型的端点支持,auto 表示使用端点自身的默认值。")}>
|
||||
<div className="seg" role="group" aria-label={t("推理强度")}>
|
||||
{efforts.map((e) => (
|
||||
<button key={e} data-action="reasoning.effort" data-value={e}
|
||||
aria-pressed={(status?.effort || "auto") === e}
|
||||
onClick={() => run(e, () => port.setEffort(e))}>
|
||||
{e}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</Group>
|
||||
) : (
|
||||
<Group id="effort" title={t("推理强度")} hint={t("当前模型未提供可调的推理档位,因此不显示该选项。")} />
|
||||
)}
|
||||
<Group id="context" title={t("上下文维护")}
|
||||
hint={t("默认按模型容量自动整理。自定义中转站若无法提供最大上下文,先按 160k 计算;你可以在这里填写实际容量。")}>
|
||||
<Compaction port={port} onChanged={onChanged} />
|
||||
</Group>
|
||||
</>
|
||||
)}
|
||||
|
||||
{at === "tools" && (
|
||||
<>
|
||||
<Group id="approval" title={t("工具批准")} now={approval} hint={t("这是 agent 访问文件前的唯一审批入口,被拦截的操作没有其他途径可以绕过。")}>
|
||||
{/* Four rows of label-and-description was 190px for one choice,
|
||||
and it was the only choice in this pane shaped that way. The
|
||||
description follows the selection instead: what a档 does is
|
||||
read when it is picked, not compared four at a time. */}
|
||||
<div className="seg" data-text data-danger={status?.toolApprovalMode === "yolo" ? "" : undefined}
|
||||
role="radiogroup" aria-label={t("工具批准")}>
|
||||
{APPROVALS.map(([id, name]) => (
|
||||
<button key={id} role="radio" data-action="tool-approval.mode" data-value={id}
|
||||
aria-checked={status?.toolApprovalMode === id} disabled={!!busy}
|
||||
onClick={() => run(id, () => port.setApprovalMode(id))}>
|
||||
{t(name)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<p className="note">{approvalNote(status?.toolApprovalMode)}</p>
|
||||
</Group>
|
||||
<Group id="rules"
|
||||
title={t("明确的规则")}
|
||||
hint={t("上一项决定是否请求确认,此处决定哪些操作始终禁止、哪些无需确认。修改会重建运行时,任务运行期间无法变更。")}
|
||||
>
|
||||
<Rules port={port} onChanged={onChanged} />
|
||||
</Group>
|
||||
<Group id="sandbox"
|
||||
title={t("沙箱")}
|
||||
hint={t("批准之后可以操作的范围。该限制不依赖 agent 自觉:写入范围由工具实施,命令隔离由操作系统实施。")}
|
||||
>
|
||||
<Sandbox port={port} onChanged={onChanged} />
|
||||
</Group>
|
||||
<Group id="shell"
|
||||
title={t("命令执行程序")}
|
||||
hint={t("agent 的所有命令都由该程序执行,它也决定命令使用哪种语法;选择错误会导致每条命令都执行失败。下方只列出本机已安装的程序。更换需要重建运行时,任务运行期间无法修改。")}
|
||||
>
|
||||
<ShellPicker port={port} onChanged={onChanged} />
|
||||
</Group>
|
||||
<Group id="browser-tools"
|
||||
title={t("内置浏览器")}
|
||||
hint={t("agent 用来打开和操作网页的工具。该开关单独保存,不受命令行版本浏览器设置的影响。修改会重建运行时,任务运行期间无法变更。")}
|
||||
>
|
||||
<BrowserTools port={port} onChanged={onChanged} />
|
||||
</Group>
|
||||
</>
|
||||
)}
|
||||
|
||||
{at === "hooks" && (
|
||||
<Group id="hooks"
|
||||
title={t("自动化")}
|
||||
hint={t("在 agent 执行任务前后运行自定义命令。这些命令在本机以当前用户权限运行;可拦截 agent 的两个事件已在下方标出。")}
|
||||
>
|
||||
<Hooks port={port} onChanged={afterExtChange} />
|
||||
</Group>
|
||||
)}
|
||||
{at === "ext" && (
|
||||
<>
|
||||
<ExtTabs at={extTab} disabled={updatingPkg.applying} onPick={(tab) => { setInstalledTarget(null); setExtTab(tab); }} />
|
||||
{extTab === "installed" && <InstalledLocation root={root} target={installedTarget} packages={packages} skills={skills} mcp={mcp} refreshing={extRefreshing} onFound={setInstalledTarget} />}
|
||||
{extTab === "market" && <MarketGroup port={port} onApplying={applyingChanged} onInstalled={afterExtChange} onViewInstalled={(kind, name) => { setInstalledTarget({ kind, name }); setExtTab("installed"); }} account={acct} onSignIn={() => go("account", "account")} />}
|
||||
{extTab === "installed" && (
|
||||
<>
|
||||
{scope && <ScopeBar scope={scope} scopes={scopes} onPick={setScopeAt} />}
|
||||
{/* 装完、改完、删完都要过这一步才算数——把它放在包列表上面,
|
||||
因为它管的是整个运行时,不是某一个包。 */}
|
||||
<Group id="ext-runtime"
|
||||
title={t("运行时")}
|
||||
hint={t("修改扩展代码,或安装、删除、启用或停用插件包之后,用它使改动生效。当前这一轮不受影响,下一轮开始使用新的配置。")}
|
||||
action={reload.action}
|
||||
>
|
||||
{reload.note}
|
||||
</Group>
|
||||
<Group id="plugins"
|
||||
title={t("插件包")}
|
||||
now={packages.length ? t("{n} 个", { n: packages.length }) : undefined}
|
||||
hint={t("一个包可以同时提供技能、命令、自动化钩子和外部服务。安装与导入是同一个操作:提供一个仓库地址,或本机的一个文件夹。")}
|
||||
action={
|
||||
addingPkg ? undefined : (
|
||||
<button className="act" data-action="extensions.add" disabled={!!updatingPkg.name} onClick={() => setAddingPkg(true)}>
|
||||
{t("添加")}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
>
|
||||
{addingPkg && (
|
||||
<AddPlugin port={port} onClose={() => setAddingPkg(false)} onInstalled={afterExtChange} />
|
||||
)}
|
||||
{updatingPkg.name && (
|
||||
<AddPlugin
|
||||
port={port}
|
||||
updating={packages.find((p) => p.name === updatingPkg.name)}
|
||||
onApplying={applyingChanged}
|
||||
onClose={() => setUpdatingPkg({ name: "", applying: false })} onInstalled={afterExtChange}
|
||||
/>
|
||||
)}
|
||||
<Packages
|
||||
port={port}
|
||||
packages={packages}
|
||||
onChanged={afterExtChange} onReloadError={reload.report} onReloaded={reload.applied}
|
||||
updating={updatingPkg.name}
|
||||
onUpdate={(name) => setUpdatingPkg({ name, applying: false })}
|
||||
/>
|
||||
{extErrors.packages && <div className="rnote" data-s="bad" role="alert">{extErrors.packages} <button className="act" data-action="extensions.refresh" disabled={extRefreshing} onClick={reloadExt}>{t("重试")}</button></div>}
|
||||
{packages.length === 0 && !addingPkg && !extRefreshing && !extErrors.packages && <div className="empty">{t("尚未安装插件包。")}</div>}
|
||||
</Group>
|
||||
{/* Below the packages: what was added by hand. A server the user
|
||||
typed in themselves is not part of anyone's package, and
|
||||
filing it under one would misname where it came from. */}
|
||||
<Group id="mcp"
|
||||
title={t("外部工具")}
|
||||
now={looseMcp.length ? t("{n} 个服务", { n: looseMcp.length }) : undefined}
|
||||
hint={t("已接入的 MCP 服务。其提供的能力与内置工具等同,列出的每一项均可访问文件与数据。关闭后立即从本轮工具列表中移除,重启后保持关闭。")}
|
||||
action={
|
||||
adding || !live ? undefined : (
|
||||
<button className="act" onClick={() => setAdding(true)}>
|
||||
{t("接入服务")}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
>
|
||||
{adding && live && (
|
||||
<AddServer
|
||||
port={port}
|
||||
canProject={!!status?.workspaceRoot}
|
||||
onClose={() => setAdding(false)}
|
||||
onInstalled={afterExtChange}
|
||||
/>
|
||||
)}
|
||||
{looseMcp.map((m) => (
|
||||
<ServerRow key={m.name} m={m} port={port} onDone={afterExtChange} root={scopeAt} live={live} />
|
||||
))}
|
||||
{extErrors.mcp && <div className="rnote" data-s="bad" role="alert">{extErrors.mcp} <button className="act" data-action="extensions.refresh" disabled={extRefreshing} onClick={reloadExt}>{t("重试")}</button></div>}
|
||||
{looseMcp.length === 0 && !adding && !extRefreshing && !extErrors.mcp && <div className="empty">{t("尚未接入外部服务。")}</div>}
|
||||
</Group>
|
||||
<Group id="skills"
|
||||
title={t("技能")}
|
||||
now={looseSkills.length ? t("{on}/{all} 已启用", { on: looseOn, all: looseSkills.length }) : undefined}
|
||||
hint={t(
|
||||
implicit
|
||||
? "工作目录与「我的」中的技能。带 / 的可以由你直接调用,其余的由模型根据任务判断是否使用。关掉一个立刻生效:从你的下一条消息起模型不再拿到它,直接点名也调不动,不用新建会话。"
|
||||
: "模型自动发现已关闭:仅显式指定的技能会运行。开关立即生效,自下一条消息起,无需新建会话。",
|
||||
)}
|
||||
>
|
||||
{looseSkills.map((sk) => (
|
||||
<SkillRow key={sk.name} sk={sk} implicit={implicit} port={port} onDone={afterExtChange} root={scopeAt} onFailed={setFailed} />
|
||||
))}
|
||||
{extErrors.skills && <div className="rnote" data-s="bad" role="alert">{extErrors.skills} <button className="act" data-action="extensions.refresh" disabled={extRefreshing} onClick={reloadExt}>{t("重试")}</button></div>}
|
||||
{looseSkills.length === 0 && !extRefreshing && !extErrors.skills && <div className="empty">{t("当前工作目录下没有技能。")}</div>}
|
||||
</Group>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{at === "network" && (
|
||||
<Group id="network"
|
||||
title={t("网络")}
|
||||
hint={t("模型请求、MCP 远程服务和网页抓取都经由此处。配置错误通常表现为聊天无响应,建议先测试连接,测试会指出中断的环节。")}
|
||||
>
|
||||
<Network port={networkPort ?? port} host={networkHost ?? ""} />
|
||||
</Group>
|
||||
)}
|
||||
|
||||
{at === "remote" && (
|
||||
<Group id="remote"
|
||||
title={t("远程")}
|
||||
hint={t("接入另一台机器上的工作区:内核在远程运行,界面仍在本机。远程面板与本地面板并排显示,每处都会标明所在的机器。")}
|
||||
>
|
||||
<Remotes hub={hub} onError={onError} />
|
||||
</Group>
|
||||
)}
|
||||
{at === "remote" && <PhoneAccess hub={hub} onError={onError} />}
|
||||
|
||||
{at === "account" && (
|
||||
<Group id="account"
|
||||
title={t("账号")}
|
||||
hint={t("Reasonix 本身不需要账号,仅在需要联网的功能中使用:社区发帖、崩溃问题跟进,以及后续的技能发布。")}
|
||||
>
|
||||
<Account port={port} state={acct} unread={accountUnread} reload={reloadAccount} />
|
||||
</Group>
|
||||
)}
|
||||
{at === "account" && acct?.signedIn && acct.user && (
|
||||
<Group id="backup" title={t("云备份")} hint={t("把本机的模型、扩展、记忆等配置加密保存到账号,换机器或重装后按项恢复。")}>
|
||||
<Backup port={port} />
|
||||
</Group>
|
||||
)}
|
||||
|
||||
{at === "versions" && <About port={port} />}
|
||||
|
||||
{at === "memory" && (
|
||||
<Group id="memory"
|
||||
title={t("记忆")}
|
||||
hint={t("agent 自动记录的内容:未经配置,但会据此执行。此处按触发时机分组,并标出上一轮实际使用的条目。")}
|
||||
>
|
||||
<Memory port={port} />
|
||||
</Group>
|
||||
)}
|
||||
|
||||
{at === "usage" && <UsageSettings port={port} onChanged={onChanged} />}
|
||||
|
||||
{at === "storage" && (
|
||||
<Group id="storage" title={t("存储")} hint={t("数据的存储位置与占用空间。会话和索引会持续增长,配置和凭据不会,因此只有前者可以迁移。迁移在重启后生效。")}><Storage port={port} hub={hub} workspace={workspaceRoot ?? status?.workspaceRoot ?? ""} onRecovered={() => { onChanged(); onSessionsRecovered?.(); }} /></Group>
|
||||
)}
|
||||
|
||||
{at === "appearance" && (
|
||||
<Appearance port={port} theme={theme} onTheme={onTheme} contrast={contrast} onContrast={onContrast} weight={weight} onWeight={onWeight} reloadThemes={reloadThemes} look={look} onLook={onLook} />
|
||||
)}
|
||||
|
||||
{at === "advanced" && (
|
||||
<Group id="elsewhere" title={t("本版本尚未提供")} hint={t("以下项目尚未提供设置界面,此处仅说明它们当前的位置。")}>
|
||||
{ELSEWHERE.map((x) => (
|
||||
<div className="lrow" key={x}>
|
||||
<span className="ds">{x}</span>
|
||||
<span className="sc">{t("旧版桌面端")}</span>
|
||||
</div>
|
||||
))}
|
||||
</Group>
|
||||
)}
|
||||
</div>
|
||||
</Boundary>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import { FeedbackBadge, feedbackEntryTab } from "./feedbackentry";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { t } from "../i18n";
|
||||
import type { AccountState } from "../port/port";
|
||||
@@ -273,15 +274,10 @@ export function Sidebar({
|
||||
</div>
|
||||
<div className="railfoot">
|
||||
<button className="studio-wallet" data-action="settings.section" data-value="usage" onClick={() => onSettings("usage")}><span aria-hidden="true"><StudioIcon name="wallet" /></span><b>{t("钱包与用量")}</b>{wallet && <small>{hideAmounts ? MASK : wallet}</small>}</button>
|
||||
<button className="studio-wallet studio-feedback" data-action="feedback.open" onClick={() => onFeedback(feedbackUnread > 0 ? "mine" : "send")}>
|
||||
<button className="studio-wallet studio-feedback" data-action="feedback.open" onClick={() => onFeedback(feedbackEntryTab(feedbackUnread))}>
|
||||
<span aria-hidden="true"><StudioIcon name="feedback" /></span>
|
||||
<b>{t("发送反馈")}</b>
|
||||
{feedbackUnread > 0 && (
|
||||
<>
|
||||
<i className="fbk-badge" aria-hidden="true" title={t("{n} 项待查看", { n: feedbackUnread })}>{feedbackUnread > 9 ? "9+" : feedbackUnread}</i>
|
||||
<span className="sr-only">{t("{n} 项待查看", { n: feedbackUnread })}</span>
|
||||
</>
|
||||
)}
|
||||
<FeedbackBadge unread={feedbackUnread} />
|
||||
</button>
|
||||
<div className="studio-user-foot">
|
||||
<AccountRow account={account} unread={accountUnread} onOpen={() => onSettings("account")} />
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import { onPresence, present } from "./presence";
|
||||
|
||||
// Where each puff sits, how big it is, and how fast it drifts. Three bands
|
||||
// rather than one spread: parallax is what makes a flat blur read as depth,
|
||||
@@ -13,7 +14,7 @@ const PUFFS = 34;
|
||||
// viewport, so drawing it at window size would cost pixels nobody can see.
|
||||
const W = 400;
|
||||
const H = 225;
|
||||
const FRAME = 1000 / 30;
|
||||
const FRAME = 1000 / 15;
|
||||
|
||||
interface Puff {
|
||||
x: number;
|
||||
@@ -87,41 +88,48 @@ export function Sky() {
|
||||
};
|
||||
|
||||
let raf = 0;
|
||||
let timer = 0;
|
||||
let last = 0;
|
||||
const tick = (now: number) => {
|
||||
// Waiting between frames on a timer, not on a rAF loop: a loop that does
|
||||
// nothing on 3 of 4 callbacks still wakes the compositor on every one.
|
||||
const frame = (now: number) => {
|
||||
raf = 0;
|
||||
if (still.matches || document.hidden) return;
|
||||
const elapsed = now - last;
|
||||
if (elapsed >= FRAME) {
|
||||
paint(true, Math.min(3, elapsed / (1000 / 60)));
|
||||
last = now;
|
||||
}
|
||||
raf = requestAnimationFrame(tick);
|
||||
paint(true, Math.min(6, (now - last) / (1000 / 60)));
|
||||
last = now;
|
||||
timer = window.setTimeout(wake, FRAME);
|
||||
};
|
||||
const wake = () => {
|
||||
timer = 0;
|
||||
raf = requestAnimationFrame(frame);
|
||||
};
|
||||
const halt = () => {
|
||||
if (raf) cancelAnimationFrame(raf);
|
||||
if (timer) clearTimeout(timer);
|
||||
raf = timer = 0;
|
||||
};
|
||||
const sync = () => {
|
||||
if (still.matches || document.hidden) {
|
||||
if (raf) cancelAnimationFrame(raf);
|
||||
raf = 0;
|
||||
if (still.matches || !present()) {
|
||||
halt();
|
||||
paint(false);
|
||||
return;
|
||||
}
|
||||
if (!raf) {
|
||||
if (!raf && !timer) {
|
||||
last = performance.now();
|
||||
raf = requestAnimationFrame(tick);
|
||||
wake();
|
||||
}
|
||||
};
|
||||
const theme = new MutationObserver(() => {
|
||||
readTheme();
|
||||
if (still.matches || document.hidden) paint(false);
|
||||
if (still.matches || !present()) paint(false);
|
||||
});
|
||||
theme.observe(document.documentElement, { attributes: true, attributeFilter: ["data-theme", "data-pack", "style"] });
|
||||
still.addEventListener("change", sync);
|
||||
document.addEventListener("visibilitychange", sync);
|
||||
const unwatch = onPresence(sync);
|
||||
sync();
|
||||
return () => {
|
||||
if (raf) cancelAnimationFrame(raf);
|
||||
halt();
|
||||
still.removeEventListener("change", sync);
|
||||
document.removeEventListener("visibilitychange", sync);
|
||||
unwatch();
|
||||
theme.disconnect();
|
||||
};
|
||||
}, []);
|
||||
@@ -144,11 +152,11 @@ export function Sky() {
|
||||
// differently on every mount, and a backdrop that is not the same twice is a
|
||||
// backdrop somebody notices.
|
||||
const SHAFTS = [
|
||||
{ "--x": "8%", "--w": "8%", "--r": "7deg", "--dur": "19s", "--del": "0s" },
|
||||
{ "--x": "22%", "--w": "5%", "--r": "5deg", "--dur": "23s", "--del": "-4s" },
|
||||
{ "--x": "38%", "--w": "11%", "--r": "3deg", "--dur": "17s", "--del": "-9s" },
|
||||
{ "--x": "54%", "--w": "4%", "--r": "-1deg", "--dur": "26s", "--del": "-2s" },
|
||||
{ "--x": "64%", "--w": "14%", "--r": "-3deg", "--dur": "21s", "--del": "-13s" },
|
||||
{ "--x": "81%", "--w": "6%", "--r": "-6deg", "--dur": "24s", "--del": "-6s" },
|
||||
{ "--x": "91%", "--w": "9%", "--r": "-9deg", "--dur": "18s", "--del": "-11s" },
|
||||
{ "--x": "8%", "--w": "8%", "--r": "7deg" },
|
||||
{ "--x": "22%", "--w": "5%", "--r": "5deg" },
|
||||
{ "--x": "38%", "--w": "11%", "--r": "3deg" },
|
||||
{ "--x": "54%", "--w": "4%", "--r": "-1deg" },
|
||||
{ "--x": "64%", "--w": "14%", "--r": "-3deg" },
|
||||
{ "--x": "81%", "--w": "6%", "--r": "-6deg" },
|
||||
{ "--x": "91%", "--w": "9%", "--r": "-9deg" },
|
||||
];
|
||||
@@ -2,7 +2,7 @@ import { Fragment, memo, useCallback, useContext, useEffect, useId, useLayoutEff
|
||||
import { t } from "../i18n";
|
||||
import type { Item, Waiting } from "../state/session";
|
||||
import type { ExtensionSurface } from "../port/wire";
|
||||
import type { ApprovalVerdict, Checkpoint, RewindPlan, RewindResult, RewindScope } from "../port/port";
|
||||
import type { AgentPort, ApprovalVerdict, Checkpoint, RewindPlan, RewindResult, RewindScope } from "../port/port";
|
||||
import { RMark } from "./RMark";
|
||||
import { Await } from "./Await";
|
||||
import { ToolCard } from "./cards/ToolCard";
|
||||
@@ -31,6 +31,7 @@ import { landingBox, useFindLanding, useFindPaint } from "./findland";
|
||||
|
||||
interface Props {
|
||||
items: Item[];
|
||||
port?: Pick<AgentPort, "workspaceImageURL">;
|
||||
// Changes only when the transcript's composition does — see state/session.
|
||||
revision: number;
|
||||
waiting: Waiting;
|
||||
@@ -72,7 +73,7 @@ interface Props {
|
||||
onPrepareRewind: (turn: number, scope: RewindScope) => Promise<RewindPlan>;
|
||||
onPrepareFileRevert: (path: string) => Promise<RewindPlan>;
|
||||
onCommitFileRevert: (planId: string, resolution?: string) => Promise<RewindResult>;
|
||||
onCommitRewind: (planId: string) => Promise<RewindResult>;
|
||||
onCommitRewind: (planId: string, text?: string) => Promise<RewindResult>;
|
||||
onUndoRewind: (transactionId: string) => Promise<void>;
|
||||
/** Cards that have not had their one entrance yet. Owed by the projection,
|
||||
* spent by the first render that draws them — never by the animation, which
|
||||
@@ -81,7 +82,7 @@ interface Props {
|
||||
onEntered: (ids: string[]) => void;
|
||||
}
|
||||
|
||||
export function Transcript({ items, entering, onEntered, revision, waiting, scroll, hidden, onPinned, jump, focus, find, query, onApprove, onFullAccess, onPlan, onAnswer, onForget, onExtInvoke, onExtSubmit, reply, onResend, takeovers = {}, checkpoints, onPrepareRewind, onCommitRewind, onUndoRewind, onPrepareFileRevert, onCommitFileRevert, needsProject, onOpenProject, onKeepHere }: Props) {
|
||||
export function Transcript({ items, port, entering, onEntered, revision, waiting, scroll, hidden, onPinned, jump, focus, find, query, onApprove, onFullAccess, onPlan, onAnswer, onForget, onExtInvoke, onExtSubmit, reply, onResend, takeovers = {}, checkpoints, onPrepareRewind, onCommitRewind, onUndoRewind, onPrepareFileRevert, onCommitFileRevert, needsProject, onOpenProject, onKeepHere }: Props) {
|
||||
// A block the selection touches must not leave the DOM. Unmounting the node a
|
||||
// selection is anchored to makes the browser remap that selection onto
|
||||
// whatever is still mounted — which reads as "I selected up there and the
|
||||
@@ -434,7 +435,7 @@ export function Transcript({ items, entering, onEntered, revision, waiting, scro
|
||||
}, [entering, onEntered]);
|
||||
const owed = useMemo(() => new Set(entering), [entering]);
|
||||
|
||||
const rowProps = { owed, onApprove, onFullAccess, onPlan, onAnswer, onForget, onExtInvoke, takeovers, onExtSubmit, onPrepareRewind, onCommitRewind, onUndoRewind, onPrepareFileRevert, onCommitFileRevert, reply, onResend };
|
||||
const rowProps = { owed, port, onApprove, onFullAccess, onPlan, onAnswer, onForget, onExtInvoke, takeovers, onExtSubmit, onPrepareRewind, onCommitRewind, onUndoRewind, onPrepareFileRevert, onCommitFileRevert, reply, onResend };
|
||||
|
||||
// What you said, and where it sits. Derived from the same blocks the
|
||||
// transcript renders, so a mark always knows which block holds it — that is
|
||||
@@ -558,6 +559,7 @@ const Block = memo(function Block({
|
||||
|
||||
interface RowHandlers {
|
||||
owed: Set<string>;
|
||||
port: Props["port"];
|
||||
onApprove: Props["onApprove"];
|
||||
onFullAccess: Props["onFullAccess"];
|
||||
onPlan: Props["onPlan"];
|
||||
@@ -631,6 +633,7 @@ const ActivityGroup = memo(function ActivityGroup({
|
||||
// chunk than parsing the message did.
|
||||
const Row = memo(function Row({
|
||||
it,
|
||||
port,
|
||||
owed,
|
||||
onApprove,
|
||||
onFullAccess,
|
||||
@@ -666,6 +669,7 @@ const Row = memo(function Row({
|
||||
return (
|
||||
<UserCard
|
||||
item={it}
|
||||
port={port}
|
||||
cp={cp}
|
||||
onResend={onResend}
|
||||
onPrepareRewind={onPrepareRewind}
|
||||
|
||||
@@ -15,8 +15,8 @@ const hub: VersionHub = {
|
||||
latest: "2.21.0",
|
||||
newer: true,
|
||||
versions: [
|
||||
{ version: "2.21.0", tag: "studio-v2.21.0", publishedAt: "", current: false, older: false },
|
||||
{ version: "2.20.0", tag: "studio-v2.20.0", publishedAt: "", current: true, older: false },
|
||||
{ version: "2.21.0", tag: "studio-v2.21.0", publishedAt: "", hasNotes: false, current: false, older: false },
|
||||
{ version: "2.20.0", tag: "studio-v2.20.0", publishedAt: "", hasNotes: false, current: true, older: false },
|
||||
],
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
// @vitest-environment jsdom
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { cleanup, render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import "./testkit";
|
||||
import { Versions } from "./Versions";
|
||||
import { HttpError, type UpdateProgress, type VersionHub, type VersionNotes } from "../port/port";
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
const row = (version: string, hasNotes: boolean, current = false, older = false) => ({
|
||||
version,
|
||||
tag: `studio-v${version}`,
|
||||
publishedAt: "",
|
||||
hasNotes,
|
||||
current,
|
||||
older,
|
||||
});
|
||||
|
||||
const hub: VersionHub = {
|
||||
current: "2.20.0",
|
||||
pinned: "",
|
||||
stalePin: false,
|
||||
latest: "2.21.0",
|
||||
newer: true,
|
||||
versions: [row("2.21.0", true), row("2.20.0", true, true), row("2.19.0", false, false, true)],
|
||||
};
|
||||
|
||||
type Notes = (v: string, retry?: boolean) => Promise<VersionNotes>;
|
||||
|
||||
function portWith(versionNotes: Notes, h: VersionHub = hub) {
|
||||
return {
|
||||
versions: () => Promise.resolve(h),
|
||||
pinVersion: vi.fn(() => Promise.resolve()),
|
||||
goToVersion: vi.fn(() => Promise.resolve()),
|
||||
restartToVersion: vi.fn(() => Promise.resolve()),
|
||||
onUpdateProgress: (_cb: (p: UpdateProgress) => void) => () => {},
|
||||
versionNotes: vi.fn(versionNotes),
|
||||
};
|
||||
}
|
||||
|
||||
const doc = (version: string, markdown: string): Notes => () => Promise.resolve({ version, markdown, cached: false });
|
||||
const refused = (code: string) => new HttpError(502, "english fallback for logs", { code }, true);
|
||||
|
||||
describe("version notes on the version panel", () => {
|
||||
it("offers the view only on rows whose catalog entry has notes, and asks for nothing until it is used", async () => {
|
||||
const port = portWith(doc("2.21.0", "# hi"));
|
||||
render(<Versions port={port} />);
|
||||
await screen.findByText("2.19.0");
|
||||
expect(screen.getAllByRole("button", { name: "更新内容" })).toHaveLength(2);
|
||||
expect(port.versionNotes).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("shows no control and makes no request when the catalog names no notes at all", async () => {
|
||||
const bare: VersionHub = { ...hub, versions: hub.versions.map((v) => ({ ...v, hasNotes: false })) };
|
||||
const port = portWith(doc("2.21.0", "x"), bare);
|
||||
render(<Versions port={port} />);
|
||||
await screen.findByText("2.19.0");
|
||||
expect(screen.queryByRole("button", { name: "更新内容" })).toBeNull();
|
||||
expect(port.versionNotes).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("tolerates a kernel that does not send hasNotes", async () => {
|
||||
const old = { ...hub, versions: hub.versions.map(({ hasNotes: _h, ...rest }) => rest) } as unknown as VersionHub;
|
||||
render(<Versions port={portWith(doc("2.21.0", "x"), old)} />);
|
||||
await screen.findByText("2.19.0");
|
||||
expect(screen.queryByRole("button", { name: "更新内容" })).toBeNull();
|
||||
});
|
||||
|
||||
it("expands inline with the document, and names the state for assistive tech", async () => {
|
||||
const port = portWith(doc("2.21.0", "# 本版新增\n\n- 修好了一件事"));
|
||||
render(<Versions port={port} />);
|
||||
const [button] = await screen.findAllByRole("button", { name: "更新内容" });
|
||||
expect(button.getAttribute("aria-expanded")).toBe("false");
|
||||
await userEvent.click(button);
|
||||
expect(await screen.findByText("修好了一件事")).toBeTruthy();
|
||||
const open = screen.getByRole("button", { name: "收起更新内容" });
|
||||
expect(open.getAttribute("aria-expanded")).toBe("true");
|
||||
expect(document.getElementById(open.getAttribute("aria-controls") ?? "")).toBeTruthy();
|
||||
expect(screen.getByRole("region", { name: "2.21.0 的更新内容" })).toBeTruthy();
|
||||
expect(port.versionNotes).toHaveBeenCalledWith("2.21.0", false);
|
||||
});
|
||||
|
||||
it("collapses on the button and on Escape, which hands focus back and does not travel on", async () => {
|
||||
const port = portWith(doc("2.21.0", "- 一条"));
|
||||
const onWindowKey = vi.fn();
|
||||
window.addEventListener("keydown", onWindowKey);
|
||||
render(<Versions port={port} />);
|
||||
const [button] = await screen.findAllByRole("button", { name: "更新内容" });
|
||||
await userEvent.click(button);
|
||||
await screen.findByText("一条");
|
||||
await userEvent.click(screen.getByRole("button", { name: "收起更新内容" }));
|
||||
expect(screen.queryByText("一条")).toBeNull();
|
||||
await userEvent.click(screen.getAllByRole("button", { name: "更新内容" })[0]);
|
||||
await screen.findByText("一条");
|
||||
await userEvent.keyboard("{Escape}");
|
||||
expect(screen.queryByText("一条")).toBeNull();
|
||||
expect(document.activeElement).toBe(screen.getAllByRole("button", { name: "更新内容" })[0]);
|
||||
expect(onWindowKey).not.toHaveBeenCalled();
|
||||
window.removeEventListener("keydown", onWindowKey);
|
||||
});
|
||||
|
||||
it("closes on Escape pressed inside the document too, and returns focus to the control", async () => {
|
||||
const port = portWith(() => Promise.reject(refused("studio.notes_unreachable")));
|
||||
render(<Versions port={port} />);
|
||||
await userEvent.click((await screen.findAllByRole("button", { name: "更新内容" }))[0]);
|
||||
const link = await screen.findByRole("link", { name: "在 GitHub 查看" });
|
||||
link.focus();
|
||||
await userEvent.keyboard("{Escape}");
|
||||
expect(screen.queryByRole("region")).toBeNull();
|
||||
expect(document.activeElement).toBe(screen.getAllByRole("button", { name: "更新内容" })[0]);
|
||||
});
|
||||
|
||||
it("opens one at a time", async () => {
|
||||
const port = portWith((v) => Promise.resolve({ version: v, markdown: `- 内容 ${v}`, cached: false }));
|
||||
render(<Versions port={port} />);
|
||||
const buttons = await screen.findAllByRole("button", { name: "更新内容" });
|
||||
await userEvent.click(buttons[0]);
|
||||
await screen.findByText("内容 2.21.0");
|
||||
await userEvent.click(screen.getAllByRole("button", { name: "更新内容" })[0]);
|
||||
await screen.findByText("内容 2.20.0");
|
||||
expect(screen.queryByText("内容 2.21.0")).toBeNull();
|
||||
expect(screen.getAllByRole("region")).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("does not ask again for a document it already has", async () => {
|
||||
const port = portWith(doc("2.21.0", "- 一条"));
|
||||
render(<Versions port={port} />);
|
||||
const [button] = await screen.findAllByRole("button", { name: "更新内容" });
|
||||
await userEvent.click(button);
|
||||
await screen.findByText("一条");
|
||||
await userEvent.click(screen.getByRole("button", { name: "收起更新内容" }));
|
||||
await userEvent.click(screen.getAllByRole("button", { name: "更新内容" })[0]);
|
||||
await screen.findByText("一条");
|
||||
expect(port.versionNotes).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("says why it failed, keeps the GitHub page one click away, and retries past the failure", async () => {
|
||||
let fail = true;
|
||||
const port = portWith(() => (fail ? Promise.reject(refused("studio.notes_unreachable")) : Promise.resolve({ version: "2.21.0", markdown: "- 好了", cached: false })));
|
||||
render(<Versions port={port} />);
|
||||
await userEvent.click((await screen.findAllByRole("button", { name: "更新内容" }))[0]);
|
||||
expect(await screen.findByText("暂时取不到更新内容,请检查网络后重试")).toBeTruthy();
|
||||
const link = screen.getByRole("link", { name: "在 GitHub 查看" });
|
||||
expect(link.getAttribute("href")).toBe("https://github.com/esengine/DeepSeek-Reasonix/releases/tag/studio-v2.21.0");
|
||||
fail = false;
|
||||
await userEvent.click(screen.getByRole("button", { name: "重试" }));
|
||||
expect(await screen.findByText("好了")).toBeTruthy();
|
||||
expect(port.versionNotes).toHaveBeenLastCalledWith("2.21.0", true);
|
||||
});
|
||||
|
||||
it("offers no retry for a release the mirror has no notes for", async () => {
|
||||
const port = portWith(() => Promise.reject(new HttpError(404, "x", { code: "studio.notes_absent" }, true)));
|
||||
render(<Versions port={port} />);
|
||||
await userEvent.click((await screen.findAllByRole("button", { name: "更新内容" }))[0]);
|
||||
expect(await screen.findByText("这个版本没有发布更新内容")).toBeTruthy();
|
||||
expect(screen.queryByRole("button", { name: "重试" })).toBeNull();
|
||||
expect(screen.getByRole("link", { name: "在 GitHub 查看" })).toBeTruthy();
|
||||
});
|
||||
|
||||
it("draws no picture and runs no markup that the document carries", async () => {
|
||||
const hostile = "\n\n<img src=\"https://evil.test/q.png\"><script>window.__ran = 1</script>\n\n[链接](https://example.com)";
|
||||
const port = portWith(doc("2.21.0", hostile));
|
||||
render(<Versions port={port} />);
|
||||
await userEvent.click((await screen.findAllByRole("button", { name: "更新内容" }))[0]);
|
||||
await screen.findByText("链接");
|
||||
await waitFor(() => expect(document.querySelector(".vnotes img")).toBeNull());
|
||||
expect(document.querySelector(".vnotes script")).toBeNull();
|
||||
expect((window as unknown as { __ran?: number }).__ran).toBeUndefined();
|
||||
expect(screen.getByText("链接").closest("a")?.getAttribute("href")).toBe("https://example.com");
|
||||
});
|
||||
|
||||
it("keeps the install and pin actions beside the notes control", async () => {
|
||||
const port = portWith(doc("2.21.0", "- x"));
|
||||
render(<Versions port={port} />);
|
||||
await screen.findAllByRole("button", { name: "更新内容" });
|
||||
expect(screen.getByRole("button", { name: "安装这个版本" })).toBeTruthy();
|
||||
expect(screen.getByRole("button", { name: "固定在这里" })).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -1,15 +1,18 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { Fragment, useCallback, useEffect, useState, type KeyboardEvent } from "react";
|
||||
import { bytes } from "../i18n/format";
|
||||
import { t } from "../i18n";
|
||||
import type { UpdateProgress, VersionHub } from "../port/port";
|
||||
import type { UpdateProgress, VersionHub, VersionNotes } from "../port/port";
|
||||
import { reason } from "../i18n/kernel";
|
||||
import { HttpError } from "../port/http_error";
|
||||
import { LazyMarkdown } from "./LazyMarkdown";
|
||||
import { deltaSkippedCopy, failureCopy, releasePage } from "./versionFailure";
|
||||
|
||||
// A shell that declared no install: a build run from source, not a failure.
|
||||
const NO_INSTALL = "studio.no_install";
|
||||
// Work is running and would end with the restart; the person decides.
|
||||
const RESTART_BUSY = "update.restart_busy";
|
||||
// The release has no notes published: nothing to retry, only the page to read.
|
||||
const NOTES_ABSENT = "studio.notes_absent";
|
||||
|
||||
// The panel answers three questions in the order a user asks them: what am I
|
||||
// running, is something wrong with it, and how do I get off it. Every action
|
||||
@@ -22,8 +25,13 @@ type Port = {
|
||||
goToVersion(v: string): Promise<void>;
|
||||
restartToVersion(v: string, force: boolean): Promise<void>;
|
||||
onUpdateProgress(cb: (p: UpdateProgress) => void): () => void;
|
||||
versionNotes?(version: string, retry?: boolean): Promise<VersionNotes>;
|
||||
};
|
||||
|
||||
// A document read stays read: it is kept for as long as the panel is mounted,
|
||||
// and the kernel keeps it on disk past that.
|
||||
type NotesState = { phase: "loading" } | { phase: "ok"; markdown: string } | { phase: "err"; why: string; absent: boolean };
|
||||
|
||||
// Phases during which a move owns the install and nothing else may start.
|
||||
const MOVING = new Set<UpdateProgress["phase"]>(["downloading", "verifying", "downloaded", "applying", "authorizing", "relaunching"]);
|
||||
|
||||
@@ -77,6 +85,8 @@ export function Versions({ port }: { port: Port }) {
|
||||
const [progress, setProgress] = useState<UpdateProgress | null>(null);
|
||||
const [later, setLater] = useState("");
|
||||
const [running, setRunning] = useState(0);
|
||||
const [openNotes, setOpenNotes] = useState("");
|
||||
const [notes, setNotes] = useState<Record<string, NotesState>>({});
|
||||
|
||||
// The kernel says why it cannot answer — a shell that never declared an
|
||||
// install, a server that does not carry this at all. Folding that back into
|
||||
@@ -149,6 +159,37 @@ export function Versions({ port }: { port: Port }) {
|
||||
}
|
||||
};
|
||||
|
||||
const loadNotes = (v: string, retry: boolean) => {
|
||||
if (!port.versionNotes) return;
|
||||
setNotes((n) => ({ ...n, [v]: { phase: "loading" } }));
|
||||
port
|
||||
.versionNotes(v, retry)
|
||||
.then((r) => setNotes((n) => ({ ...n, [v]: { phase: "ok", markdown: r.markdown } })))
|
||||
.catch((e) =>
|
||||
setNotes((n) => ({
|
||||
...n,
|
||||
[v]: { phase: "err", why: reason(e), absent: e instanceof HttpError && e.reason?.code === NOTES_ABSENT },
|
||||
})),
|
||||
);
|
||||
};
|
||||
|
||||
const toggleNotes = (v: string) => {
|
||||
if (openNotes === v) {
|
||||
setOpenNotes("");
|
||||
return;
|
||||
}
|
||||
setOpenNotes(v);
|
||||
const have = notes[v];
|
||||
if (!have || have.phase === "err") loadNotes(v, false);
|
||||
};
|
||||
|
||||
const closeNotes = (e: KeyboardEvent<HTMLElement>, v: string) => {
|
||||
if (e.key !== "Escape" || openNotes !== v) return;
|
||||
e.stopPropagation();
|
||||
setOpenNotes("");
|
||||
document.querySelector<HTMLElement>(`[aria-controls="vnotes-${v}"]`)?.focus();
|
||||
};
|
||||
|
||||
if (uninstalled) {
|
||||
return <p className="acct-note">{t("当前是从源码启动的开发版,没有可以查看或切换的版本。安装版 Studio 会在这里列出可用的更新。")}</p>;
|
||||
}
|
||||
@@ -270,12 +311,15 @@ export function Versions({ port }: { port: Port }) {
|
||||
marked the way every other "this one" in the app is. */}
|
||||
<div className="vlist">
|
||||
{list.map((v, i) => (
|
||||
<Fragment key={v.version}>
|
||||
<div
|
||||
key={v.version}
|
||||
className="vrow"
|
||||
data-open={openNotes === v.version ? "" : undefined}
|
||||
data-on={v.current ? "" : undefined}
|
||||
data-side={v.current ? "now" : v.older ? "past" : "ahead"}
|
||||
style={{ animationDelay: `${Math.min(i, 8) * 34}ms` }}
|
||||
data-action={v.hasNotes ? "versions.notes-close" : undefined}
|
||||
onKeyDown={v.hasNotes ? (e) => closeNotes(e, v.version) : undefined}
|
||||
>
|
||||
<span className="nm">{v.version}</span>
|
||||
<span className="ds">{t(v.current ? "正在运行" : v.older ? "更早的版本" : "更新的版本")}</span>
|
||||
@@ -316,9 +360,50 @@ export function Versions({ port }: { port: Port }) {
|
||||
{t("固定在这里")}
|
||||
</button>
|
||||
)}
|
||||
{v.hasNotes && port.versionNotes && (
|
||||
<button
|
||||
className="vn lnk"
|
||||
data-action="versions.notes"
|
||||
aria-expanded={openNotes === v.version}
|
||||
aria-controls={`vnotes-${v.version}`}
|
||||
onClick={() => toggleNotes(v.version)}
|
||||
>
|
||||
{t(openNotes === v.version ? "收起更新内容" : "更新内容")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{v.hasNotes && port.versionNotes && openNotes === v.version && (
|
||||
<NotesPanel id={`vnotes-${v.version}`} version={v.version} state={notes[v.version]} retry={() => loadNotes(v.version, true)} onKeyDown={(e) => closeNotes(e, v.version)} />
|
||||
)}
|
||||
</Fragment>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function NotesPanel({ id, version, state, retry, onKeyDown }: { id: string; version: string; state: NotesState | undefined; retry: () => void; onKeyDown: (e: KeyboardEvent<HTMLElement>) => void }) {
|
||||
const loading = !state || state.phase === "loading";
|
||||
return (
|
||||
<section id={id} className="vnotes" data-action="versions.notes-close" onKeyDown={onKeyDown} aria-label={t("{v} 的更新内容", { v: version })} aria-busy={loading}>
|
||||
{loading && <p className="acct-note">{t("正在读取更新内容…")}</p>}
|
||||
{state?.phase === "ok" && <LazyMarkdown text={state.markdown} images={false} />}
|
||||
{state?.phase === "err" && (
|
||||
<div className="find" data-lvl="warn" role="alert">
|
||||
<span className="t">{t("更新内容读取失败")}</span>
|
||||
<span className="why">
|
||||
{state.why}
|
||||
{!state.absent && (
|
||||
<button className="lnk" data-action="versions.notes-retry" onClick={retry}>
|
||||
{t("重试")}
|
||||
</button>
|
||||
)}
|
||||
<a className="lnk" href={releasePage(version)} target="_blank" rel="noreferrer noopener">
|
||||
{t("在 GitHub 查看")}
|
||||
</a>
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import { t } from "../i18n";
|
||||
import { StudioIcon } from "./StudioIcon";
|
||||
|
||||
export type FileMode = "file" | "diff" | "read" | "preview";
|
||||
|
||||
/** The open file's path, the views it can be seen in, and save. A document that
|
||||
* renders (Markdown, a page) leads with that view and calls the editor
|
||||
* "编辑"; any other file has only its text. */
|
||||
export function WorkbenchFileBar({
|
||||
path,
|
||||
mode,
|
||||
onMode,
|
||||
readable,
|
||||
previewable,
|
||||
canSave,
|
||||
onSave,
|
||||
}: {
|
||||
path: string;
|
||||
mode: FileMode;
|
||||
onMode: (mode: FileMode) => void;
|
||||
readable: boolean;
|
||||
previewable: boolean;
|
||||
canSave: boolean;
|
||||
onSave: () => void;
|
||||
}) {
|
||||
const view = (value: FileMode, label: string) => (
|
||||
<button data-action="workbench.mode" data-value={value} aria-pressed={mode === value} onClick={() => onMode(value)}>
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
return (
|
||||
<div className="workbench-filebar">
|
||||
<strong>{path}</strong>
|
||||
<div role="group">
|
||||
{readable && view("read", t("阅读"))}
|
||||
{previewable && view("preview", t("预览"))}
|
||||
{view("file", readable || previewable ? t("编辑") : t("文件"))}
|
||||
{view("diff", "Diff")}
|
||||
</div>
|
||||
<button className="workbench-save" data-action="workbench.save" data-target={path} disabled={!canSave} onClick={onSave}>
|
||||
<StudioIcon name="check" />
|
||||
{t("保存")}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -20,6 +20,8 @@ import { useGlance } from "./glance";
|
||||
import { useMarkdownPoll } from "./useMarkdownPoll";
|
||||
import { pinToViewport } from "./place";
|
||||
import { WorkbenchExplorerHead } from "./WorkbenchExplorerHead";
|
||||
import { WorkbenchFileBar, type FileMode } from "./WorkbenchFileBar";
|
||||
import { useHtmlPreview } from "./useHtmlPreview";
|
||||
import { setShowsHiddenFiles, showsHiddenFiles } from "../state/prefs";
|
||||
import { useCommitCard } from "./CommitCard";
|
||||
|
||||
@@ -106,7 +108,7 @@ function treeRows(
|
||||
|
||||
export function WorkbenchPanel({
|
||||
port,
|
||||
tabs,
|
||||
tabs: allTabs,
|
||||
manual,
|
||||
shown,
|
||||
scheme,
|
||||
@@ -139,6 +141,8 @@ export function WorkbenchPanel({
|
||||
onSurfaces: (n: number) => void;
|
||||
onExternal: (url: string) => void;
|
||||
}) {
|
||||
const preview = useHtmlPreview(port, allTabs, remote);
|
||||
const tabs = preview.tabs;
|
||||
const [files, setFiles] = useState<string[]>([]),
|
||||
[directories, setDirectories] = useState<string[]>([]),
|
||||
[openFiles, setOpenFiles] = useState<string[]>([]);
|
||||
@@ -167,7 +171,7 @@ export function WorkbenchPanel({
|
||||
const [browsers, setBrowsers] = useState<string[]>([]),
|
||||
[hosts, setHosts] = useState<Record<string, string>>({});
|
||||
const minted = useRef(0);
|
||||
const [mode, setMode] = useState<"file" | "diff" | "read">("file");
|
||||
const [mode, setMode] = useState<FileMode>("file");
|
||||
const [file, setFile] = useState<WorkspaceFile | null>(null),
|
||||
[draft, setDraft] = useState(""),
|
||||
[diff, setDiff] = useState("");
|
||||
@@ -308,7 +312,7 @@ export function WorkbenchPanel({
|
||||
useEffect(() => {
|
||||
const before = lastAgent.current;
|
||||
lastAgent.current = agentAt;
|
||||
if (before === null || !agentAt || before === agentAt) return;
|
||||
if (!before || !agentAt || before === agentAt) return;
|
||||
dropBlankStart();
|
||||
pick(`browser:${agentTarget}`);
|
||||
}, [agentAt, agentTarget, dropBlankStart, pick]);
|
||||
@@ -334,10 +338,26 @@ export function WorkbenchPanel({
|
||||
// source view is one click away.
|
||||
const filePath = active?.kind === "file" ? active.path : "";
|
||||
const readable = /\.(md|markdown|mdx)$/i.test(filePath);
|
||||
const previewable = !!filePath && preview.can(filePath);
|
||||
useEffect(() => {
|
||||
if (readable) setMode((m) => (m === "file" ? "read" : m));
|
||||
else setMode((m) => (m === "read" ? "file" : m));
|
||||
}, [filePath, readable]);
|
||||
setMode((m) => {
|
||||
const kept = (m === "read" && !readable) || (m === "preview" && !previewable) ? "file" : m;
|
||||
return kept === "file" ? (readable ? "read" : previewable ? "preview" : "file") : kept;
|
||||
});
|
||||
}, [filePath, readable, previewable]);
|
||||
const previewTab = previewable ? preview.tabFor(filePath) : undefined;
|
||||
const { open: openPreview, sync: syncPreview } = preview;
|
||||
const revision = file?.path === filePath ? file.revision : undefined;
|
||||
useEffect(() => {
|
||||
if (mode !== "preview" || !previewable || !shown || previewTab || (!revision && !failed)) return;
|
||||
openPreview(filePath, revision).catch((e) => {
|
||||
setFailed(reason(e));
|
||||
setMode("file");
|
||||
});
|
||||
}, [mode, previewable, shown, previewTab, revision, failed, filePath, openPreview, tabs]);
|
||||
useEffect(() => {
|
||||
if (mode === "preview" && revision) syncPreview(filePath, revision);
|
||||
}, [mode, filePath, revision, syncPreview]);
|
||||
const rows = useMemo(
|
||||
() => treeRows(files, directories, query, collapsed),
|
||||
[files, directories, query, collapsed],
|
||||
@@ -394,7 +414,7 @@ export function WorkbenchPanel({
|
||||
live = false;
|
||||
};
|
||||
}, [port, openPath, shown, changeKey, glance, running, wrote]);
|
||||
useMarkdownPoll({ port, filePath: shown && readable && mode === "read" ? filePath : "", held, issued, setFailed, setFile, setDraft });
|
||||
useMarkdownPoll({ port, filePath: shown && ((readable && mode === "read") || (previewable && mode === "preview")) ? filePath : "", held, issued, setFailed, setFile, setDraft });
|
||||
// Picking a file is asking to read it. Docked, the list and the file share one
|
||||
// column, so the list steps aside; side by side it stays where it is.
|
||||
const openFile = (path: string) => {
|
||||
@@ -590,48 +610,27 @@ export function WorkbenchPanel({
|
||||
)}
|
||||
{active?.kind === "file" && (
|
||||
<>
|
||||
<div className="workbench-filebar">
|
||||
<strong>{active.path}</strong>
|
||||
<div role="group">
|
||||
{readable && (
|
||||
<button data-action="workbench.mode" data-value="read" aria-pressed={mode === "read"} onClick={() => setMode("read")}>
|
||||
{t("阅读")}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
data-action="workbench.mode"
|
||||
data-value="file"
|
||||
aria-pressed={mode === "file"}
|
||||
onClick={() => setMode("file")}
|
||||
>
|
||||
{readable ? t("编辑") : t("文件")}
|
||||
</button>
|
||||
<button
|
||||
data-action="workbench.mode"
|
||||
data-value="diff"
|
||||
aria-pressed={mode === "diff"}
|
||||
onClick={() => setMode("diff")}
|
||||
>
|
||||
Diff
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
className="workbench-save"
|
||||
data-action="workbench.save"
|
||||
data-target={active.path}
|
||||
disabled={busy || !file || draft === file.content}
|
||||
onClick={() => void save()}
|
||||
>
|
||||
<StudioIcon name="check" />
|
||||
{t("保存")}
|
||||
</button>
|
||||
</div>
|
||||
{failed && (
|
||||
<WorkbenchFileBar
|
||||
path={active.path}
|
||||
mode={mode}
|
||||
onMode={setMode}
|
||||
readable={readable}
|
||||
previewable={previewable}
|
||||
canSave={!busy && !!file && draft !== file.content}
|
||||
onSave={() => void save()}
|
||||
/>
|
||||
{failed && !(mode === "preview" && previewTab) && (
|
||||
<div className="workbench-error" role="alert">
|
||||
{failed}
|
||||
</div>
|
||||
)}
|
||||
{busy && !file ? (
|
||||
{mode === "preview" ? (
|
||||
previewTab ? (
|
||||
<AgentBrowserPanel tabs={[previewTab]} shown={shown} showTabs={false} />
|
||||
) : (
|
||||
<div className="workbench-empty">{t("正在读取…")}</div>
|
||||
)
|
||||
) : busy && !file ? (
|
||||
<div className="workbench-empty">{t("正在读取…")}</div>
|
||||
) : mode === "diff" ? (
|
||||
<div className="workbench-diff">
|
||||
|
||||
@@ -95,3 +95,14 @@ it("counts only running delegates, reaching 0 when the last one finishes", () =>
|
||||
cleanup();
|
||||
expect(n([task("a", false, "A"), task("b", false, "B")])).toBe("0");
|
||||
});
|
||||
|
||||
it("heads a refused delegation as not run and a cut-short one as interrupted", () => {
|
||||
const refused = { ...task("r", false, "explore"), tool: { id: "r", name: "task", profile: { name: "explore" }, err: "invalid arguments for explore", refusalCode: "tool.arguments_invalid" } } as unknown as Task;
|
||||
const { rerender } = render(<AgentTranscript task={refused} onClose={() => {}} />);
|
||||
const head = () => screen.getByRole("dialog").querySelector(".agent-tx-hd .rt")!.textContent;
|
||||
expect(head()).toContain("未执行");
|
||||
expect(head()).not.toContain("已中断");
|
||||
const cut = { ...refused, tool: { ...refused.tool, refusalCode: undefined, err: "sub-agent: context canceled" } } as unknown as Task;
|
||||
rerender(<AgentTranscript task={cut} onClose={() => {}} />);
|
||||
expect(head()).toContain("已中断");
|
||||
});
|
||||
@@ -78,7 +78,7 @@ describe("what the appearance page says a person is doing", () => {
|
||||
it("keeps neighbouring intents apart", () => {
|
||||
const ids = new Set(ACTIONS.map((a) => a.id));
|
||||
for (const id of ["theme.activate", "wallpaper.change", "wallpaper.remove", "appearance.scheme", "appearance.background",
|
||||
"appearance.weight", "appearance.contrast"]) {
|
||||
"appearance.weight", "appearance.contrast", "appearance.effects"]) {
|
||||
expect(ids.has(id), `${id} is missing`).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,21 +1,33 @@
|
||||
import type { AgentPort, BrowserTab } from "../port/port";
|
||||
|
||||
const opening = new WeakMap<AgentPort, Promise<BrowserTab>>();
|
||||
const opening = new WeakMap<AgentPort, { page: Promise<BrowserTab>; pending: boolean }>();
|
||||
|
||||
/** The one way this window asks the kernel for a tab. What is being opened is
|
||||
* remembered until the kernel answers, so a column that needs a page to show
|
||||
* takes that one rather than asking for another. */
|
||||
export function openPage(port: AgentPort, url: string): Promise<BrowserTab> {
|
||||
const page = port.browserOpen(url, true);
|
||||
opening.set(port, page);
|
||||
const settled = () => {
|
||||
if (opening.get(port) === page) opening.delete(port);
|
||||
};
|
||||
const state = { page, pending: true };
|
||||
opening.set(port, state);
|
||||
const settled = () => { state.pending = false; };
|
||||
page.then(settled, settled);
|
||||
return page;
|
||||
}
|
||||
|
||||
/** A page for a column that has none: the one already on its way, else a blank. */
|
||||
export function pageForEmptyColumn(port: AgentPort): Promise<BrowserTab> {
|
||||
return opening.get(port) ?? openPage(port, "about:blank");
|
||||
const current = opening.get(port);
|
||||
return newestPage(port, current?.pending ? current.page : openPage(port, "about:blank"));
|
||||
}
|
||||
|
||||
async function newestPage(port: AgentPort, page: Promise<BrowserTab>): Promise<BrowserTab> {
|
||||
try {
|
||||
const tab = await page;
|
||||
const next = opening.get(port)?.page;
|
||||
return next && next !== page ? newestPage(port, next) : tab;
|
||||
} catch (error) {
|
||||
const next = opening.get(port)?.page;
|
||||
if (next && next !== page) return newestPage(port, next);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -112,6 +112,18 @@ describe("a coded notice with a payload", () => {
|
||||
expect(auto.querySelector(".find .t")?.textContent).toBe(t("费用显示币种已设为 {mode}", { mode: "auto" }));
|
||||
});
|
||||
|
||||
it("words the recovered inbox from its count and draws no raw payload line", () => {
|
||||
const box = draw({ level: "warn", code: "inbox_recovered", text: "Recovered 1 pending instruction(s). Inbox is paused — review with /queue before resuming.", detail: '{"count":1}' });
|
||||
expect(box.querySelector(".find .t")?.textContent).toBe(t("已恢复 {n} 条未完成的指令。待发送已暂停,请先在输入框上方的队列里查看,再点“继续派发”", { n: 1 }));
|
||||
expect(box.querySelector(".find .t")?.textContent).not.toContain("Recovered");
|
||||
expect(box.querySelector(".find .why")).toBeNull();
|
||||
});
|
||||
|
||||
it("keeps the kernel's English when the recovered-inbox payload is not readable", () => {
|
||||
const box = draw({ level: "warn", code: "inbox_recovered", text: "Recovered 2 pending instruction(s).", detail: "garbled" });
|
||||
expect(box.querySelector(".find .t")?.textContent).toContain("Recovered 2 pending");
|
||||
});
|
||||
|
||||
it("wraps the user's unapplied guidance in this build's sentence and keeps their words verbatim", () => {
|
||||
const box = draw({
|
||||
code: "unapplied_steer",
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { Item } from "../../state/session";
|
||||
import { t } from "../../i18n";
|
||||
import { NOTICE_TEXT } from "../../i18n/notices";
|
||||
import { EXTENSION_SKIPPED, extensionSkippedVars } from "../../i18n/extension_skipped";
|
||||
import { INBOX_RECOVERED, inboxRecoveredVars } from "../../i18n/inbox_recovered";
|
||||
import { FOLD_WHY, NO_CODE_WHY } from "../../i18n/compaction_why";
|
||||
import { workspaceLeaseDetail } from "../../i18n/workspace_lease";
|
||||
import { Sym } from "../Sym";
|
||||
@@ -33,8 +34,9 @@ export function NoticeCard({ item }: { item: Extract<Item, { t: "notice" }> }) {
|
||||
const reasoned = item.code !== undefined && REASONED.has(item.code);
|
||||
const reason = reasoned ? (item.detail ? FOLD_WHY[item.detail] : item.code === "compact_declined" ? NO_CODE_WHY : undefined) : undefined;
|
||||
const skipped = item.code === EXTENSION_SKIPPED ? extensionSkippedVars(item.detail) : undefined;
|
||||
const vars: Record<string, string | number> | undefined = stored ? { mode: item.detail || "auto" } : reason ? { why: t(reason) } : skipped;
|
||||
const wording = item.code && (!reasoned || vars) && (item.code !== EXTENSION_SKIPPED || skipped) ? NOTICE_TEXT[item.code] : undefined;
|
||||
const recovered = item.code === INBOX_RECOVERED ? inboxRecoveredVars(item.detail) : undefined;
|
||||
const vars: Record<string, string | number> | undefined = stored ? { mode: item.detail || "auto" } : reason ? { why: t(reason) } : skipped ?? recovered;
|
||||
const wording = item.code && (!reasoned || vars) && (item.code !== EXTENSION_SKIPPED || skipped) && (item.code !== INBOX_RECOVERED || recovered) ? NOTICE_TEXT[item.code] : undefined;
|
||||
const claim = item.workspaceLease;
|
||||
const detail = claim
|
||||
? workspaceLeaseDetail(claim, item.code !== "workspace_lease_resumed") || item.detail
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
// @vitest-environment jsdom
|
||||
import { act, cleanup, render, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import "../testkit";
|
||||
import { FOLD_DEFAULTS, setFoldModes } from "../../state/prefs";
|
||||
import { appendText, foldMessage } from "../../state/say";
|
||||
import type { Item } from "../../state/session";
|
||||
import { PaneShown } from "../shown";
|
||||
import { SayCard } from "./SayCard";
|
||||
|
||||
const frames = new Map<number, FrameRequestCallback>();
|
||||
let requested = 0;
|
||||
let canceled = 0;
|
||||
|
||||
beforeEach(() => {
|
||||
setFoldModes(FOLD_DEFAULTS);
|
||||
frames.clear();
|
||||
requested = 0;
|
||||
canceled = 0;
|
||||
vi.stubGlobal("requestAnimationFrame", (cb: FrameRequestCallback) => {
|
||||
frames.set(++requested, cb);
|
||||
return requested;
|
||||
});
|
||||
vi.stubGlobal("cancelAnimationFrame", (id: number) => {
|
||||
if (frames.delete(id)) canceled++;
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
setFoldModes(FOLD_DEFAULTS);
|
||||
vi.unstubAllGlobals();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
function drain() {
|
||||
act(() => {
|
||||
let delivered = 0;
|
||||
while (frames.size) {
|
||||
if (++delivered > 1000) throw new Error("reveal did not settle");
|
||||
const [id, cb] = frames.entries().next().value as [number, FrameRequestCallback];
|
||||
frames.delete(id);
|
||||
cb(performance.now());
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function draw(shown = true) {
|
||||
let items = appendText([], "考虑中", "reasoning");
|
||||
const card = () => <PaneShown.Provider value={shown}><SayCard item={items[0] as Extract<Item, { t: "say" }>} /></PaneShown.Provider>;
|
||||
const view = render(card());
|
||||
return {
|
||||
details: view.container.querySelector("details") as HTMLDetailsElement,
|
||||
text: () => view.container.querySelector(".tk")?.textContent,
|
||||
reasoning: () => (items[0] as Extract<Item, { t: "say" }>).reasoning,
|
||||
append: (text: string, field: "text" | "reasoning" = "reasoning") => {
|
||||
items = appendText(items, text, field);
|
||||
view.rerender(card());
|
||||
},
|
||||
show: (next: boolean) => { shown = next; view.rerender(card()); },
|
||||
finish: () => { items = foldMessage(items, { kind: "message" }); view.rerender(card()); },
|
||||
};
|
||||
}
|
||||
|
||||
describe("thinking text reveal", () => {
|
||||
it("requests no frames for bursts arriving in a folded thought", () => {
|
||||
const tick = vi.spyOn(window, "setInterval");
|
||||
const card = draw();
|
||||
expect(card.details.open).toBe(false);
|
||||
for (const chunk of ["分析".repeat(60), "继续".repeat(60), "结论".repeat(60)]) {
|
||||
card.append(chunk);
|
||||
drain();
|
||||
}
|
||||
expect(card.text()).toBe(card.reasoning());
|
||||
expect(requested).toBe(0);
|
||||
expect(tick).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it.each(["live", "open"] as const)("stops on a user fold under %s and resumes from the latest text", async (mode) => {
|
||||
setFoldModes({ thinking: mode });
|
||||
const card = draw();
|
||||
const summary = card.details.querySelector("summary") as HTMLElement;
|
||||
expect(card.details.open).toBe(true);
|
||||
card.append("展开时到达的内容".repeat(20));
|
||||
expect(frames.size).toBe(1);
|
||||
expect(card.text()).not.toBe(card.reasoning());
|
||||
|
||||
await userEvent.click(summary);
|
||||
await waitFor(() => expect(frames.size).toBe(0));
|
||||
expect(card.details.open).toBe(false);
|
||||
expect(canceled).toBe(1);
|
||||
const before = requested;
|
||||
card.append("折叠时到达的内容".repeat(20));
|
||||
expect(requested).toBe(before);
|
||||
expect(card.text()).toBe(card.reasoning());
|
||||
|
||||
await userEvent.click(summary);
|
||||
expect(card.details.open).toBe(true);
|
||||
expect(card.text()).toBe(card.reasoning());
|
||||
expect(frames.size).toBe(0);
|
||||
card.append("重新展开后的新内容".repeat(20));
|
||||
expect(frames.size).toBe(1);
|
||||
expect(card.text()).not.toBe(card.reasoning());
|
||||
drain();
|
||||
expect(card.text()).toBe(card.reasoning());
|
||||
expect(frames.size).toBe(0);
|
||||
});
|
||||
|
||||
it("cancels a live thought's backlog when the first answer folds it", () => {
|
||||
setFoldModes({ thinking: "live" });
|
||||
const card = draw();
|
||||
card.append("分析".repeat(60));
|
||||
expect(frames.size).toBe(1);
|
||||
card.append("答", "text");
|
||||
expect(card.details.open).toBe(false);
|
||||
expect(frames.size).toBe(0);
|
||||
expect(canceled).toBe(1);
|
||||
expect(card.text()).toBe(card.reasoning());
|
||||
});
|
||||
|
||||
it("keeps expanded thoughts current while their pane is hidden", () => {
|
||||
setFoldModes({ thinking: "open" });
|
||||
const card = draw();
|
||||
card.append("分析".repeat(60));
|
||||
expect(frames.size).toBe(1);
|
||||
card.show(false);
|
||||
expect(frames.size).toBe(0);
|
||||
const before = requested;
|
||||
card.append("后台".repeat(60));
|
||||
expect(requested).toBe(before);
|
||||
expect(card.text()).toBe(card.reasoning());
|
||||
card.show(true);
|
||||
expect(frames.size).toBe(0);
|
||||
card.append("前台".repeat(60));
|
||||
expect(frames.size).toBe(1);
|
||||
drain();
|
||||
expect(card.text()).toBe(card.reasoning());
|
||||
});
|
||||
|
||||
it("settles an expanded thought immediately on completion", () => {
|
||||
setFoldModes({ thinking: "open" });
|
||||
const card = draw();
|
||||
card.append("分析".repeat(60));
|
||||
expect(frames.size).toBe(1);
|
||||
card.finish();
|
||||
expect(card.details.open).toBe(true);
|
||||
expect(frames.size).toBe(0);
|
||||
expect(card.text()).toBe(card.reasoning());
|
||||
expect(card.details.querySelector(".caret")).toBeNull();
|
||||
});
|
||||
|
||||
it("shows expanded bursts immediately with reduced motion", () => {
|
||||
setFoldModes({ thinking: "open" });
|
||||
const media = matchMedia;
|
||||
vi.stubGlobal("matchMedia", (query: string) => ({ ...media(query), matches: true }));
|
||||
const card = draw();
|
||||
card.append("分析".repeat(60));
|
||||
expect(card.details.open).toBe(true);
|
||||
expect(card.text()).toBe(card.reasoning());
|
||||
expect(requested).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -99,7 +99,7 @@ export function SayCard({ item, afterAnswer, reply }: { item: Extract<Item, { t:
|
||||
// Thinking is the longest-running stream of the turn — 10s of it before the
|
||||
// first answer token, measured — so it gets the same paced reveal the answer
|
||||
// does rather than tracking the wire's bursts.
|
||||
const thought = useRevealed(item.reasoning ?? "", !item.done);
|
||||
const thought = useRevealed(item.reasoning ?? "", !item.done && open);
|
||||
return (
|
||||
<div className="call" data-k="say">
|
||||
<div className="g">
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import type { Checkpoint, RewindPlan, RewindResult, RewindScope } from "../../port/port";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import type { AgentPort, Checkpoint, RewindPlan, RewindResult, RewindScope } from "../../port/port";
|
||||
import type { Item } from "../../state/session";
|
||||
import { RewindControl } from "./RewindControl";
|
||||
import { CopyButton } from "../CopyButton";
|
||||
@@ -10,8 +10,18 @@ import { messageSource } from "../source";
|
||||
import { touchKeyboard } from "../touchKeyboard";
|
||||
import { useViewer } from "../../state/viewer";
|
||||
|
||||
const SAVED_IMAGE = /(?:^|\s)@(\.reasonix\/attachments\/clipboard-[\d.-]+\.(?:png|jpe?g|gif|webp|bmp|svg))(?=\s|$)/gi;
|
||||
|
||||
function SavedImage({ path, src }: { path: string; src: string }) {
|
||||
const [failed, setFailed] = useState(false);
|
||||
return <div className="user-image">
|
||||
{failed ? <span>{t("图片不可用")}</span> : <img src={src} alt={path.split("/").at(-1)} loading="lazy" width={240} height={160} onError={() => setFailed(true)} />}
|
||||
</div>;
|
||||
}
|
||||
|
||||
export function UserCard({
|
||||
item,
|
||||
port,
|
||||
cp,
|
||||
onResend,
|
||||
onPrepareRewind,
|
||||
@@ -19,10 +29,11 @@ export function UserCard({
|
||||
onUndoRewind,
|
||||
}: {
|
||||
item: Extract<Item, { t: "user" }>;
|
||||
port?: Pick<AgentPort, "workspaceImageURL">;
|
||||
cp?: Checkpoint;
|
||||
onResend?: (turn: number, text: string) => Promise<void>;
|
||||
onPrepareRewind?: (turn: number, scope: RewindScope) => Promise<RewindPlan>;
|
||||
onCommitRewind?: (planId: string) => Promise<RewindResult>;
|
||||
onCommitRewind?: (planId: string, text?: string) => Promise<RewindResult>;
|
||||
onUndoRewind?: (transactionId: string) => Promise<void>;
|
||||
}) {
|
||||
// A rewind needs a turn the kernel claimed, and a queued line has not
|
||||
@@ -35,6 +46,7 @@ export function UserCard({
|
||||
const source = messageSource(item.via, useViewer());
|
||||
const reopen = editable && draft === null;
|
||||
const rewind = !!(cp && onPrepareRewind && onCommitRewind && onUndoRewind);
|
||||
const images = useMemo(() => [...new Set(Array.from(item.text.matchAll(SAVED_IMAGE), (match) => match[1]))], [item.text]);
|
||||
|
||||
useEffect(() => {
|
||||
const el = box.current;
|
||||
@@ -69,7 +81,13 @@ export function UserCard({
|
||||
</div>}
|
||||
<div className="out">
|
||||
{draft === null ? (
|
||||
<div className="txt">{item.text}</div>
|
||||
<>
|
||||
{port && images.length > 0 && <div className="user-images">{images.map((path) => {
|
||||
const src = port.workspaceImageURL(path);
|
||||
return <SavedImage key={src} path={path} src={src} />;
|
||||
})}</div>}
|
||||
<div className="txt">{item.text}</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="reask">
|
||||
<textarea
|
||||
@@ -122,7 +140,7 @@ export function UserCard({
|
||||
</button>
|
||||
)}
|
||||
{rewind && (
|
||||
<RewindControl cp={cp!} compact onPrepare={onPrepareRewind!} onCommit={onCommitRewind!} onUndo={onUndoRewind!} />
|
||||
<RewindControl cp={cp!} compact onPrepare={onPrepareRewind!} onCommit={(planId) => onCommitRewind!(planId, item.text)} onUndo={onUndoRewind!} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { Tool } from "../port/wire";
|
||||
import { t } from "../i18n";
|
||||
import { toolFailed } from "./cards/outcome";
|
||||
|
||||
// One reading of "what left this context". The kernel marks a dispatch with a
|
||||
// profile; the tool name cannot say it, because every dispatcher is reached
|
||||
@@ -18,3 +19,13 @@ export function nestLabel(who: string | undefined, count: number | undefined, st
|
||||
if (who) return t("{name} 做了 {n} 步", { name: who, n: steps });
|
||||
return t("子代理做了 {n} 步", { n: steps });
|
||||
}
|
||||
|
||||
export type DelegateStatus = "running" | "refused" | "failed" | "done";
|
||||
|
||||
// A host refusal names a call that never started; "interrupted" is only true of
|
||||
// one that began and was cut short.
|
||||
export const delegateStatus = (running: boolean, tool: Tool): DelegateStatus =>
|
||||
running ? "running" : tool.refusalCode ? "refused" : toolFailed(tool) ? "failed" : "done";
|
||||
|
||||
export const delegateStatusLabel = (status: DelegateStatus): string =>
|
||||
status === "running" ? t("运行中") : status === "refused" ? t("未执行") : status === "failed" ? t("已中断") : t("已交付");
|
||||
Loaded 100 of 279 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user