mirror of
https://github.com/esengine/DeepSeek-Reasonix.git
synced 2026-11-07 10:45:59 -10:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c48ee0f80d | ||
|
|
10e929f3d5 | ||
|
|
a750f83859 | ||
|
|
ff77cb75ff | ||
|
|
780d5e04fa | ||
|
|
b4060aa4fd | ||
|
|
ce84fe9ff8 | ||
|
|
03aeb27e4e | ||
|
|
ea8f6c9ca6 | ||
|
|
217702190c | ||
|
|
71164ebf3c | ||
|
|
057894edec | ||
|
|
9f78a77e1c | ||
|
|
f9548ec3dd | ||
|
|
1b6929aad4 | ||
|
|
cad221e1d6 | ||
|
|
458fbbcabf | ||
|
|
d68d34c811 | ||
|
|
510915ba51 | ||
|
|
6db92a8904 | ||
|
|
586b3068c4 | ||
|
|
e3985f1971 | ||
|
|
d9884c795c | ||
|
|
6cc59ec78c | ||
|
|
fbcc83cdc6 | ||
|
|
174571c2a9 | ||
|
|
d1f096b206 | ||
|
|
3bce33bff3 | ||
|
|
2f27112578 | ||
|
|
a2ae61b9c0 | ||
|
|
17589e87d5 | ||
|
|
0818a41038 | ||
|
|
4473c23aa5 | ||
|
|
0a647e12ff | ||
|
|
f43cc2af32 | ||
|
|
101e82b5bb | ||
|
|
21219f6890 | ||
|
|
02a8abeec3 | ||
|
|
7fbcc02b92 | ||
|
|
17af885a67 | ||
|
|
a81212d745 | ||
|
|
cf1ca1ff06 | ||
|
|
7ee7a7fe8e | ||
|
|
c430ae5fe3 | ||
|
|
06ff93fda2 | ||
|
|
9ea570231a | ||
|
|
ba06cdd16f | ||
|
|
470fcfea8b | ||
|
|
79f5f1f90b | ||
|
|
5a1e3655d4 | ||
|
|
c58f236dd8 | ||
|
|
50014383fb | ||
|
|
47056a93ee | ||
|
|
6d98fbe7dd | ||
|
|
4395969694 | ||
|
|
d48ba5e65b | ||
|
|
8931d3e612 | ||
|
|
c51e648adf | ||
|
|
6dd1650dca | ||
|
|
0ee194932c | ||
|
|
bcfbe73bc0 | ||
|
|
06009af71b | ||
|
|
a0a0763fdd | ||
|
|
9ea250b5bf | ||
|
|
68eaa5069e | ||
|
|
6a30c3e3c6 | ||
|
|
0461622f70 | ||
|
|
67da7b1930 | ||
|
|
d5b7dd6809 | ||
|
|
4357242a98 | ||
|
|
26768e54d6 | ||
|
|
8734ee8d7a | ||
|
|
38e66576fc | ||
|
|
49d8c158bc | ||
|
|
931dda7061 | ||
|
|
abc23f9fea | ||
|
|
0626f197ff | ||
|
|
ee85dd3481 | ||
|
|
75910feefc | ||
|
|
4f548643b0 | ||
|
|
113a01e1dc | ||
|
|
9a26301d83 | ||
|
|
2e2b580bd8 | ||
|
|
fa818864ee | ||
|
|
c67fc94eb9 | ||
|
|
7e71b2dce4 | ||
|
|
2140e55491 | ||
|
|
7c93b587bf | ||
|
|
53a9bb6dc1 | ||
|
|
0fbcd55ebd | ||
|
|
fa278aedd5 | ||
|
|
567816d7d3 | ||
|
|
cfa255cc70 | ||
|
|
459bc7f4f7 | ||
|
|
be06337aa0 | ||
|
|
d21d91f6b3 | ||
|
|
6766cc0042 | ||
|
|
abbd487b95 | ||
|
|
62ddce6804 | ||
|
|
f9e3f3a5e5 | ||
|
|
a3518cc60a | ||
|
|
b92becac71 | ||
|
|
91ceafc038 | ||
|
|
8cb7d82c01 | ||
|
|
1682ab7fad | ||
|
|
a4f1729c22 | ||
|
|
0ea0c0e0fc | ||
|
|
dfe972034d | ||
|
|
d7b42cc072 | ||
|
|
95822af03a | ||
|
|
a1e598fee9 | ||
|
|
2ea1d8e01c | ||
|
|
2b97f139d8 | ||
|
|
9c371349a1 | ||
|
|
3478a1dc6e | ||
|
|
55d3ca45c1 | ||
|
|
fddc6512a8 | ||
|
|
5fb8925ca2 | ||
|
|
c8d2bac6fb | ||
|
|
f14e75f9b5 | ||
|
|
5b4f8f073a | ||
|
|
7340ac3a68 | ||
|
|
22491271c0 | ||
|
|
191abea93c | ||
|
|
2e46375e4f | ||
|
|
75a08f128c | ||
|
|
cbd9038b07 | ||
|
|
fe7c6c44e6 | ||
|
|
2874af8b0e | ||
|
|
6b9e50246a | ||
|
|
091c1b2865 | ||
|
|
bf0505e70e | ||
|
|
5851b802ad | ||
|
|
970c91d138 | ||
|
|
859ef4782c | ||
|
|
7f79763026 | ||
|
|
6d98474fdf | ||
|
|
8d1f46cb50 | ||
|
|
58aef50d47 | ||
|
|
f3287f7dfc | ||
|
|
25c6d4c10d | ||
|
|
00d7ece65a | ||
|
|
fba020d9bb | ||
|
|
4f4b43978b | ||
|
|
e67f256d5b | ||
|
|
ee40a3b90e | ||
|
|
0968731518 | ||
|
|
3b131d66a4 | ||
|
|
b71837c1e5 | ||
|
|
b88596a4fc | ||
|
|
9290b3bc0d | ||
|
|
fb77904a9f | ||
|
|
0522cd92e6 | ||
|
|
50e25025a0 | ||
|
|
0c9d2455be | ||
|
|
dc921776c3 | ||
|
|
912659f0dd | ||
|
|
4ef274d02e | ||
|
|
79af107700 | ||
|
|
c6a134cfa1 | ||
|
|
97b21b6ed7 | ||
|
|
8e9227ceff | ||
|
|
3b1a88db12 | ||
|
|
900aa3aa3a | ||
|
|
d18b248992 | ||
|
|
a6facc0adf | ||
|
|
c9d1517846 | ||
|
|
2e2c9248f5 | ||
|
|
36d69bf9e1 | ||
|
|
8a4281abfb | ||
|
|
268ccef0a8 | ||
|
|
9eb14cc8cc | ||
|
|
59b2b75e62 | ||
|
|
1724a154f8 | ||
|
|
4c3eabc0ab | ||
|
|
4a0b3888cb | ||
|
|
6f29d3d960 | ||
|
|
b7ee3207da | ||
|
|
f808837d43 | ||
|
|
a201497806 | ||
|
|
92339c8580 | ||
|
|
1ca349830d | ||
|
|
beb1b0082e | ||
|
|
76552fb5a4 | ||
|
|
beeb2865f0 | ||
|
|
ded403ab37 | ||
|
|
1d3267a44c | ||
|
|
95de31ed20 | ||
|
|
9add93a143 | ||
|
|
1c1c18bd39 | ||
|
|
46efe8eba4 | ||
|
|
248071332a | ||
|
|
6646758aad | ||
|
|
8d5655f96d | ||
|
|
2ca8e60a01 | ||
|
|
ac3673ff72 | ||
|
|
7613fd5d3e | ||
|
|
172d61bba6 | ||
|
|
0d85a11055 | ||
|
|
29fb53efca | ||
|
|
987f44cafa | ||
|
|
0eb5a7ca2c | ||
|
|
c32b46c9fd | ||
|
|
51f3100546 | ||
|
|
8aed0bc8ab | ||
|
|
467707ce4b | ||
|
|
de06daf75a | ||
|
|
fd6b917c80 | ||
|
|
22ca8f3f17 | ||
|
|
bb7d51e7a9 | ||
|
|
8c644fa972 | ||
|
|
504101cb17 | ||
|
|
2a383830b6 | ||
|
|
119dbd72ce | ||
|
|
2b23801457 | ||
|
|
69a046fed3 | ||
|
|
ec6ffb4887 | ||
|
|
78069f638a | ||
|
|
f54da89d15 | ||
|
|
0b76ca0f63 |
No files matched your search
@@ -88,7 +88,7 @@ jobs:
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
cache: true
|
||||
- run: node --test scripts/ci-platform-scope.test.mjs scripts/cli-archive-build.test.mjs scripts/release-credits.test.mjs scripts/update-contributors.test.mjs scripts/update-acknowledgments.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
|
||||
- run: node --test scripts/ci-platform-scope.test.mjs scripts/cli-archive-build.test.mjs scripts/release-credits.test.mjs scripts/update-contributors.test.mjs scripts/update-acknowledgments.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/withdraw-version.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
|
||||
|
||||
@@ -136,6 +136,7 @@ jobs:
|
||||
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/firstscreen-fonts.mjs
|
||||
node perf/contrast.mjs
|
||||
node perf/wheel.mjs
|
||||
node perf/branch-tooltip.mjs
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
name: Withdraw a Studio version
|
||||
|
||||
# Pulls a published Studio release out of the update catalog (or puts it back),
|
||||
# so installs stop being offered it without waiting for a new release. Only the
|
||||
# catalog changes: the release's files stay where they are, and a withdrawn
|
||||
# release can be restored with the same run.
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version:
|
||||
description: "Version to withdraw or restore, for example 2.36.0"
|
||||
required: true
|
||||
type: string
|
||||
reason:
|
||||
description: "Why it is withdrawn (shown to users on that version); ignored when restoring"
|
||||
required: false
|
||||
type: string
|
||||
restore:
|
||||
description: "Put a withdrawn version back instead of withdrawing it"
|
||||
required: false
|
||||
type: boolean
|
||||
default: false
|
||||
|
||||
concurrency:
|
||||
group: studio-catalog
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
withdraw:
|
||||
name: ${{ inputs.restore && 'restore' || 'withdraw' }} ${{ inputs.version }}
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
environment: studio-release
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
with:
|
||||
ref: studio
|
||||
persist-credentials: false
|
||||
|
||||
- name: Update the Studio catalog
|
||||
env:
|
||||
VERSION: ${{ inputs.version }}
|
||||
REASON: ${{ inputs.reason }}
|
||||
RESTORE: ${{ inputs.restore }}
|
||||
R2_BUCKET: ${{ secrets.R2_BUCKET }}
|
||||
HAS_R2: ${{ secrets.R2_ACCESS_KEY_ID != '' && secrets.R2_SECRET_ACCESS_KEY != '' && secrets.R2_ACCOUNT_ID != '' && secrets.R2_BUCKET != '' }}
|
||||
run: |
|
||||
if [ "$HAS_R2" != "true" ]; then
|
||||
echo "::error::R2 credentials are not configured, so the catalog cannot be changed"
|
||||
exit 1
|
||||
fi
|
||||
if ! [[ "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.]+)?$ ]]; then
|
||||
echo "::error::version must look like 2.36.0"
|
||||
exit 1
|
||||
fi
|
||||
aws configure set aws_access_key_id "${{ secrets.R2_ACCESS_KEY_ID }}"
|
||||
aws configure set aws_secret_access_key "${{ secrets.R2_SECRET_ACCESS_KEY }}"
|
||||
aws configure set default.region auto
|
||||
endpoint="https://${{ secrets.R2_ACCOUNT_ID }}.r2.cloudflarestorage.com"
|
||||
at="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
|
||||
current=/tmp/studio-versions-current.json
|
||||
next=/tmp/studio-versions-next.json
|
||||
aws s3 cp "s3://${R2_BUCKET}/studio/versions.json" "$current" --endpoint-url "$endpoint"
|
||||
if [ "$RESTORE" = "true" ]; then
|
||||
bash scripts/withdraw-version.sh "$current" --restore "$VERSION" "$at" > "$next"
|
||||
jq -e --arg v "$VERSION" '.versions | map(select((.version | ltrimstr("v")) == $v)) | length == 1' "$next" >/dev/null
|
||||
else
|
||||
bash scripts/withdraw-version.sh "$current" "$VERSION" "$REASON" "$at" > "$next"
|
||||
jq -e --arg v "$VERSION" '(.versions | map(select((.version | ltrimstr("v")) == $v)) | length == 0) and (.withdrawn | map(select((.version | ltrimstr("v")) == $v)) | length == 1)' "$next" >/dev/null
|
||||
fi
|
||||
aws s3 cp "$next" "s3://${R2_BUCKET}/studio/versions.json" \
|
||||
--endpoint-url "$endpoint" \
|
||||
--content-type "application/json; charset=utf-8" \
|
||||
--cache-control "public, max-age=300, stale-if-error=86400"
|
||||
echo "Studio catalog updated for $VERSION (restore=$RESTORE)"
|
||||
|
||||
- name: Keep the GitHub release off the latest slot
|
||||
if: ${{ !inputs.restore }}
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
VERSION: ${{ inputs.version }}
|
||||
run: gh release edit "studio-v${VERSION}" --prerelease --latest=false --repo "$GITHUB_REPOSITORY"
|
||||
@@ -59,7 +59,7 @@
|
||||
|
||||
## ❤️Sponsor
|
||||
|
||||
<table><tr><td width="180" align="center" valign="middle"><a href="https://www.rightapi.ai/register?aff=e083bf73" target="_blank" rel="sponsored noopener noreferrer"><img src="./.github/sponsor/rightcode.png" alt="Right Code" width="64"></a></td><td valign="middle">Thanks to Right Code for sponsoring this project! Right Code reliably provides routing services for models such as Claude Code, Codex, and Gemini, with both pay-as-you-go and monthly subscription billing options available. Invoices are available upon top-up, and enterprise and team users can receive dedicated one-on-one support.</td></tr><tr><td width="180" align="center" valign="middle"><a href="https://www.packyapi.ai/register?aff=rIbQ" target="_blank" rel="sponsored noopener noreferrer"><picture><source media="(prefers-color-scheme: dark)" srcset="./.github/sponsor/packycode-dark.svg"><img src="./.github/sponsor/packycode-light.svg" alt="PackyCode" width="80"></picture></a></td><td valign="middle">Thanks to PackyCode for sponsoring this project! Access leading AI models through PackyCode with one API endpoint and one API key, with automatic failover and dedicated high-speed routes for Codex and Claude Code.</td></tr></table>
|
||||
<table><tr><td width="180" align="center" valign="middle"><a href="https://www.rightapi.ai/register?aff=e083bf73" target="_blank" rel="sponsored noopener noreferrer"><img src="./.github/sponsor/rightcode.png" alt="Right Code" width="64"></a></td><td valign="middle">Thanks to Right Code for sponsoring this project! Right Code reliably provides routing services for models such as Claude Code, Codex, and Gemini, with both pay-as-you-go and monthly subscription billing options available. Invoices are available upon top-up, and enterprise and team users can receive dedicated one-on-one support. Register via <a href="https://www.rightapi.ai/register?aff=e083bf73" target="_blank" rel="sponsored noopener noreferrer">this link</a>.</td></tr><tr><td width="180" align="center" valign="middle"><a href="https://www.packyapi.ai/register?aff=rIbQ" target="_blank" rel="sponsored noopener noreferrer"><picture><source media="(prefers-color-scheme: dark)" srcset="./.github/sponsor/packycode-dark.svg"><img src="./.github/sponsor/packycode-light.svg" alt="PackyCode" width="80"></picture></a></td><td valign="middle">Thanks to PackyCode for sponsoring this project! Access leading AI models through PackyCode with one API endpoint and one API key, with automatic failover and dedicated high-speed routes for Codex and Claude Code. Register via <a href="https://www.packyapi.ai/register?aff=rIbQ" target="_blank" rel="sponsored noopener noreferrer">this link</a>.</td></tr></table>
|
||||
|
||||
## What it is
|
||||
|
||||
@@ -278,9 +278,9 @@ A small list of folks whose work has shaped Reasonix the most — the current to
|
||||
| --- | --- | --- | --- |
|
||||
| [**SivanCola**](https://github.com/SivanCola) | [**KHG420**](https://github.com/KHG420) | [**ttmouse**](https://github.com/ttmouse) | [**lifu963**](https://github.com/lifu963) |
|
||||
| [**HUQIANTAO**](https://github.com/HUQIANTAO) | [**Harbor404**](https://github.com/Harbor404) | [**GTC2080**](https://github.com/GTC2080) | [**mchenziyi**](https://github.com/mchenziyi) |
|
||||
| [**Li-Charles-One**](https://github.com/Li-Charles-One) | [**light-front-theory**](https://github.com/light-front-theory) | [**eghrhegpe**](https://github.com/eghrhegpe) | [**Bernardxu123**](https://github.com/Bernardxu123) |
|
||||
| [**HaoyueQin**](https://github.com/HaoyueQin) | [**chuiwenwei**](https://github.com/chuiwenwei) | [**BuGlessRB**](https://github.com/BuGlessRB) | [**CVEngineer66**](https://github.com/CVEngineer66) |
|
||||
| [**JesonChou**](https://github.com/JesonChou) | [**lanshi17**](https://github.com/lanshi17) | [**par73e**](https://github.com/par73e) | [**SuMuxi66**](https://github.com/SuMuxi66) |
|
||||
| [**Li-Charles-One**](https://github.com/Li-Charles-One) | [**light-front-theory**](https://github.com/light-front-theory) | [**XTLine**](https://github.com/XTLine) | [**eghrhegpe**](https://github.com/eghrhegpe) |
|
||||
| [**noreply**](https://github.com/noreply) | [**chuiwenwei**](https://github.com/chuiwenwei) | [**xxoingr**](https://github.com/xxoingr) | [**Bernardxu123**](https://github.com/Bernardxu123) |
|
||||
| [**HaoyueQin**](https://github.com/HaoyueQin) | [**BuGlessRB**](https://github.com/BuGlessRB) | [**CVEngineer66**](https://github.com/CVEngineer66) | [**JesonChou**](https://github.com/JesonChou) |
|
||||
<!-- reasonix-top-contributors:end -->
|
||||
|
||||
Special thanks to [**Bernardxu123**](https://github.com/Bernardxu123) for designing the project logo and intro video.
|
||||
|
||||
+4
-4
@@ -57,7 +57,7 @@
|
||||
|
||||
## ❤️赞助商
|
||||
|
||||
<table><tr><td width="180" align="center" valign="middle"><a href="https://www.rightapi.ai/register?aff=e083bf73" target="_blank" rel="sponsored noopener noreferrer"><img src="./.github/sponsor/rightcode.png" alt="Right Code" width="64"></a></td><td valign="middle">感谢 Right Code 赞助了本项目!Right Code 稳定提供 Claude Code、Codex、Gemini 等模型的中转服务,并可选按量、包月两种计费模式。充值即可开票,企业、团队用户一对一对接。</td></tr><tr><td width="180" align="center" valign="middle"><a href="https://www.packyapi.ai/register?aff=rIbQ" target="_blank" rel="sponsored noopener noreferrer"><picture><source media="(prefers-color-scheme: dark)" srcset="./.github/sponsor/packycode-dark.svg"><img src="./.github/sponsor/packycode-light.svg" alt="PackyCode" width="80"></picture></a></td><td valign="middle">感谢 PackyCode 赞助了本项目!PackyCode 是一家稳定、高效的 API 中转服务商,统一域名、统一密钥、智能容灾切换。</td></tr></table>
|
||||
<table><tr><td width="180" align="center" valign="middle"><a href="https://www.rightapi.ai/register?aff=e083bf73" target="_blank" rel="sponsored noopener noreferrer"><img src="./.github/sponsor/rightcode.png" alt="Right Code" width="64"></a></td><td valign="middle">感谢 Right Code 赞助了本项目!Right Code 稳定提供 Claude Code、Codex、Gemini 等模型的中转服务,并可选按量、包月两种计费模式。充值即可开票,企业、团队用户一对一对接。通过<a href="https://www.rightapi.ai/register?aff=e083bf73" target="_blank" rel="sponsored noopener noreferrer">此链接</a>注册。</td></tr><tr><td width="180" align="center" valign="middle"><a href="https://www.packyapi.ai/register?aff=rIbQ" target="_blank" rel="sponsored noopener noreferrer"><picture><source media="(prefers-color-scheme: dark)" srcset="./.github/sponsor/packycode-dark.svg"><img src="./.github/sponsor/packycode-light.svg" alt="PackyCode" width="80"></picture></a></td><td valign="middle">感谢 PackyCode 赞助了本项目!PackyCode 是一家稳定、高效的 API 中转服务商,统一域名、统一密钥、智能容灾切换。通过<a href="https://www.packyapi.ai/register?aff=rIbQ" target="_blank" rel="sponsored noopener noreferrer">此链接</a>注册。</td></tr></table>
|
||||
|
||||
## 它是什么
|
||||
|
||||
@@ -260,9 +260,9 @@ CLI 进阶用法和详细配置见 **[CLI 命令参考](./docs/CLI.zh-CN.md)**
|
||||
| --- | --- | --- | --- |
|
||||
| [**SivanCola**](https://github.com/SivanCola) | [**KHG420**](https://github.com/KHG420) | [**ttmouse**](https://github.com/ttmouse) | [**lifu963**](https://github.com/lifu963) |
|
||||
| [**HUQIANTAO**](https://github.com/HUQIANTAO) | [**Harbor404**](https://github.com/Harbor404) | [**GTC2080**](https://github.com/GTC2080) | [**mchenziyi**](https://github.com/mchenziyi) |
|
||||
| [**Li-Charles-One**](https://github.com/Li-Charles-One) | [**light-front-theory**](https://github.com/light-front-theory) | [**eghrhegpe**](https://github.com/eghrhegpe) | [**Bernardxu123**](https://github.com/Bernardxu123) |
|
||||
| [**HaoyueQin**](https://github.com/HaoyueQin) | [**chuiwenwei**](https://github.com/chuiwenwei) | [**BuGlessRB**](https://github.com/BuGlessRB) | [**CVEngineer66**](https://github.com/CVEngineer66) |
|
||||
| [**JesonChou**](https://github.com/JesonChou) | [**lanshi17**](https://github.com/lanshi17) | [**par73e**](https://github.com/par73e) | [**SuMuxi66**](https://github.com/SuMuxi66) |
|
||||
| [**Li-Charles-One**](https://github.com/Li-Charles-One) | [**light-front-theory**](https://github.com/light-front-theory) | [**XTLine**](https://github.com/XTLine) | [**eghrhegpe**](https://github.com/eghrhegpe) |
|
||||
| [**noreply**](https://github.com/noreply) | [**chuiwenwei**](https://github.com/chuiwenwei) | [**xxoingr**](https://github.com/xxoingr) | [**Bernardxu123**](https://github.com/Bernardxu123) |
|
||||
| [**HaoyueQin**](https://github.com/HaoyueQin) | [**BuGlessRB**](https://github.com/BuGlessRB) | [**CVEngineer66**](https://github.com/CVEngineer66) | [**JesonChou**](https://github.com/JesonChou) |
|
||||
<!-- reasonix-top-contributors:end -->
|
||||
|
||||
特别感谢 [**Bernardxu123**](https://github.com/Bernardxu123) 设计的项目 logo和开场视频。
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// batchInfo is what the mode itself knows before any run happens. Everything
|
||||
// else in the manifest is derived from the runs that were written, so a figure
|
||||
// in it cannot drift from the artifact beside it.
|
||||
type batchInfo struct {
|
||||
Mode string
|
||||
Planned int
|
||||
Started time.Time
|
||||
Dry bool
|
||||
// Repo is the checkout the batch ran from, captured before the harness
|
||||
// isolates its workspace: git cannot describe a directory that is gone.
|
||||
Repo string
|
||||
}
|
||||
|
||||
// batchManifest is written beside results.jsonl so a later reader does not have
|
||||
// to reconstruct what the batch was from a terminal scrollback.
|
||||
type batchManifest struct {
|
||||
SchemaVersion int `json:"schema_version"`
|
||||
Mode string `json:"mode"`
|
||||
StartedAt string `json:"started_at"`
|
||||
FinishedAt string `json:"finished_at"`
|
||||
GitCommit string `json:"git_commit"`
|
||||
GitDirty *bool `json:"git_dirty,omitempty"`
|
||||
Model string `json:"model"`
|
||||
Window int `json:"window"`
|
||||
Generations int `json:"generations"`
|
||||
TaskIDs []string `json:"task_ids"`
|
||||
Arms []string `json:"arms"`
|
||||
PlannedRuns int `json:"planned_runs"`
|
||||
CompletedRuns int `json:"completed_runs"`
|
||||
// Usage totals are absent when nothing reported any: an unmeasured cost must
|
||||
// not read as a measured zero. UsageReportedRuns stays, because zero there
|
||||
// is a fact about the batch.
|
||||
UsageReportedRuns int `json:"usage_reported_runs"`
|
||||
UsageReportedRequests int `json:"usage_reported_requests,omitempty"`
|
||||
PromptTokens int `json:"prompt_tokens,omitempty"`
|
||||
CompletionTokens int `json:"completion_tokens,omitempty"`
|
||||
CacheHitTokens int `json:"cache_hit_tokens,omitempty"`
|
||||
CacheMissTokens int `json:"cache_miss_tokens,omitempty"`
|
||||
UsageEstimated bool `json:"usage_estimated,omitempty"`
|
||||
ResultsSHA256 string `json:"results_sha256"`
|
||||
TrajectoriesSHA256 string `json:"trajectories_sha256"`
|
||||
}
|
||||
|
||||
// gitCommitAndDirty reports the checkout the batch ran from. Unknown is written
|
||||
// rather than omitted, so an absent field is never mistaken for a clean tree.
|
||||
func gitCommitAndDirty(repo string) (commit string, dirty *bool) {
|
||||
commit = "unknown"
|
||||
if repo == "" {
|
||||
return commit, nil
|
||||
}
|
||||
in := func(args ...string) ([]byte, error) {
|
||||
cmd := exec.Command("git", args...)
|
||||
cmd.Dir = repo
|
||||
return cmd.Output()
|
||||
}
|
||||
if out, err := in("rev-parse", "--short", "HEAD"); err == nil {
|
||||
commit = strings.TrimSpace(string(out))
|
||||
}
|
||||
if out, err := in("status", "--porcelain"); err == nil {
|
||||
state := strings.TrimSpace(string(out)) != ""
|
||||
dirty = &state
|
||||
}
|
||||
return commit, dirty
|
||||
}
|
||||
|
||||
func fileSHA256(path string) (string, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return fmt.Sprintf("%x", sha256.Sum256(data)), nil
|
||||
}
|
||||
|
||||
func sortedKeys(set map[string]bool) []string {
|
||||
out := make([]string, 0, len(set))
|
||||
for k := range set {
|
||||
out = append(out, k)
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// writeBatchManifest records the batch beside the runs it describes. It runs
|
||||
// last, so its digests are of what is already on disk.
|
||||
func writeBatchManifest(root string, info batchInfo, all []contextMetrics) error {
|
||||
ids, arms := map[string]bool{}, map[string]bool{}
|
||||
var prompt, completion, cacheHit, cacheMiss, requests, reported int
|
||||
estimated := false
|
||||
for _, m := range all {
|
||||
ids[m.Task] = true
|
||||
arms[m.Arm] = true
|
||||
if m.UsageReportedRequests > 0 {
|
||||
reported++
|
||||
requests += m.UsageReportedRequests
|
||||
prompt += m.PromptTokens
|
||||
completion += m.CompletionTokens
|
||||
cacheHit += m.CacheHitTokens
|
||||
cacheMiss += m.CacheMissTokens
|
||||
estimated = estimated || m.UsageEstimated
|
||||
}
|
||||
}
|
||||
commit, dirty := gitCommitAndDirty(info.Repo)
|
||||
model := runModel
|
||||
if info.Dry {
|
||||
model = "scripted"
|
||||
}
|
||||
manifest := batchManifest{
|
||||
SchemaVersion: 1, Mode: info.Mode,
|
||||
StartedAt: info.Started.UTC().Format(time.RFC3339),
|
||||
FinishedAt: time.Now().UTC().Format(time.RFC3339),
|
||||
GitCommit: commit, GitDirty: dirty,
|
||||
Model: model, Window: fixtureWindow, Generations: fixtureGenerations,
|
||||
TaskIDs: sortedKeys(ids), Arms: sortedKeys(arms),
|
||||
PlannedRuns: info.Planned, CompletedRuns: len(all),
|
||||
UsageReportedRuns: reported, UsageReportedRequests: requests,
|
||||
PromptTokens: prompt, CompletionTokens: completion,
|
||||
CacheHitTokens: cacheHit, CacheMissTokens: cacheMiss, UsageEstimated: estimated,
|
||||
}
|
||||
var err error
|
||||
if manifest.ResultsSHA256, err = fileSHA256(filepath.Join(root, "results.jsonl")); err != nil {
|
||||
return err
|
||||
}
|
||||
if manifest.TrajectoriesSHA256, err = fileSHA256(filepath.Join(root, "trajectories.jsonl")); err != nil {
|
||||
return err
|
||||
}
|
||||
data, err := json.MarshalIndent(manifest, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return os.WriteFile(filepath.Join(root, "batch.json"), append(data, '\n'), 0o644)
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func seedArtifacts(t *testing.T, root string) {
|
||||
t.Helper()
|
||||
for _, name := range []string{"results.jsonl", "trajectories.jsonl"} {
|
||||
if err := os.WriteFile(filepath.Join(root, name), []byte("{}\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func readManifest(t *testing.T, root string) (batchManifest, string) {
|
||||
t.Helper()
|
||||
data, err := os.ReadFile(filepath.Join(root, "batch.json"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var got batchManifest
|
||||
if err := json.Unmarshal(data, &got); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return got, string(data)
|
||||
}
|
||||
|
||||
func TestBatchManifestRecordsWhatTheBatchWas(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
seedArtifacts(t, root)
|
||||
all := []contextMetrics{
|
||||
{Task: "b", Arm: "index-off"},
|
||||
{Task: "a", Arm: "index-off"},
|
||||
{Task: "a", Arm: "index-half", UsageReportedRequests: 2, PromptTokens: 100,
|
||||
CompletionTokens: 10, CacheHitTokens: 60, CacheMissTokens: 40},
|
||||
}
|
||||
if err := writeBatchManifest(root, batchInfo{Mode: "run-index", Planned: 4, Started: time.Now().Add(-time.Minute)}, all); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, _ := readManifest(t, root)
|
||||
if got.SchemaVersion != 1 || got.Mode != "run-index" || got.PlannedRuns != 4 || got.CompletedRuns != 3 {
|
||||
t.Fatalf("manifest header = %+v", got)
|
||||
}
|
||||
if strings.Join(got.TaskIDs, ",") != "a,b" || strings.Join(got.Arms, ",") != "index-half,index-off" {
|
||||
t.Fatalf("ids/arms = %v / %v, want sorted and unique", got.TaskIDs, got.Arms)
|
||||
}
|
||||
if got.UsageReportedRuns != 1 || got.UsageReportedRequests != 2 || got.PromptTokens != 100 ||
|
||||
got.CompletionTokens != 10 || got.CacheHitTokens != 60 || got.CacheMissTokens != 40 {
|
||||
t.Fatalf("usage totals = %+v", got)
|
||||
}
|
||||
// No repo means git could not describe the checkout: unknown commit, and no
|
||||
// dirty flag at all, because "clean" is a claim this batch cannot make.
|
||||
if got.GitCommit != "unknown" || got.GitDirty != nil {
|
||||
t.Fatalf("git state = %q / %v, want unknown and no dirty flag", got.GitCommit, got.GitDirty)
|
||||
}
|
||||
want := fmt.Sprintf("%x", sha256.Sum256([]byte("{}\n")))
|
||||
if got.ResultsSHA256 != want || got.TrajectoriesSHA256 != want {
|
||||
t.Fatalf("digests = %q / %q, want %q", got.ResultsSHA256, got.TrajectoriesSHA256, want)
|
||||
}
|
||||
}
|
||||
|
||||
// A dry batch pays nothing, so its cost fields must be absent rather than zero:
|
||||
// an unmeasured spend must not read as a measured one.
|
||||
func TestBatchManifestLeavesUnreportedUsageAbsent(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
seedArtifacts(t, root)
|
||||
all := []contextMetrics{{Task: "a", Arm: "index-off"}, {Task: "a", Arm: "index-half"}}
|
||||
if err := writeBatchManifest(root, batchInfo{Mode: "run-index", Planned: 2, Started: time.Now(), Dry: true}, all); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, raw := readManifest(t, root)
|
||||
if got.UsageReportedRuns != 0 {
|
||||
t.Fatalf("usage_reported_runs = %d, want 0 reported", got.UsageReportedRuns)
|
||||
}
|
||||
if got.Model != "scripted" {
|
||||
t.Fatalf("dry batch model = %q, want scripted", got.Model)
|
||||
}
|
||||
for _, absent := range []string{"usage_reported_requests", "prompt_tokens", "completion_tokens", "cache_hit_tokens"} {
|
||||
if strings.Contains(raw, absent) {
|
||||
t.Fatalf("a dry batch wrote %q:\n%s", absent, raw)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -54,9 +54,9 @@ func main() {
|
||||
case "preflight":
|
||||
os.Exit(runPreflight(tasks, root))
|
||||
case "run-search":
|
||||
os.Exit(runExperiment(experimentSearch, root, *dry, tasks))
|
||||
os.Exit(runExperiment(*mode, experimentSearch, root, *dry, tasks))
|
||||
case "run-boundary":
|
||||
os.Exit(runBoundaries(root))
|
||||
os.Exit(runBoundaries(*mode, root))
|
||||
case "snippet":
|
||||
os.Exit(runSnippetAudit(root, 100))
|
||||
case "adversarial":
|
||||
@@ -69,7 +69,7 @@ func main() {
|
||||
// stays measurable on its own.
|
||||
batch = efficiencyTasks()
|
||||
}
|
||||
os.Exit(runExperiment(experimentIndex, root, *dry, batch))
|
||||
os.Exit(runExperiment(*mode, experimentIndex, root, *dry, batch))
|
||||
default:
|
||||
fmt.Fprintf(os.Stderr, "unknown mode %q\n", *mode)
|
||||
os.Exit(2)
|
||||
|
||||
@@ -216,7 +216,10 @@ func isolateWorkspace() (func(), error) {
|
||||
|
||||
// The two arms get separate nonces: reusing one would leave the first arm's
|
||||
// answer in a host-readable history for the second to find.
|
||||
func runBoundaries(root string) int {
|
||||
func runBoundaries(mode, root string) int {
|
||||
started := time.Now()
|
||||
planned := 0
|
||||
repo, _ := os.Getwd()
|
||||
root, err := filepath.Abs(root)
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
@@ -243,6 +246,7 @@ func runBoundaries(root string) int {
|
||||
has bool
|
||||
}{{cue, true}, {noCue, false}} {
|
||||
name := "index-" + side.scale
|
||||
planned++
|
||||
m, err := runOne(p, t, armFor(side.scale, false), name, root, side.has, false)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "%s [%s]: %v\n", t.ID, name, err)
|
||||
@@ -260,7 +264,7 @@ func runBoundaries(root string) int {
|
||||
}
|
||||
reportDeltas(cueSide, noCueSide)
|
||||
fmt.Print(reportStopping(all))
|
||||
return writeResults(root, all)
|
||||
return writeResults(root, batchInfo{Mode: mode, Planned: planned, Started: started, Repo: repo}, all)
|
||||
}
|
||||
|
||||
// reportDeltas is the paired reading: each task against itself, then pooled.
|
||||
@@ -302,7 +306,10 @@ func deltaBool(a, b bool) string {
|
||||
// runExperiment runs one experiment's tasks across its arms. A dry run drives
|
||||
// the same pipeline with a scripted provider: it proves fixture, run, scoring
|
||||
// and report hold together before any of it is paid for.
|
||||
func runExperiment(experiment, root string, dry bool, tasks []contextTask) int {
|
||||
func runExperiment(mode, experiment, root string, dry bool, tasks []contextTask) int {
|
||||
started := time.Now()
|
||||
planned := 0
|
||||
repo, _ := os.Getwd()
|
||||
root, err := filepath.Abs(root)
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
@@ -338,6 +345,7 @@ func runExperiment(experiment, root string, dry bool, tasks []contextTask) int {
|
||||
// test: a bad minute would land entirely on one arm.
|
||||
for _, arm := range shuffledArms(t) {
|
||||
cueVisible := t.Experiment == experimentIndex && cueExpectedVisible(t, arm.scale)
|
||||
planned++
|
||||
m, err := runOne(p, t, armFor(arm.scale, arm.searchOff), arm.name, root, cueVisible, dry)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "%s [%s]: %v\n", t.ID, arm.name, err)
|
||||
@@ -359,7 +367,7 @@ func runExperiment(experiment, root string, dry bool, tasks []contextTask) int {
|
||||
if experiment == experimentIndex {
|
||||
reportBoundaries(all)
|
||||
}
|
||||
return writeResults(root, all)
|
||||
return writeResults(root, batchInfo{Mode: mode, Planned: planned, Started: started, Dry: dry, Repo: repo}, all)
|
||||
}
|
||||
|
||||
func cueExpectedVisible(t contextTask, scale string) bool {
|
||||
@@ -414,7 +422,7 @@ func writeTrajectories(root string, all []contextMetrics) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func writeResults(root string, all []contextMetrics) int {
|
||||
func writeResults(root string, info batchInfo, all []contextMetrics) int {
|
||||
path := filepath.Join(root, "results.jsonl")
|
||||
f, err := os.Create(path)
|
||||
if err != nil {
|
||||
@@ -432,7 +440,11 @@ func writeResults(root string, all []contextMetrics) int {
|
||||
fmt.Fprintln(os.Stderr, "write trajectories:", err)
|
||||
return 2
|
||||
}
|
||||
fmt.Printf("\n%d runs written to %s (with trajectories.jsonl)\n", len(all), path)
|
||||
if err := writeBatchManifest(root, info, all); err != nil {
|
||||
fmt.Fprintln(os.Stderr, "write batch manifest:", err)
|
||||
return 2
|
||||
}
|
||||
fmt.Printf("\n%d runs written to %s (with trajectories.jsonl and batch.json)\n", len(all), path)
|
||||
return 0
|
||||
}
|
||||
|
||||
|
||||
+18
-5
@@ -290,8 +290,8 @@ func renderDiff(r diffReport) string {
|
||||
if r.runErr != nil {
|
||||
fmt.Fprintf(&b, "\n<sub>agent run note: %v</sub>\n", r.runErr)
|
||||
}
|
||||
if r.analysisErr != nil {
|
||||
fmt.Fprintf(&b, "\n**Analysis did not finish: %v.** Pin and mutation figures are absent, not zero.\n", r.analysisErr)
|
||||
if line := analysisFailureLine(r); line != "" {
|
||||
b.WriteString(line)
|
||||
}
|
||||
fmt.Fprintf(&b, "\n<sub>Pass = the agent added ≥1 test, the affected packages are green, AND ≥1 new test fails when the PR's source is reverted. \"By assertion\" pins are strong (they check changed behavior); \"by compile only\" pins just need a PR-added symbol — and since Go compiles per package, one compile-coupled test marks every test in its package that way. Mutation is the behavioral signal for additive PRs: each changed function's return is replaced with zero values and the new tests are re-run; \"caught\" means a test asserts that output, \"survived\" means it doesn't. Read the generated tests above to judge the rest.</sub>\n")
|
||||
return b.String()
|
||||
@@ -342,9 +342,6 @@ func differentialPerTest(repo, base string, srcFiles []string, refs []testRef) (
|
||||
_ = os.Remove(filepath.Join(repo, filepath.FromSlash(f)))
|
||||
}
|
||||
}
|
||||
if err := treeMatches(repo, base, srcFiles); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Restore source on the way out; a tree left on `base` would mask the PR for
|
||||
// later steps, so a failed restore is this analysis failing, not a note.
|
||||
restored := false
|
||||
@@ -359,6 +356,9 @@ func differentialPerTest(repo, base string, srcFiles []string, refs []testRef) (
|
||||
err = terr
|
||||
}
|
||||
}()
|
||||
if err := treeMatches(repo, base, srcFiles); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
out = make([]pinResult, 0, len(refs))
|
||||
for _, r := range refs {
|
||||
@@ -681,3 +681,16 @@ func passFail(ok bool) string {
|
||||
}
|
||||
return "fail"
|
||||
}
|
||||
|
||||
// analysisFailureLine says which figures a failed analysis leaves absent. A pin
|
||||
// table that was already produced still stands, so claiming both are missing
|
||||
// would be wrong whenever only the mutation sweep failed.
|
||||
func analysisFailureLine(r diffReport) string {
|
||||
if r.analysisErr == nil {
|
||||
return ""
|
||||
}
|
||||
if len(r.pins) > 0 {
|
||||
return fmt.Sprintf("\n**Analysis did not finish: %v.** The mutation figures are absent, not zero; the pin table above still stands.\n", r.analysisErr)
|
||||
}
|
||||
return fmt.Sprintf("\n**Analysis did not finish: %v.** Pin and mutation figures are absent, not zero.\n", r.analysisErr)
|
||||
}
|
||||
+14
-1
@@ -53,6 +53,10 @@ type meterUsage struct {
|
||||
DivergedAt int `json:"diverged_at,omitempty"`
|
||||
Divergence string `json:"divergence,omitempty"`
|
||||
TapeMissing int `json:"tape_missing,omitempty"`
|
||||
// TapeIncomplete counts recorded exchanges that could not be written, so a
|
||||
// recording that cannot be replayed is reported rather than assumed.
|
||||
TapeIncomplete int `json:"tape_incomplete,omitempty"`
|
||||
TapeError string `json:"tape_error,omitempty"`
|
||||
}
|
||||
|
||||
// faultScript decides which requests fail. Absolute indices pin a failure to
|
||||
@@ -220,7 +224,16 @@ func (m *meter) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
src := io.Reader(resp.Body)
|
||||
if m.tape != nil {
|
||||
src = io.TeeReader(resp.Body, &captured)
|
||||
defer func() { m.tape.save(index, body, resp.StatusCode, resp.Header.Get("Content-Type"), captured.Bytes()) }()
|
||||
defer func() {
|
||||
if err := m.tape.save(index, body, resp.StatusCode, resp.Header.Get("Content-Type"), captured.Bytes()); err != nil {
|
||||
m.mu.Lock()
|
||||
m.TapeIncomplete++
|
||||
if m.TapeError == "" {
|
||||
m.TapeError = err.Error()
|
||||
}
|
||||
m.mu.Unlock()
|
||||
}
|
||||
}()
|
||||
}
|
||||
if strings.Contains(resp.Header.Get("Content-Type"), "event-stream") {
|
||||
m.pipeStream(w, src)
|
||||
|
||||
@@ -47,12 +47,14 @@ func (rm runMeter) close() {
|
||||
}
|
||||
}
|
||||
|
||||
// record attaches what the proxy observed. It stays separate from close so the
|
||||
// snapshot is taken while the run's numbers are still being assembled.
|
||||
// record attaches what the proxy observed. It stops the meter first, because a
|
||||
// handler's deferred write can land after the last response is read and stopping
|
||||
// is what waits for those handlers; a snapshot taken before that misses them.
|
||||
func (rm runMeter) record(r *result) {
|
||||
if rm.m == nil {
|
||||
return
|
||||
}
|
||||
rm.close()
|
||||
observed := rm.m.snapshot()
|
||||
r.Meter = &observed
|
||||
}
|
||||
|
||||
@@ -202,6 +202,7 @@ func renderBody(results []result) string {
|
||||
b.WriteString(renderAuxiliarySpend(s.bySource, s.currency))
|
||||
b.WriteString(renderMeterAccounting(results))
|
||||
b.WriteString(renderTapeReplay(results))
|
||||
b.WriteString(renderTapeRecord(results))
|
||||
b.WriteString(renderFaultRecovery(results))
|
||||
b.WriteString(renderTimeAttribution(results))
|
||||
b.WriteString(renderSolveProfiles(results))
|
||||
|
||||
@@ -8,13 +8,15 @@ import (
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Restoring the source is I/O like any other step, but a failure used to read as
|
||||
// success: the analysis went on to report a verdict about a tree that was still
|
||||
// mutated. These helpers make it loud, and are variables so tests can inject one.
|
||||
// Restoring the source is I/O like any other step, and an analysis must not
|
||||
// report a verdict about a tree it failed to restore. These helpers make that
|
||||
// failure loud, and are variables so a test can inject one.
|
||||
|
||||
var (
|
||||
writeFile = os.WriteFile
|
||||
readFile = os.ReadFile
|
||||
// treeMatches is a variable so a test can inject the mismatch a restore guards.
|
||||
treeMatches = treeMatchesTree
|
||||
)
|
||||
|
||||
// restoreSource writes the original bytes back and proves they are back.
|
||||
@@ -34,7 +36,7 @@ func restoreSource(abs string, src []byte) error {
|
||||
|
||||
// treeMatches checks files against a revision. A checkout that exited zero is
|
||||
// not evidence the tree moved; the tree is.
|
||||
func treeMatches(repo, rev string, files []string) error {
|
||||
func treeMatchesTree(repo, rev string, files []string) error {
|
||||
args := append([]string{"-C", repo, "diff", "--exit-code", rev, "--"}, files...)
|
||||
out, err := exec.Command("git", args...).CombinedOutput()
|
||||
if err != nil {
|
||||
|
||||
@@ -192,3 +192,70 @@ func TestDifferentialPerTestFailsWhenRestoreFails(t *testing.T) {
|
||||
t.Fatal("a failed restore still produced pin results")
|
||||
}
|
||||
}
|
||||
|
||||
// The base side is checked before any pin runs, and that check can fail too: the
|
||||
// tree must not be left on base just because the failure happened early. The
|
||||
// defer that restores is registered before the check for exactly this path.
|
||||
func TestDifferentialPerTestRestoresWhenTheBaseCheckFails(t *testing.T) {
|
||||
repo := t.TempDir()
|
||||
run := func(args ...string) {
|
||||
t.Helper()
|
||||
cmd := exec.Command("git", append([]string{"-C", repo}, args...)...)
|
||||
cmd.Env = append(os.Environ(), "GIT_AUTHOR_NAME=t", "GIT_AUTHOR_EMAIL=t@t",
|
||||
"GIT_COMMITTER_NAME=t", "GIT_COMMITTER_EMAIL=t@t")
|
||||
if out, err := cmd.CombinedOutput(); err != nil {
|
||||
t.Fatalf("git %v: %v: %s", args, err, out)
|
||||
}
|
||||
}
|
||||
run("init", "-q")
|
||||
if err := os.WriteFile(filepath.Join(repo, "f.go"), []byte("base\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
run("add", "f.go")
|
||||
run("commit", "-qm", "base")
|
||||
base := strings.TrimSpace(gitOut(repo, "rev-parse", "HEAD"))
|
||||
if err := os.WriteFile(filepath.Join(repo, "f.go"), []byte("head\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
run("commit", "-aqm", "head")
|
||||
|
||||
orig := treeMatches
|
||||
treeMatches = func(r, rev string, files []string) error {
|
||||
if rev == base {
|
||||
return errors.New("injected: base side does not match")
|
||||
}
|
||||
return treeMatchesTree(r, rev, files)
|
||||
}
|
||||
t.Cleanup(func() { treeMatches = orig })
|
||||
|
||||
out, err := differentialPerTest(repo, base, []string{"f.go"}, nil)
|
||||
if err == nil {
|
||||
t.Fatalf("a failed base check reported success with %d pins", len(out))
|
||||
}
|
||||
got, rerr := os.ReadFile(filepath.Join(repo, "f.go"))
|
||||
if rerr != nil {
|
||||
t.Fatal(rerr)
|
||||
}
|
||||
if strings.TrimSpace(string(got)) != "head" {
|
||||
t.Fatalf("the tree was left on base: f.go = %q, want the PR's content", strings.TrimSpace(string(got)))
|
||||
}
|
||||
}
|
||||
|
||||
// A pin table that was already produced still stands, so the failure line must
|
||||
// not claim both figures are missing when only the mutation sweep failed.
|
||||
func TestAnalysisFailureLineNamesWhatIsMissing(t *testing.T) {
|
||||
only := analysisFailureLine(diffReport{analysisErr: errors.New("boom"), pins: []pinResult{{}}})
|
||||
if !strings.Contains(only, "mutation figures are absent") || !strings.Contains(only, "pin table above still stands") {
|
||||
t.Fatalf("with pins present the line reads %q", only)
|
||||
}
|
||||
if strings.Contains(only, "Pin and mutation figures are absent") {
|
||||
t.Fatalf("with pins present the line still claims both are absent: %q", only)
|
||||
}
|
||||
both := analysisFailureLine(diffReport{analysisErr: errors.New("boom")})
|
||||
if !strings.Contains(both, "Pin and mutation figures are absent") {
|
||||
t.Fatalf("with no pins the line reads %q", both)
|
||||
}
|
||||
if analysisFailureLine(diffReport{}) != "" {
|
||||
t.Fatal("a finished analysis produced a failure line")
|
||||
}
|
||||
}
|
||||
+54
-6
@@ -107,6 +107,18 @@ func (c tapeConfig) forRun(taskID string, trial int) (*tape, error) {
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// This run replaces the tape. Drop the old completeness markers first, or
|
||||
// a failure here would leave the previous request beside this run's
|
||||
// partial response and read as a complete exchange.
|
||||
stale, err := filepath.Glob(filepath.Join(dir, "*.request.json"))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, path := range stale {
|
||||
if err := os.Remove(path); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
} else if _, err := os.Stat(dir); err != nil {
|
||||
return nil, fmt.Errorf("no tape for %s: %w", taskID, err)
|
||||
}
|
||||
@@ -122,11 +134,26 @@ func (t *tape) file(index int, suffix string) string {
|
||||
return filepath.Join(t.dir, fmt.Sprintf("%04d.%s", index, suffix))
|
||||
}
|
||||
|
||||
func (t *tape) save(index int, request []byte, status int, contentType string, body []byte) {
|
||||
meta, _ := json.Marshal(tapeResponse{Status: status, ContentType: contentType})
|
||||
_ = os.WriteFile(t.file(index, "request.json"), request, 0o644)
|
||||
_ = os.WriteFile(t.file(index, "response.json"), meta, 0o644)
|
||||
_ = os.WriteFile(t.file(index, "response.body"), body, 0o644)
|
||||
// tapeWriteFile is a variable so a test can inject a write failure.
|
||||
var tapeWriteFile = os.WriteFile
|
||||
|
||||
// save records one exchange. request.json goes last: its presence is what tells
|
||||
// a later replay the exchange is complete rather than truncated.
|
||||
func (t *tape) save(index int, request []byte, status int, contentType string, body []byte) error {
|
||||
meta, err := json.Marshal(tapeResponse{Status: status, ContentType: contentType})
|
||||
if err != nil {
|
||||
return fmt.Errorf("tape %d response meta: %w", index, err)
|
||||
}
|
||||
if err := tapeWriteFile(t.file(index, "response.json"), meta, 0o644); err != nil {
|
||||
return fmt.Errorf("tape %d response.json: %w", index, err)
|
||||
}
|
||||
if err := tapeWriteFile(t.file(index, "response.body"), body, 0o644); err != nil {
|
||||
return fmt.Errorf("tape %d response.body: %w", index, err)
|
||||
}
|
||||
if err := tapeWriteFile(t.file(index, "request.json"), request, 0o644); err != nil {
|
||||
return fmt.Errorf("tape %d request.json: %w", index, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// load returns the recorded answer to request index, and where the request
|
||||
@@ -142,7 +169,12 @@ func (t *tape) load(index int, request []byte) (resp tapeResponse, body []byte,
|
||||
if body, err = os.ReadFile(t.file(index, "response.body")); err != nil {
|
||||
return resp, nil, "", err
|
||||
}
|
||||
recorded, _ := os.ReadFile(t.file(index, "request.json"))
|
||||
recorded, err := os.ReadFile(t.file(index, "request.json"))
|
||||
if err != nil {
|
||||
// Not a divergence: without the recorded request there is nothing to
|
||||
// compare against, and reporting one would blame the harness.
|
||||
return resp, nil, "", fmt.Errorf("tape %d is incomplete: %w", index, err)
|
||||
}
|
||||
return resp, body, requestDiff(recorded, request), nil
|
||||
}
|
||||
|
||||
@@ -249,3 +281,19 @@ func renderTapeReplay(results []result) string {
|
||||
}
|
||||
return line + "\n\n"
|
||||
}
|
||||
|
||||
// renderTapeRecord reports recordings that did not finish, so a run whose tape
|
||||
// cannot be replayed says so instead of looking recorded.
|
||||
func renderTapeRecord(results []result) string {
|
||||
var incomplete []string
|
||||
for _, r := range results {
|
||||
if r.Meter == nil || r.Meter.TapeIncomplete == 0 {
|
||||
continue
|
||||
}
|
||||
incomplete = append(incomplete, fmt.Sprintf("`%s` (%d exchanges): %s", r.ID, r.Meter.TapeIncomplete, r.Meter.TapeError))
|
||||
}
|
||||
if len(incomplete) == 0 {
|
||||
return ""
|
||||
}
|
||||
return "**Tape record incomplete**: " + strings.Join(incomplete, " · ") + "\n\n"
|
||||
}
|
||||
@@ -1,11 +1,15 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
const tapedStream = "data: {\"choices\":[{\"delta\":{\"content\":\"fixed\"}}]}\n\n" +
|
||||
@@ -103,3 +107,179 @@ func TestRequestDiffReadsStructureBeforeBytes(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func waitFor(t *testing.T, what string, cond func() bool) {
|
||||
t.Helper()
|
||||
deadline := time.Now().Add(2 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
if cond() {
|
||||
return
|
||||
}
|
||||
time.Sleep(5 * time.Millisecond)
|
||||
}
|
||||
t.Fatalf("timed out waiting for %s", what)
|
||||
}
|
||||
|
||||
// A recording whose request file cannot be written is reported rather than
|
||||
// treated as a tape: request.json is what marks the exchange complete.
|
||||
func TestTapeSaveFailureIsReportedOnTheMeter(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
orig := tapeWriteFile
|
||||
tapeWriteFile = func(path string, body []byte, mode os.FileMode) error {
|
||||
if strings.HasSuffix(path, "request.json") {
|
||||
return errors.New("disk full")
|
||||
}
|
||||
return orig(path, body, mode)
|
||||
}
|
||||
t.Cleanup(func() { tapeWriteFile = orig })
|
||||
|
||||
var calls atomic.Int32
|
||||
m, base, stop := tapedMeter(t, tapeRecord, root, &calls)
|
||||
defer stop()
|
||||
readAll(t, post(t, base, "/chat/completions", tapedRequest))
|
||||
|
||||
var got meterUsage
|
||||
waitFor(t, "the failed write to be reported", func() bool {
|
||||
got = m.snapshot()
|
||||
return got.TapeIncomplete > 0
|
||||
})
|
||||
if !strings.Contains(got.TapeError, "disk full") {
|
||||
t.Fatalf("incomplete recording = %+v, want the write error named", got)
|
||||
}
|
||||
if markers, _ := filepath.Glob(filepath.Join(root, "bugfix-slug", "trial-1", "*.request.json")); len(markers) != 0 {
|
||||
t.Fatalf("the completeness marker was written although the request write failed: %v", markers)
|
||||
}
|
||||
line := renderTapeRecord([]result{{task: task{ID: "bugfix-slug"}, Meter: &got}})
|
||||
if !strings.Contains(line, "Tape record incomplete") || !strings.Contains(line, "bugfix-slug") {
|
||||
t.Fatalf("report line = %q", line)
|
||||
}
|
||||
if renderTapeRecord(nil) != "" {
|
||||
t.Fatal("an empty run set produced a tape line")
|
||||
}
|
||||
}
|
||||
|
||||
// A tape with no recorded request is incomplete, not a divergence: there is
|
||||
// nothing to compare against, and blaming the harness would be wrong.
|
||||
func TestTapeMissingRequestIsIncompleteNotADivergence(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
var calls atomic.Int32
|
||||
_, base, stop := tapedMeter(t, tapeRecord, root, &calls)
|
||||
readAll(t, post(t, base, "/chat/completions", tapedRequest))
|
||||
dir := filepath.Join(root, "bugfix-slug", "trial-1")
|
||||
var markers []string
|
||||
waitFor(t, "the recorded request", func() bool {
|
||||
markers, _ = filepath.Glob(filepath.Join(dir, "*.request.json"))
|
||||
return len(markers) > 0
|
||||
})
|
||||
stop()
|
||||
if err := os.Remove(markers[0]); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
m, base, stop := tapedMeter(t, tapeReplay, root, &calls)
|
||||
defer stop()
|
||||
resp := post(t, base, "/chat/completions", tapedRequest)
|
||||
readAll(t, resp)
|
||||
got := m.snapshot()
|
||||
if resp.StatusCode != http.StatusBadGateway || got.TapeMissing != 1 {
|
||||
t.Fatalf("incomplete tape = %d, missing %d; want 502 and counted", resp.StatusCode, got.TapeMissing)
|
||||
}
|
||||
if got.DivergedAt != 0 || got.Divergence != "" {
|
||||
t.Fatalf("an incomplete tape was reported as a divergence: %d %q", got.DivergedAt, got.Divergence)
|
||||
}
|
||||
}
|
||||
|
||||
// The run path snapshots the meter through record, which must stop the meter
|
||||
// first: stopping is what waits for the handlers whose deferred writes carry the
|
||||
// tape counters. Snapshotting before that misses the last exchange.
|
||||
func TestMeterRecordDrainsHandlersBeforeSnapshot(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
var calls atomic.Int32
|
||||
m, base, stop := tapedMeter(t, tapeRecord, root, &calls)
|
||||
defer stop()
|
||||
readAll(t, post(t, base, "/chat/completions", tapedRequest))
|
||||
|
||||
var r result
|
||||
runMeter{m: m, stop: stop}.record(&r)
|
||||
// No waitFor: the drain inside record is what makes this observable.
|
||||
if r.Meter == nil {
|
||||
t.Fatal("record attached no meter")
|
||||
}
|
||||
if resp, err := http.Post(base+"/chat/completions", "application/json", strings.NewReader(tapedRequest)); err == nil {
|
||||
resp.Body.Close()
|
||||
t.Fatal("the meter was still serving after record, so its handlers were not drained")
|
||||
}
|
||||
}
|
||||
|
||||
// Re-recording over an existing tape must not let the previous run's
|
||||
// completeness marker make a partial new exchange look complete.
|
||||
func TestTapeReRecordClearsStaleCompletenessMarkers(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
var calls atomic.Int32
|
||||
_, base, stop := tapedMeter(t, tapeRecord, root, &calls)
|
||||
readAll(t, post(t, base, "/chat/completions", tapedRequest))
|
||||
dir := filepath.Join(root, "bugfix-slug", "trial-1")
|
||||
waitFor(t, "the first recording", func() bool {
|
||||
m, _ := filepath.Glob(filepath.Join(dir, "*.request.json"))
|
||||
return len(m) > 0
|
||||
})
|
||||
stop()
|
||||
|
||||
// The second recording fails while writing the body, after the request has
|
||||
// already been replaced in memory.
|
||||
orig := tapeWriteFile
|
||||
tapeWriteFile = func(path string, body []byte, mode os.FileMode) error {
|
||||
if strings.HasSuffix(path, "response.body") {
|
||||
return errors.New("disk full")
|
||||
}
|
||||
return orig(path, body, mode)
|
||||
}
|
||||
t.Cleanup(func() { tapeWriteFile = orig })
|
||||
|
||||
var calls2 atomic.Int32
|
||||
m2, base2, stop2 := tapedMeter(t, tapeRecord, root, &calls2)
|
||||
defer stop2()
|
||||
readAll(t, post(t, base2, "/chat/completions", tapedRequest))
|
||||
var r result
|
||||
runMeter{m: m2, stop: stop2}.record(&r)
|
||||
if markers, _ := filepath.Glob(filepath.Join(dir, "*.request.json")); len(markers) != 0 {
|
||||
t.Fatalf("a failed re-record left completeness markers: %v", markers)
|
||||
}
|
||||
// Replay must call the exchange incomplete, not compare a stale request with
|
||||
// a partial response.
|
||||
m3, base3, stop3 := tapedMeter(t, tapeReplay, root, &calls2)
|
||||
defer stop3()
|
||||
resp := post(t, base3, "/chat/completions", tapedRequest)
|
||||
readAll(t, resp)
|
||||
if resp.StatusCode != http.StatusBadGateway {
|
||||
t.Fatalf("replay of a partial tape = %d, want 502", resp.StatusCode)
|
||||
}
|
||||
if got := m3.snapshot(); got.DivergedAt != 0 {
|
||||
t.Fatalf("a partial tape was reported as a divergence: %d %q", got.DivergedAt, got.Divergence)
|
||||
}
|
||||
}
|
||||
|
||||
// The healthy path must stay quiet: a recording that completed reports no
|
||||
// incomplete exchange and prints no line about one. Only the failure paths were
|
||||
// pinned before.
|
||||
func TestTapeCompleteRecordingReportsNothingIncomplete(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
var calls atomic.Int32
|
||||
m, base, stop := tapedMeter(t, tapeRecord, root, &calls)
|
||||
defer stop()
|
||||
readAll(t, post(t, base, "/chat/completions", tapedRequest))
|
||||
|
||||
var r result
|
||||
runMeter{m: m, stop: stop}.record(&r)
|
||||
if r.Meter == nil {
|
||||
t.Fatal("record attached no meter")
|
||||
}
|
||||
if r.Meter.TapeIncomplete != 0 || r.Meter.TapeError != "" {
|
||||
t.Fatalf("a complete recording reported %d incomplete exchanges (%q)", r.Meter.TapeIncomplete, r.Meter.TapeError)
|
||||
}
|
||||
// renderTapeRecord is the printer behind the report's summary block, so an
|
||||
// empty string here is what "no line is printed" means.
|
||||
if line := renderTapeRecord([]result{{task: task{ID: "bugfix-slug"}, Meter: r.Meter}}); line != "" {
|
||||
t.Fatalf("a complete recording printed %q, want no line", line)
|
||||
}
|
||||
}
|
||||
@@ -20,9 +20,9 @@
|
||||
"computer-live": "electron test/computer_live.js"
|
||||
},
|
||||
"devDependencies": {
|
||||
"electron": "44.5.1",
|
||||
"electron": "44.7.0",
|
||||
"electron-builder": "^26.15.3",
|
||||
"playwright": "1.63.0"
|
||||
"playwright": "1.64.0"
|
||||
},
|
||||
"pnpm": {
|
||||
"onlyBuiltDependencies": [
|
||||
|
||||
Generated
+18
-18
@@ -9,14 +9,14 @@ importers:
|
||||
.:
|
||||
devDependencies:
|
||||
electron:
|
||||
specifier: 44.5.1
|
||||
version: 44.5.1([email protected])
|
||||
specifier: 44.7.0
|
||||
version: 44.7.0([email protected])
|
||||
electron-builder:
|
||||
specifier: ^26.15.3
|
||||
version: 26.15.3([email protected])([email protected])
|
||||
playwright:
|
||||
specifier: 1.63.0
|
||||
version: 1.63.0
|
||||
specifier: 1.64.0
|
||||
version: 1.64.0
|
||||
|
||||
packages:
|
||||
|
||||
@@ -374,8 +374,8 @@ packages:
|
||||
resolution: {integrity: sha512-bO3y10YikuUwUuDUQRM4KfwNkKhnpVO7IPdbsrejwN9/AABJzzTQ4GeHwyzNSrVO+tEH3/Np255a3sVZpZDjvg==}
|
||||
engines: {node: '>=8.0.0'}
|
||||
|
||||
electron@44.5.1:
|
||||
resolution: {integrity: sha512-lx7AYoFIiyvEvgvpyWO9kOC60xtNPpiPZAth6Fmg9NutzpCAMLwpUlaBY2EXfZyIgUdzEmLMj9KvK0Vrokl1yQ==}
|
||||
electron@44.7.0:
|
||||
resolution: {integrity: sha512-aUq9/zLAAFYc+a9gy6cd0C3Y/N/RmovLCD8TV8LhIzNhuND6HEgsu96jJz9cbAxvckSPv+Trf2PKEjktoZgFkw==}
|
||||
engines: {node: '>= 22.12.0'}
|
||||
hasBin: true
|
||||
|
||||
@@ -762,13 +762,13 @@ packages:
|
||||
resolution: {integrity: sha512-emEcLuomt2j03vxD54giVB4SxTjnsqkU692xZOZXHDVoYyypEm+b3jpiTcc+Cf+myooc+/Ly0z01jqeNHVgJGw==}
|
||||
engines: {node: '>=16.0.0'}
|
||||
|
||||
[email protected]3.0:
|
||||
resolution: {integrity: sha512-rYCsBF/M5HjUch52bbtVONEFjv6Xu8sm8h72dNlR5bzIE1fvC/bxgspzkjSfU+MweEMmPM8KJebG6nnyxo5mCg==}
|
||||
[email protected]4.0:
|
||||
resolution: {integrity: sha512-T9r+MZkTECl2+oUcZ26YgZzTrYidTsIhYYkJix+6iDOymOb1FB2RLLSOxRGnZGv5DStt+aduIVbt+E8WygXYpg==}
|
||||
engines: {node: '>=20'}
|
||||
hasBin: true
|
||||
|
||||
[email protected]3.0:
|
||||
resolution: {integrity: sha512-+7ziBLidS4NaNCdt57SUDT+wYmmd5fmiQejUic/kb+YsYSCPyOOE9sebzMjNmQrsnNpDJqd4WHvV/8lfKfUDUg==}
|
||||
[email protected]4.0:
|
||||
resolution: {integrity: sha512-kVzTnYFYEyQ9RGzBjNCd2+yEqWmVbxl3AHPynv3qQ6JTjv4a+kwRcnqQwIpuP3NnrnIrE/Q6wWAIAvMfIE2/Yg==}
|
||||
engines: {node: '>=20'}
|
||||
hasBin: true
|
||||
|
||||
@@ -1551,7 +1551,7 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
electron@44.5.1([email protected]):
|
||||
electron@44.7.0([email protected]):
|
||||
dependencies:
|
||||
'@electron-internal/extract-zip': 1.0.5
|
||||
'@electron/get': 5.1.0([email protected])
|
||||
@@ -1696,7 +1696,7 @@ snapshots:
|
||||
es6-error: 4.1.1
|
||||
matcher: 3.0.0
|
||||
roarr: 2.15.4
|
||||
semver: 7.8.5
|
||||
semver: 7.7.4
|
||||
serialize-error: 7.0.1
|
||||
optional: true
|
||||
|
||||
@@ -1883,11 +1883,11 @@ snapshots:
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
semver: 7.8.5
|
||||
semver: 7.7.4
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
semver: 7.8.5
|
||||
semver: 7.7.4
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
@@ -1896,7 +1896,7 @@ snapshots:
|
||||
graceful-fs: 4.2.11
|
||||
nopt: 9.0.0
|
||||
proc-log: 6.1.0
|
||||
semver: 7.8.5
|
||||
semver: 7.7.4
|
||||
tar: 7.5.22
|
||||
tinyglobby: 0.2.17
|
||||
undici: 6.28.0
|
||||
@@ -1942,11 +1942,11 @@ snapshots:
|
||||
pvutils: 1.2.0
|
||||
tslib: 2.8.1
|
||||
|
||||
[email protected]3.0: {}
|
||||
[email protected]4.0: {}
|
||||
|
||||
[email protected]3.0:
|
||||
[email protected]4.0:
|
||||
dependencies:
|
||||
playwright-core: 1.63.0
|
||||
playwright-core: 1.64.0
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
|
||||
@@ -152,6 +152,10 @@ class BrowserViews {
|
||||
if (id !== targetId) this.putAway(other.view, other);
|
||||
}
|
||||
this.shown = targetId;
|
||||
// A page that was parked comes back as a view that is attached but whose
|
||||
// widget never repaints until the window's geometry changes; attaching it
|
||||
// again is what wakes it, the same as after a minimize.
|
||||
if (!entry.at) this.win.contentView.removeChildView(entry.view);
|
||||
this.win.contentView.addChildView(entry.view);
|
||||
entry.view.setBounds(at);
|
||||
entry.at = at;
|
||||
|
||||
@@ -17,7 +17,7 @@ const { uiLanguage } = require("./uilang");
|
||||
const { installFullScreenKey } = require("./fullscreen");
|
||||
const { installReload } = require("./reload");
|
||||
const { externalTarget } = require("./links");
|
||||
const { reveal, revealWorkspace } = require("./reveal");
|
||||
const { reveal, revealWorkspace, revealLogs } = require("./reveal");
|
||||
const { appIcon } = require("./appicon");
|
||||
const layout = require("./layout");
|
||||
const { offerCleanup } = require("./legacy");
|
||||
@@ -415,6 +415,9 @@ ipcMain.handle("shell:open-external", (event, raw) => {
|
||||
ipcMain.handle("shell:reveal-workspace", (event, root) =>
|
||||
fromWindow(event) && client ? revealWorkspace(client, shell, String(root)) : { code: "", error: "no window" },
|
||||
);
|
||||
ipcMain.handle("shell:reveal-logs", (event) =>
|
||||
fromWindow(event) ? revealLogs(shell, logs?.dir) : { error: "no window" },
|
||||
);
|
||||
ipcMain.handle("shell:reveal", (event, base, rel) =>
|
||||
fromWindow(event) && client ? reveal(client, shell, String(base), String(rel)) : { code: "", error: "no window" },
|
||||
);
|
||||
|
||||
@@ -32,6 +32,7 @@ contextBridge.exposeInMainWorld("reasonixHost", {
|
||||
revealPath: (base, rel) => ipcRenderer.invoke("shell:reveal", String(base), String(rel)),
|
||||
// A project the sidebar lists, which may have no pane at all.
|
||||
revealWorkspace: (root) => ipcRenderer.invoke("shell:reveal-workspace", String(root)),
|
||||
revealLogs: () => ipcRenderer.invoke("shell:reveal-logs"),
|
||||
// Where a dropped file lives. Resolved here rather than in the page: the
|
||||
// renderer is handed a File and never a path, and a turn that has to work on
|
||||
// the file itself cannot do it on a copy of the bytes.
|
||||
|
||||
@@ -67,4 +67,13 @@ async function revealWorkspace(client, shell, root, platform = process.platform)
|
||||
return null;
|
||||
}
|
||||
|
||||
module.exports = { reveal, revealWorkspace };
|
||||
// The shell's own logs folder. The shell names it, never the page, and it is
|
||||
// opened rather than selected: it is a directory this app created. null means
|
||||
// it was shown.
|
||||
async function revealLogs(shell, dir) {
|
||||
if (!dir) return { error: "the logs folder is unknown" };
|
||||
const failed = await shell.openPath(String(dir));
|
||||
return failed ? { error: failed } : null;
|
||||
}
|
||||
|
||||
module.exports = { reveal, revealWorkspace, revealLogs };
|
||||
@@ -205,7 +205,7 @@ test("an unreachable kernel is an answer, not a crash", async () => {
|
||||
assert.equal(await dead.trayState(), null);
|
||||
});
|
||||
|
||||
const { reveal, revealWorkspace } = require("../src/reveal.js");
|
||||
const { reveal, revealWorkspace, revealLogs } = require("../src/reveal.js");
|
||||
|
||||
// A kernel that answers /workspace/locate as the test says, and a shell that
|
||||
// only records what it was asked to open.
|
||||
@@ -2049,3 +2049,61 @@ test("tray: a refused close-to-tray save redraws the checkbox, and a menu that f
|
||||
assert.equal(menus[1].template.find((item) => item.type === "checkbox").checked, false);
|
||||
tray.close();
|
||||
});
|
||||
|
||||
test("revealLogs opens only the folder the shell itself names, and reports why it could not", async () => {
|
||||
const opened = [];
|
||||
const shell = { openPath: async (target) => (opened.push(target), "") };
|
||||
assert.equal(await revealLogs(shell, "/user/logs"), null);
|
||||
assert.deepEqual(opened, ["/user/logs"]);
|
||||
const refusing = { openPath: async () => "no application is associated with this folder" };
|
||||
assert.deepEqual(await revealLogs(refusing, "/user/logs"), { error: "no application is associated with this folder" });
|
||||
assert.deepEqual(await revealLogs(shell, ""), { error: "the logs folder is unknown" });
|
||||
});
|
||||
|
||||
// The built-in browser drew a white page after switching away from a tab and
|
||||
// back until the window was resized: a parked view moved onscreen with
|
||||
// setBounds alone does not repaint. Bringing it back attaches it again.
|
||||
test("a parked page is attached again when it is shown, and a page already on screen is left alone", () => {
|
||||
const Module = require("node:module");
|
||||
const load = Module._load;
|
||||
Module._load = function (request, ...rest) {
|
||||
if (request === "electron") return { WebContentsView: class {}, session: { fromPartition: () => ({}) } };
|
||||
return load.call(this, request, ...rest);
|
||||
};
|
||||
let BrowserViews;
|
||||
try {
|
||||
({ BrowserViews } = require("../src/browserviews.js"));
|
||||
} finally {
|
||||
Module._load = load;
|
||||
}
|
||||
const calls = [];
|
||||
const win = {
|
||||
on() {},
|
||||
isDestroyed: () => false,
|
||||
getContentBounds: () => ({ width: 1200, height: 800 }),
|
||||
contentView: { addChildView: () => calls.push("add"), removeChildView: () => calls.push("remove") },
|
||||
};
|
||||
const views = new BrowserViews({ win, kernelOrigin: "http://127.0.0.1:1" });
|
||||
const view = { setBounds: (b) => calls.push("bounds " + b.x), webContents: { isDestroyed: () => false } };
|
||||
views.entries.set("t1", { view, at: null });
|
||||
const rect = { x: 10, y: 20, width: 600, height: 400 };
|
||||
|
||||
views.show("t1", rect);
|
||||
assert.deepEqual(calls, ["remove", "add", "bounds 10"]);
|
||||
calls.length = 0;
|
||||
views.show("t1", rect);
|
||||
assert.deepEqual(calls, [], "the same rectangle on a page already shown does no work");
|
||||
views.hide();
|
||||
calls.length = 0;
|
||||
views.show("t1", rect);
|
||||
assert.deepEqual(calls, ["remove", "add", "bounds 10"], "a page put away and shown again is attached again");
|
||||
});
|
||||
|
||||
test("rewind notice dismissal survives the desktop preference disk round trip", (t) => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rewind-notice-prefs-"));
|
||||
const file = path.join(dir, "prefs.json");
|
||||
t.after(() => { fs.rmSync(file, { force: true }); fs.rmdirSync(dir); });
|
||||
const record = { "rx-rewind-notice-dismissed:tx-saved": "1" };
|
||||
savePrefs(file, record);
|
||||
assert.deepEqual(loadPrefs(file), record);
|
||||
});
|
||||
@@ -61,6 +61,7 @@ node perf/concurrent.mjs # 多会话并发的守卫:9 个会话同时流式,
|
||||
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/text-glow.mjs # Transcript glow, pack switching and prose contrast across schemes, wallpapers, widths, effects and motion
|
||||
node perf/shipped.mjs # 产物的守卫:dist 里的东西必须比源码新(构建可以成功而编的是别的树)
|
||||
node perf/reason.mjs # 内核拒绝的双语落地:同一个码,中英各说各的
|
||||
node perf/budget.mjs # 预算的守卫:输入框上下堆满东西时,转录还剩多少
|
||||
|
||||
@@ -146,6 +146,49 @@ for (const state of ["idle", "running"]) {
|
||||
}
|
||||
check("扫到的对话列覆盖 360–700px", [...swept].some((w) => w <= 360) && [...swept].some((w) => w >= 700), [...swept].sort((a, b) => a - b).slice(0, 1).concat([...swept].sort((a, b) => b - a).slice(0, 1)).join("–"));
|
||||
|
||||
// An untouched phone composer leaves room to read; focus or a draft restores
|
||||
// the editing controls without replacing the textarea or its caret.
|
||||
const phone = () => page.evaluate(() => {
|
||||
const box = document.querySelector('textarea[role="combobox"]');
|
||||
const shown = (selector) => document.querySelector(selector)?.getBoundingClientRect().height > 0;
|
||||
const tools = [...document.querySelectorAll(".turntools > *")]
|
||||
.filter((el) => el.getBoundingClientRect().height > 0);
|
||||
const rows = new Set(tools.map((el) => {
|
||||
const r = el.getBoundingClientRect();
|
||||
return Math.round((r.top + r.height / 2) / 2);
|
||||
}));
|
||||
return { height: box.getBoundingClientRect().height, line: parseFloat(getComputedStyle(box).lineHeight),
|
||||
refine: shown(".studio-refine"), branch: shown(".studio-branch-pop"), rows: rows.size };
|
||||
});
|
||||
await page.fill(BOX, "");
|
||||
await page.evaluate(() => { localStorage.clear(); sessionStorage.clear(); });
|
||||
await page.setViewportSize({ width: 390, height: 760 });
|
||||
await page.goto(PAGE, { waitUntil: "networkidle" });
|
||||
await page.waitForSelector(BOX);
|
||||
await page.click('[role="tab"][aria-selected="true"]');
|
||||
await frame();
|
||||
const quiet = await phone();
|
||||
check("手机:空置输入只占一行", quiet.height <= quiet.line + 1);
|
||||
check("手机:空置时收起优化提示,分支提示仍在", !quiet.refine && quiet.branch);
|
||||
check("手机:空置工具栏只占一行", quiet.rows === 1);
|
||||
await page.click(BOX);
|
||||
await frame();
|
||||
const focused = await phone();
|
||||
check("手机:聚焦恢复编辑空间与优化入口", focused.height > quiet.height && focused.refine);
|
||||
await page.fill(BOX, "保留草稿\n第二行\n第三行");
|
||||
await page.click('[role="tab"][aria-selected="true"]');
|
||||
await frame();
|
||||
const draft = await phone();
|
||||
check("手机:未聚焦草稿仍展开", draft.height >= draft.line * 3 - 1 && draft.refine);
|
||||
check("手机:展开后草稿原样保留", await page.inputValue(BOX) === "保留草稿\n第二行\n第三行");
|
||||
await page.fill(BOX, "");
|
||||
await page.getByRole("button", { name: "引用到输入框", exact: true }).last().click();
|
||||
await page.waitForSelector(".shots");
|
||||
await page.click('[role="tab"][aria-selected="true"]');
|
||||
await frame();
|
||||
const quoted = await phone();
|
||||
check("手机:仅有引用附件也保留编辑空间", quoted.height > quiet.height && quoted.refine);
|
||||
|
||||
await browser.close();
|
||||
if (fails.length) {
|
||||
console.error(`\n${fails.length} 项不合格:\n ` + fails.join("\n "));
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
// The first screen is drawn from the core CJK face (about 1.9 MB), not the 7.8 MB full font. The full font is a
|
||||
// later family in the stack for characters the core lacks: it must not be fetched for ordinary UI text, and it
|
||||
// must still load when a character only it has is shown.
|
||||
import { chromium } from "playwright";
|
||||
|
||||
const PAGE = process.env.PERF_URL ?? "http://localhost:4399/perf.html";
|
||||
const browser = await chromium.launch();
|
||||
const page = await (await browser.newContext({ locale: "zh-CN", viewport: { width: 1440, height: 900 } })).newPage();
|
||||
const fetched = [];
|
||||
page.on("response", (r) => {
|
||||
const m = /\/fonts\/(noto-studio[^/?]*)/.exec(r.url());
|
||||
if (m) fetched.push(m[1]);
|
||||
});
|
||||
await page.goto(PAGE, { waitUntil: "networkidle" });
|
||||
await page.waitForSelector(".app", { timeout: 15000 });
|
||||
await page.waitForTimeout(1500);
|
||||
|
||||
const fails = [];
|
||||
if (fetched.includes("noto-studio.woff2")) fails.push("the first screen fetched the full 7.8 MB font");
|
||||
if (!fetched.includes("noto-studio-core.woff2")) fails.push("the first screen did not use the core font");
|
||||
console.log(fails.length ? `FAIL ${fails.join("; ")} (${fetched.join(", ")})` : ` ok first screen fetched ${fetched.join(", ")} only`);
|
||||
|
||||
await page.evaluate(() => {
|
||||
const d = document.createElement("div");
|
||||
d.style.cssText = "position:fixed;left:0;top:0;font:32px var(--ui)";
|
||||
d.textContent = "龘";
|
||||
document.body.append(d);
|
||||
});
|
||||
await page.waitForTimeout(1500);
|
||||
const rare = fetched.includes("noto-studio.woff2");
|
||||
console.log(rare ? " ok a character only the full font has still loads it" : "FAIL a rare character did not load the full font");
|
||||
await browser.close();
|
||||
process.exit(fails.length || !rare ? 1 : 0);
|
||||
@@ -5,6 +5,16 @@ import "../src/styles/tokens.css";
|
||||
import "../src/styles/app.css";
|
||||
import "../src/styles/dock.css";
|
||||
import "../src/styles/studio.css";
|
||||
import "../src/styles/changedpill.css";
|
||||
import "../src/styles/chart.css";
|
||||
import "../src/styles/community.css";
|
||||
import "../src/styles/providerpresets.css";
|
||||
import "../src/styles/sessionnotes.css";
|
||||
import "../src/styles/sessionquick.css";
|
||||
import "../src/styles/sidebardrop.css";
|
||||
import "../src/styles/sponsor.css";
|
||||
import "../src/styles/versions.css";
|
||||
import "../src/styles/phonecomposer.css";
|
||||
import "../src/styles/tips.css";
|
||||
import "../src/styles/waitnote.css";
|
||||
import "../src/styles/narrow.css";
|
||||
@@ -34,6 +44,7 @@ let peakInFlight = 0;
|
||||
// ?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");
|
||||
const GLOW = new URLSearchParams(location.search).get("glow");
|
||||
|
||||
function benchPack(): ThemePack | null {
|
||||
const tokens = {
|
||||
@@ -49,7 +60,10 @@ function benchPack(): ThemePack | null {
|
||||
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 },
|
||||
background: {
|
||||
image: true, focusX: 0.5, focusY: 0.5, homeOpacity: 0.9, taskOpacity: 0.5, overlayStrength: 0.4,
|
||||
...(GLOW === "none" || GLOW === "default" ? { textGlow: GLOW } : {}),
|
||||
},
|
||||
};
|
||||
}
|
||||
return null;
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
// 跨屏选区:把选区从靠前的一块拉到很远的一块,量画面稳住要多久、挂了多少块,
|
||||
// 以及复制出来的文字是不是真文本而不是空占位。
|
||||
import { chromium } from "playwright";
|
||||
|
||||
const PAGE = process.env.PERF_URL ?? "http://localhost:4399/perf.html";
|
||||
const TURNS = Number(process.env.TURNS ?? 300);
|
||||
const browser = await chromium.launch();
|
||||
const page = await (await browser.newContext({ viewport: { width: 1440, height: 900 } })).newPage();
|
||||
const fails = [];
|
||||
page.on("pageerror", (e) => fails.push("页面异常: " + e.message));
|
||||
await page.goto(`${PAGE}?turns=${TURNS}`, { waitUntil: "networkidle" });
|
||||
await page.waitForSelector(".app", { timeout: 15000 });
|
||||
await page.waitForTimeout(1500);
|
||||
await page.evaluate(() => {
|
||||
const flow = document.querySelector('[data-pane="flow"]');
|
||||
flow.scrollTop = 0;
|
||||
});
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
const run = (from, to) =>
|
||||
page.evaluate(
|
||||
({ from, to }) =>
|
||||
new Promise((resolve) => {
|
||||
const chunks = [...document.querySelectorAll(".chunk")];
|
||||
const mounted = () => chunks.filter((c) => c.childElementCount > 0).length;
|
||||
const before = mounted();
|
||||
const first = chunks[from].querySelector("p, div") ?? chunks[from];
|
||||
const range = document.createRange();
|
||||
range.setStart(first, 0);
|
||||
range.setEnd(chunks[to], 0);
|
||||
const t0 = performance.now();
|
||||
const sel = document.getSelection();
|
||||
sel.removeAllRanges();
|
||||
sel.addRange(range);
|
||||
requestAnimationFrame(() =>
|
||||
requestAnimationFrame(() =>
|
||||
setTimeout(() => {
|
||||
const text = sel.toString();
|
||||
resolve({ ms: Math.round(performance.now() - t0), before, after: mounted(), total: chunks.length, chars: text.length, lines: text.split("\n").filter(Boolean).length });
|
||||
}, 0),
|
||||
),
|
||||
);
|
||||
}),
|
||||
{ from, to },
|
||||
);
|
||||
|
||||
const total = await page.evaluate(() => document.querySelectorAll(".chunk").length);
|
||||
for (const [from, to] of [[2, Math.floor(total / 3)], [2, Math.floor(total / 2)], [2, total - 3]]) {
|
||||
const r = await run(from, to);
|
||||
console.log(`选区 块${from}→块${to}: ${r.ms}ms 挂载 ${r.before}→${r.after}/${r.total} 复制 ${r.chars} 字 ${r.lines} 行`);
|
||||
}
|
||||
await browser.close();
|
||||
if (fails.length) { console.log(fails.join("\n")); process.exit(1); }
|
||||
@@ -0,0 +1,132 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { mkdir } from "node:fs/promises";
|
||||
import { chromium } from "playwright";
|
||||
|
||||
const BASE = process.env.PERF_URL ?? "http://localhost:4399/perf.html";
|
||||
const before = process.env.GLOW_BEFORE === "1";
|
||||
const shots = process.env.GLOW_SCREENSHOTS;
|
||||
if (shots) await mkdir(shots, { recursive: true });
|
||||
const browser = await chromium.launch();
|
||||
let checked = 0;
|
||||
const errors = [];
|
||||
|
||||
async function open(scheme, paper, width, effects, motion, glow) {
|
||||
const context = await browser.newContext({ viewport: { width, height: width === 390 ? 844 : 900 }, reducedMotion: motion });
|
||||
await context.addInitScript(({ scheme, effects }) => {
|
||||
localStorage.setItem("rx-theme", scheme);
|
||||
localStorage.setItem("rx-effects", effects);
|
||||
}, { scheme, effects });
|
||||
const page = await context.newPage();
|
||||
page.on("pageerror", (e) => errors.push(e.message));
|
||||
await page.route("**/themes/bench-photo/background", (route) => route.fulfill({
|
||||
contentType: "image/svg+xml",
|
||||
body: `<svg xmlns="http://www.w3.org/2000/svg" width="1600" height="1000"><rect width="1600" height="1000" fill="${paper === "bright" ? "#fff9e5" : "#081329"}"/><circle cx="1250" cy="300" r="280" fill="${paper === "bright" ? "#eebc7c" : "#253d69"}"/></svg>`,
|
||||
}));
|
||||
await page.goto(`${BASE}?pack=photo&glow=${glow}&turns=2&ws=1&sess=2`, { waitUntil: "networkidle" });
|
||||
await page.waitForSelector('.flow:not([data-empty])');
|
||||
await page.evaluate(() => document.fonts.ready);
|
||||
await page.waitForFunction(({ before, effects }) => {
|
||||
const value = getComputedStyle(document.querySelector('.flow:not([data-empty])')).textShadow;
|
||||
return before && effects === "full" ? value.includes("7px") : value === "none";
|
||||
}, { before, effects });
|
||||
return { context, page };
|
||||
}
|
||||
|
||||
async function styles(page) {
|
||||
return page.locator('.flow:not([data-empty])').first().evaluate((el) => {
|
||||
const own = getComputedStyle(el);
|
||||
const backing = getComputedStyle(el, "::before");
|
||||
return {
|
||||
shadow: own.textShadow,
|
||||
blur: backing.backdropFilter || backing.webkitBackdropFilter,
|
||||
backing: backing.backgroundColor,
|
||||
content: backing.content,
|
||||
frameShadow: getComputedStyle(document.querySelector(".rail")).textShadow,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
for (const scheme of ["light", "dark"]) {
|
||||
for (const width of [1440, 390]) {
|
||||
for (const paper of before ? [scheme === "light" ? "bright" : "dark"] : ["bright", "dark"]) {
|
||||
for (const effects of before ? ["full"] : ["full", "reduced"]) {
|
||||
for (const motion of before ? ["no-preference"] : ["no-preference", "reduce"]) {
|
||||
const { context, page } = await open(scheme, paper, width, effects, motion, "none");
|
||||
const off = await styles(page);
|
||||
if (before) assert.notEqual(off.shadow, "none");
|
||||
else assert.equal(off.shadow, "none");
|
||||
assert.notEqual(off.content, "none");
|
||||
if (effects === "full") {
|
||||
assert.match(off.blur, /blur\(11px\)/);
|
||||
assert.notEqual(off.frameShadow, "none");
|
||||
} else {
|
||||
assert.equal(off.frameShadow, "none");
|
||||
}
|
||||
if (shots && paper === (scheme === "light" ? "bright" : "dark") && effects === "full" && motion === "no-preference") {
|
||||
await page.screenshot({ path: `${shots}/${scheme}-${width === 390 ? "phone" : "wide"}-${before ? "before" : "after"}.jpg`, type: "jpeg", quality: 88 });
|
||||
}
|
||||
if (!before) {
|
||||
const samples = await page.locator('.flow p').evaluateAll((els) => {
|
||||
const canvas = document.createElement("canvas");
|
||||
const ctx = canvas.getContext("2d", { willReadFrequently: true });
|
||||
return els.flatMap((el) => {
|
||||
const r = el.getBoundingClientRect();
|
||||
if (r.width < 10 || r.height < 10 || r.top < 0 || r.bottom > innerHeight) return [];
|
||||
if (!el.checkVisibility({ checkOpacity: true, checkVisibilityCSS: true })) return [];
|
||||
const hit = document.elementFromPoint(r.left + r.width / 2, r.top + r.height / 2);
|
||||
if (!hit || !el.contains(hit)) return [];
|
||||
ctx.clearRect(0, 0, 1, 1);
|
||||
ctx.fillStyle = getComputedStyle(el).color;
|
||||
ctx.fillRect(0, 0, 1, 1);
|
||||
return [{ x: Math.floor(r.left + r.width / 2), y: Math.floor(r.top + r.height / 2), text: el.textContent, fg: [...ctx.getImageData(0, 0, 1, 1).data].slice(0, 3) }];
|
||||
});
|
||||
});
|
||||
assert.ok(samples.length, "visible transcript prose must be measured");
|
||||
const hiding = await page.addStyleTag({ content: ".flow * { color: transparent !important; text-shadow: none !important }" });
|
||||
const blank = (await page.screenshot()).toString("base64");
|
||||
const ratios = await page.evaluate(async ({ blank, samples }) => {
|
||||
const img = new Image();
|
||||
img.src = `data:image/png;base64,${blank}`;
|
||||
await img.decode();
|
||||
const ctx = document.createElement("canvas").getContext("2d", { willReadFrequently: true });
|
||||
ctx.canvas.width = img.width; ctx.canvas.height = img.height;
|
||||
ctx.drawImage(img, 0, 0);
|
||||
const lum = (c) => c.map((v) => v / 255).map((v) => v <= .04045 ? v / 12.92 : ((v + .055) / 1.055) ** 2.4).reduce((sum, v, i) => sum + v * [.2126, .7152, .0722][i], 0);
|
||||
return samples.map(({ x, y, fg }) => {
|
||||
const bg = [...ctx.getImageData(x, y, 1, 1).data].slice(0, 3);
|
||||
const [hi, lo] = [lum(fg), lum(bg)].sort((a, b) => b - a);
|
||||
return (hi + .05) / (lo + .05);
|
||||
});
|
||||
}, { blank, samples });
|
||||
await hiding.evaluate((el) => el.remove());
|
||||
assert.ok(Math.min(...ratios) >= 4.5, `prose contrast ${Math.min(...ratios).toFixed(2)}:1 ${JSON.stringify(samples)}`);
|
||||
await page.evaluate(async () => {
|
||||
const { apply } = await import("/src/ui/theme.ts");
|
||||
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" },
|
||||
};
|
||||
const background = { image: true, focusX: .5, focusY: .5, homeOpacity: .9, taskOpacity: .5, overlayStrength: .4 };
|
||||
apply({ id: "bench-photo", name: "Old pack", tokens, background }, document.documentElement.dataset.theme);
|
||||
});
|
||||
if (effects === "full") await page.waitForFunction(() => getComputedStyle(document.querySelector('.flow:not([data-empty])')).textShadow.includes("7px"));
|
||||
const legacy = await styles(page);
|
||||
assert.equal(legacy.backing, off.backing);
|
||||
assert.equal(legacy.blur, off.blur);
|
||||
if (effects === "full") assert.match(legacy.shadow, /7px/);
|
||||
else assert.equal(legacy.shadow, "none");
|
||||
checked += 2;
|
||||
console.log(`ok ${scheme} ${width} ${paper} ${effects} ${motion}; prose min ${Math.min(...ratios).toFixed(2)}:1`);
|
||||
}
|
||||
await context.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
assert.deepEqual(errors, []);
|
||||
console.log(before ? "4 baseline views captured" : `${checked} glow/default scenarios passed; no page errors`);
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
Binary file not shown.
@@ -0,0 +1,38 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Cut the core face out of public/fonts/noto-studio.woff2.
|
||||
|
||||
The core face carries what a first screen asks for: the common simplified-Chinese
|
||||
set shipped as NotoSansSC-subset.woff2, Latin, punctuation and arrows. The full
|
||||
font stays as a later family in the stack, so a character the core lacks still
|
||||
finds its glyph there and only then pays for the download.
|
||||
|
||||
Usage: slice-noto-studio.py <full.woff2> <core-out.woff2>
|
||||
Needs fontTools and brotli.
|
||||
"""
|
||||
import sys
|
||||
from fontTools import subset
|
||||
from fontTools.ttLib import TTFont
|
||||
|
||||
COMMON = "NotoSansSC-subset.woff2"
|
||||
BLOCKS = [(0x20, 0x24F), (0x2000, 0x206F), (0x2190, 0x23FF),
|
||||
(0x2500, 0x257F), (0x25A0, 0x25FF), (0x2600, 0x27BF), (0x3000, 0x303F), (0xFF00, 0xFFEF)]
|
||||
|
||||
|
||||
def main(full_path, core_path):
|
||||
full = set(TTFont(full_path).getBestCmap())
|
||||
common = set(TTFont(full_path.replace("noto-studio.woff2", COMMON)).getBestCmap())
|
||||
core = {p for p in full if p in common or any(a <= p <= b for a, b in BLOCKS)}
|
||||
opts = subset.Options()
|
||||
opts.flavor = "woff2"
|
||||
opts.layout_features = ["*"]
|
||||
opts.notdef_outline = True
|
||||
font = subset.load_font(full_path, opts)
|
||||
s = subset.Subsetter(opts)
|
||||
s.populate(unicodes=sorted(core))
|
||||
s.subset(font)
|
||||
subset.save_font(font, core_path, opts)
|
||||
print(f"core: {len(core)} of {len(full)} characters")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main(*sys.argv[1:3])
|
||||
@@ -60,11 +60,16 @@ export const ACTIONS: UIAction[] = [
|
||||
{ id: "appearance.theme", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "appearance.nav-rail", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "appearance.graphics", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "appearance.session-tabs", 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: "session.more", kind: "repeatable", target: "none", proof: "interaction" },
|
||||
{ id: "session.selection", kind: "view", target: "optional", proof: "interaction" },
|
||||
{ id: "session.batch-archive", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
|
||||
{ id: "session.batch-delete", kind: "destructive", target: "none", proof: "authority-effect" },
|
||||
{ id: "chrome.settings", kind: "navigation", target: "none", proof: "interaction" },
|
||||
{ id: "browser.open", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "browser.wide", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "browser.back", kind: "navigation", target: "none", proof: "interaction" },
|
||||
{ id: "browser.reload", kind: "repeatable", target: "none", proof: "interaction" },
|
||||
{ id: "browser.address", kind: "view", target: "none", proof: "static" },
|
||||
@@ -78,6 +83,7 @@ export const ACTIONS: UIAction[] = [
|
||||
{ id: "chrome.receipt", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "chrome.receipt-line", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "chrome.keep-awake", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "composer.send-shortcut", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "transcript.fold", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "transcript.fold-preset", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "transcript.fold-preview", kind: "view", target: "none", proof: "interaction" },
|
||||
@@ -162,6 +168,7 @@ export const ACTIONS: UIAction[] = [
|
||||
{ id: "skill.enabled", kind: "kernel-mutation", target: "entity", proof: "authority-effect" },
|
||||
{ id: "mcp.enabled", kind: "kernel-mutation", target: "entity", proof: "interaction" },
|
||||
{ id: "mcp.retry", kind: "repeatable", target: "entity", proof: "interaction" },
|
||||
{ id: "mcp.trust", kind: "kernel-mutation", target: "entity", proof: "interaction" },
|
||||
{ id: "mcp.remove", kind: "destructive", target: "entity", proof: "interaction" },
|
||||
{ id: "mcp.load", kind: "kernel-mutation", target: "entity", proof: "interaction" },
|
||||
|
||||
@@ -203,11 +210,13 @@ export const ACTIONS: UIAction[] = [
|
||||
{ id: "feedback.send", kind: "kernel-mutation", target: "none", proof: "interaction" },
|
||||
{ id: "feedback.another", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "feedback.thread.more", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "feedback.thread.toggle", kind: "kernel-mutation", target: "entity", proof: "interaction" },
|
||||
{ id: "feedback.reply.open", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "feedback.reply.body", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "feedback.reply.send", kind: "kernel-mutation", target: "none", proof: "interaction" },
|
||||
{ id: "feedback.reply.cancel", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "feedback.mine", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "feedback.logs", kind: "shell-native", target: "none", proof: "interaction" },
|
||||
{ id: "feedback.refresh", kind: "repeatable", target: "none", proof: "interaction" },
|
||||
{ id: "feedback.retry-env", kind: "repeatable", target: "none", proof: "interaction" },
|
||||
{ id: "feedback.retry-mine", kind: "repeatable", target: "none", proof: "interaction" },
|
||||
@@ -358,7 +367,9 @@ export const ACTIONS: UIAction[] = [
|
||||
{ id: "file-revert.commit", kind: "destructive", target: "entity", proof: "authority-effect" },
|
||||
{ id: "rewind.prepare", kind: "kernel-mutation", target: "entity", proof: "authority-effect" },
|
||||
{ id: "rewind.commit", kind: "destructive", target: "entity", proof: "authority-effect" },
|
||||
{ id: "session.reload", kind: "repeatable", target: "entity", proof: "interaction" },
|
||||
{ id: "rewind.undo", kind: "kernel-mutation", target: "entity", proof: "authority-effect" },
|
||||
{ id: "rewind.notice-dismiss", kind: "view", target: "entity", proof: "interaction" },
|
||||
{ id: "extensions.submit", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
|
||||
{ id: "provider.probe", kind: "repeatable", target: "none", proof: "interaction" },
|
||||
{ id: "provider.draft", kind: "interaction", target: "none", proof: "interaction" },
|
||||
@@ -387,6 +398,8 @@ export const ACTIONS: UIAction[] = [
|
||||
{ id: "compaction.threshold", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
|
||||
// Which bound is in force is the setting; how much of it is on screen is not.
|
||||
{ id: "compaction.advanced", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "compaction.confirm", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "compaction.now", kind: "kernel-mutation", target: "none", proof: "interaction" },
|
||||
{ id: "panel.move", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
|
||||
{ id: "panel.delegate-placement", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
|
||||
{ id: "context.window-tokens", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
|
||||
@@ -476,25 +489,32 @@ export const ACTIONS: UIAction[] = [
|
||||
{ id: "settings.search", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "workspace.isolate", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
|
||||
{ id: "reasoning.effort", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
|
||||
{ id: "workbench.new-browser", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "workbench.new", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "workbench.launch-files", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "workbench.launch-changes", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "workbench.launch-browser", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "workbench.tab", kind: "view", target: "entity", proof: "interaction" },
|
||||
{ id: "workbench.close", kind: "view", target: "entity", proof: "interaction" },
|
||||
{ id: "workbench.browser-menu", kind: "view", target: "entity", proof: "interaction" },
|
||||
{ id: "workbench.browser-close", kind: "kernel-mutation", target: "entity", proof: "authority-effect" },
|
||||
{ id: "workbench.close", kind: "kernel-mutation", target: "entity", proof: "authority-effect" },
|
||||
{ id: "workbench.mode", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "workbench.save", kind: "kernel-mutation", target: "entity", proof: "authority-effect" },
|
||||
{ id: "workbench.search", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "workbench.clear-search", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "workbench.folder", kind: "view", target: "entity", proof: "interaction" },
|
||||
{ id: "workbench.diff", kind: "view", target: "entity", proof: "interaction" },
|
||||
{ id: "files.open", kind: "navigation", target: "none", proof: "interaction" },
|
||||
{ id: "workbench.files", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "workbench.hidden", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "workbench.reveal", kind: "shell-native", target: "optional", proof: "interaction" },
|
||||
{ id: "workbench.copy-path", kind: "shell-native", target: "entity", proof: "interaction" },
|
||||
{ id: "workbench.copy-ref", kind: "shell-native", target: "entity", proof: "interaction" },
|
||||
{ id: "workbench.menu", kind: "view", target: "entity", proof: "interaction" },
|
||||
{ id: "palette.pick", kind: "view", target: "entity", proof: "interaction" },
|
||||
{ id: "palette.search", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "reply.quote", kind: "view", target: "entity", proof: "interaction" },
|
||||
{ id: "reply.branch", kind: "kernel-mutation", target: "entity", proof: "interaction" },
|
||||
{ id: "reply.selection.menu", kind: "view", target: "entity", proof: "interaction" },
|
||||
{ id: "reply.retry", kind: "view", target: "entity", proof: "interaction" },
|
||||
{ id: "reply.retry-now", kind: "kernel-mutation", target: "entity", proof: "interaction" },
|
||||
{ id: "reply.configure", kind: "view", target: "none", proof: "interaction" },
|
||||
|
||||
@@ -21,6 +21,8 @@ import { EN_COMMUNITY } from "./en_community";
|
||||
import { EN_SPONSOR } from "./en_sponsor";
|
||||
|
||||
export const EN: Record<string, string> = {
|
||||
"加宽浏览器": "Widen browser",
|
||||
"恢复浏览器分栏": "Restore browser split",
|
||||
"工作台": "Workbench",
|
||||
"从右侧选择文件,或打开浏览器": "Choose a file on the right, or open a browser",
|
||||
...EN_SETTINGS,
|
||||
@@ -93,6 +95,9 @@ export const EN: Record<string, string> = {
|
||||
"复制这段回答": "Copy this answer",
|
||||
"复制这段代码": "Copy this code",
|
||||
"复制路径": "Copy path",
|
||||
"复制引用": "Copy reference",
|
||||
"此名称不能表示为 @ 引用": "This name cannot be represented as an @ reference",
|
||||
"此名称可能被识别为 MCP 资源": "This name could resolve to an MCP resource",
|
||||
"思考 {chars}": "Thought · {chars}",
|
||||
"耗时 {t}": "Worked for {t}",
|
||||
"思考 {secs} 秒 · {chars}": "Thought for {secs}s · {chars}",
|
||||
@@ -142,6 +147,10 @@ export const EN: Record<string, string> = {
|
||||
"活动": "Activity",
|
||||
"轨迹": "Trajectory",
|
||||
"对话": "Conversation",
|
||||
"展开对话": "Expand conversation",
|
||||
"收起对话": "Collapse conversation",
|
||||
"{n} 条回复": "{n} replies",
|
||||
"{n} 条新回复": "{n} new replies",
|
||||
"事件轨迹": "Event trail",
|
||||
"运行图": "Run graph",
|
||||
"你说过的话": "What you said",
|
||||
@@ -543,6 +552,13 @@ export const EN: Record<string, string> = {
|
||||
"忘记": "Forget",
|
||||
"重连": "Reconnect",
|
||||
"立即连接": "Connect now",
|
||||
"定义有改动": "Definition changed",
|
||||
"无法核对": "Cannot be checked",
|
||||
"等待你批准的工具": "Tools waiting for your approval",
|
||||
"这个服务有 {n} 个工具的定义与你批准过的不一致,已暂时不交给模型:": "{n} of this service's tools differ from what you approved and are withheld from the model for now:",
|
||||
"还有 {n} 个没有列出,批准时会一并生效。": "{n} more are not listed; approving covers them too.",
|
||||
"批准并重新连接": "Approve and reconnect",
|
||||
"批准中…": "Approving…",
|
||||
"记的是 {had},但它答的是 {got}。": "Recorded as {had}, but it answers as {got}.",
|
||||
// 收工回执
|
||||
"没有未经验证的部分": "nothing left unverified",
|
||||
@@ -750,8 +766,16 @@ export const EN: Record<string, string> = {
|
||||
"计划已移入右栏": "The plan is in the right rail",
|
||||
"收工检查": "Wrap-up check",
|
||||
"查看其后续使用的简报": "See the digest it carries forward",
|
||||
"操作已完成,但会话刷新失败": "The operation completed, but the conversation could not refresh",
|
||||
"重试刷新会话": "Retry session refresh",
|
||||
"正在还原…": "Restoring…",
|
||||
"会话已切换,请重新选择还原位置": "The conversation changed. Choose a restore point again.",
|
||||
"撤销这次还原": "Undo this restore",
|
||||
"回退已完成": "Rewind completed",
|
||||
"关闭回退提示": "Dismiss rewind notice",
|
||||
"无法保存关闭状态:{error}": "Could not save dismissal: {error}",
|
||||
"撤销这次回退": "Undo this rewind",
|
||||
"正在撤销…": "Undoing…",
|
||||
"仍还原其余部分": "Restore the rest anyway",
|
||||
"还原失败": "Could not restore",
|
||||
"回退范围内未修改任何文件": "No files changed in this rewind range",
|
||||
@@ -1139,7 +1163,9 @@ export const EN: Record<string, string> = {
|
||||
"置顶": "Pinned",
|
||||
"归档": "Archived",
|
||||
"归档会话": "Archive session",
|
||||
"归档会话:{title}": "Archive session: {title}",
|
||||
"取消归档": "Unarchive",
|
||||
"取消归档:{title}": "Unarchive: {title}",
|
||||
"操作执行前由你确认": "Ask before an operation runs",
|
||||
"只查看内容,不允许写入": "View content without allowing writes",
|
||||
"当前内核暂不支持会话级只读模式": "The current runtime does not support session-level read-only mode yet",
|
||||
@@ -1155,6 +1181,10 @@ export const EN: Record<string, string> = {
|
||||
"仅显示当前模型实际支持的档位。": "Only levels actually supported by this model are shown.",
|
||||
"按模型生效": "Per model",
|
||||
"查看上下文与压缩": "View context and compaction",
|
||||
"立即压缩": "Compact now",
|
||||
"确认压缩": "Confirm compaction",
|
||||
"压缩当前上下文?": "Compact the current context?",
|
||||
"模型将依据摘要继续工作;完整对话记录会保留。": "The model will continue from a summary. The full conversation will be preserved.",
|
||||
"查看本轮费用": "View turn cost",
|
||||
"查看钱包余额": "View wallet balance",
|
||||
"余额不足": "Balance is low",
|
||||
@@ -1290,7 +1320,12 @@ export const EN: Record<string, string> = {
|
||||
"未开始": "Not started",
|
||||
"任务清单": "Task list",
|
||||
"引用到输入框": "Quote into the composer",
|
||||
"引用选中内容": "Quote selected text",
|
||||
"复制选中内容": "Copy selected text",
|
||||
"选中内容": "Selected text",
|
||||
"重新生成": "Regenerate",
|
||||
"分支为新会话": "Branch into a new conversation",
|
||||
"分支": "branch",
|
||||
"当前回复会留在运行历史里": "The current reply stays in the run history",
|
||||
"按当前配置重试": "Retry as configured",
|
||||
"先调整模型与强度": "Adjust the model and effort first",
|
||||
@@ -1368,6 +1403,9 @@ export const EN: Record<string, string> = {
|
||||
"The staged diff is long; the draft reads only its first part",
|
||||
"这个工作区不是 git 仓库,无法提交":
|
||||
"This workspace is not a git repository, so there is nothing to commit",
|
||||
"回合运行中,等它结束后再分支": "A turn is running; branch after it ends",
|
||||
"这个位置无法分支(重启后恢复的会话,或已被压缩的历史)": "This point cannot be branched (a session restored after a restart, or history that was compacted)",
|
||||
"分支失败,请重试": "Branching failed; try again",
|
||||
"暂存区是空的,先用 git add 暂存要提交的文件":
|
||||
"Nothing is staged; stage the files to commit with git add first",
|
||||
"暂存区在你确认之后又变了,请重新起草":
|
||||
|
||||
@@ -76,6 +76,9 @@ export const EN_FEEDBACK: Record<string, string> = {
|
||||
"入口": "Surface",
|
||||
"渠道": "Channel",
|
||||
"只读。不包含密钥、地址或任何文件内容。": "Read-only. It contains no keys, addresses or file contents.",
|
||||
"打开日志文件夹": "Open logs folder",
|
||||
"维护者向你要日志时,打开这个文件夹,把最新的 shell.log 和 host.log 发给我们。发之前可以先自己看一眼。": "When a maintainer asks for logs, open this folder and send us the newest shell.log and host.log. You can read them first.",
|
||||
"无法打开日志文件夹": "The logs folder could not be opened.",
|
||||
"去 GitHub": "Go to GitHub",
|
||||
"重试发送": "Retry sending",
|
||||
"正文不能为空,请写下你遇到的情况。": "The text is empty. Please describe what happened.",
|
||||
|
||||
@@ -9,6 +9,7 @@ export const EN_KERNEL: Record<string, string> = {
|
||||
"内核繁忙,这次操作可能没有被收到,重试前请先确认": "The kernel is busy; the action may not have been received - check before retrying",
|
||||
"无法连接内核,这次操作可能没有送达,重试前请先确认": "The kernel cannot be reached; the action may not have been delivered - check before retrying",
|
||||
"本界面无响应,这次操作可能没有送达内核,重试前请先确认": "This window is unresponsive; the action may not have reached the kernel - check before retrying",
|
||||
"无法关闭这个网页:{error}": "Could not close this page: {error}",
|
||||
"递归删除目标受保护或超出授权范围;请使用工作区或授权目录内的字面路径": "The recursive delete target is protected or outside the granted roots; use a literal path inside the workspace or a granted directory",
|
||||
"无法确定递归删除范围;请使用字面命令名、路径和解释器内容": "The recursive delete extent is unknown; use a literal command name, path and interpreter payload",
|
||||
"请拆分命令;递归删除前只能使用字面路径切换目录,不能依赖变量赋值": "Split the call; only literal directory changes may precede a recursive delete, without relying on variable assignments",
|
||||
@@ -25,6 +26,11 @@ export const EN_KERNEL: Record<string, string> = {
|
||||
"所需写入范围:{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",
|
||||
"搜索模型「{model}」的格式应为 服务/模型": "Search model “{model}” must be written as service/model",
|
||||
"搜索模型「{model}」所在的连接已被移出": "Search model “{model}”'s connection has been removed",
|
||||
"搜索模型「{model}」已不在配置里": "Search model “{model}” is no longer configured",
|
||||
"搜索模型「{model}」的协议不支持原生搜索,或已关闭搜索": "Search model “{model}” has no native search on its protocol, or search is turned off for it",
|
||||
"搜索模型「{model}」所在的连接没有凭据": "Search model “{model}”'s connection has no credentials",
|
||||
"反馈通道今日已满,请明天再试": "Feedback is at capacity for today - try again tomorrow",
|
||||
"这份反馈的回复次数已到上限,或回复太频繁了,请稍后再试": "This report has reached its reply limit, or you replied too often - try again later",
|
||||
"这份反馈现在不接收回复": "This report takes no reply right now",
|
||||
@@ -195,6 +201,7 @@ export const EN_KERNEL: Record<string, string> = {
|
||||
"默认模型「{model}」不在已选择的模型中": "The default model “{model}” is not among the ones you picked",
|
||||
"显示名称最多 {max} 个字符": "A display name can be at most {max} characters",
|
||||
"显示名称不能包含换行或其他控制字符": "A display name cannot contain line breaks or other control characters",
|
||||
"显示别名必须使用已选择模型的完整 ID": "Display aliases must use the exact IDs of selected models",
|
||||
"该协议不发送思考参数,启用后不会生效": "This protocol never sends thinking parameters — turning it on does nothing",
|
||||
"该协议在轮次之间不保留状态,无需选择续接方式": "This protocol keeps no state between turns, so there is nothing to choose",
|
||||
"该协议不支持由端点自行搜索": "This protocol has no wire format for a search the endpoint runs itself",
|
||||
|
||||
@@ -2,6 +2,24 @@
|
||||
// alone — one catalogue is still the model, and this file is read as its
|
||||
// continuation: same keying by Chinese source text, same grouping by screen.
|
||||
export const EN_SETTINGS: Record<string, string> = {
|
||||
"{name} 的显示别名": "Display alias for {name}",
|
||||
"显示别名(留空使用模型 ID)": "Display alias (empty uses model ID)",
|
||||
"仅用于显示,不改变请求中的模型 ID": "Display only; the model ID sent in requests is unchanged",
|
||||
"浏览器标签": "Browser tabs",
|
||||
"关闭其他浏览器标签": "Close other browser tabs",
|
||||
"关闭全部浏览器标签": "Close all browser tabs",
|
||||
"无法关闭 {name}:{why}": "Could not close {name}: {why}",
|
||||
"发送快捷键": "Send shortcut",
|
||||
"用于任务输入、待发送消息编辑与历史消息重发。输入法确认候选词时不会发送。": "Applies to task input, editing queued messages, and resending past messages. Confirming an input-method candidate never sends.",
|
||||
"Enter 发送": "Enter to send",
|
||||
"{key} 发送": "{key} to send",
|
||||
"{key} 发送 · Enter 换行": "{key} to send · Enter for a new line",
|
||||
"{key} 插话 · Enter 换行": "{key} to steer · Enter for a new line",
|
||||
"Shift+Enter 换行;触屏设备使用发送按钮。": "Shift+Enter for a new line; use the send button on touch devices.",
|
||||
"Enter 换行,也可点击发送按钮。": "Enter for a new line, or click the send button.",
|
||||
"会话标签栏": "Session tabs",
|
||||
"显示会话标签栏": "Show session tabs",
|
||||
"打开多个会话时,在顶部显示标签,方便切换会话。": "Show tabs at the top when multiple sessions are open, for quick switching.",
|
||||
"步骤 1 · 连接服务": "Step 1 · Connect service",
|
||||
"先连接服务,再选择这个来源要启用的模型。": "Connect the service, then choose which models to enable from it.",
|
||||
"来源名称": "Source name",
|
||||
@@ -13,6 +31,18 @@ export const EN_SETTINGS: Record<string, string> = {
|
||||
"{n} 个来源": "{n} sources",
|
||||
"模型服务": "Model services",
|
||||
"模型偏好": "Model preferences",
|
||||
"独立搜索请求使用的模型": "Model for independent search requests",
|
||||
"项目配置覆盖:实际使用 {model};此处保存的是全局设置。": "Project override: {model} is used; this saves the global preference.",
|
||||
"自动:使用对话模型自带的搜索({model})": "Automatic: the conversation model's built-in search ({model})",
|
||||
"对话模型没有内置搜索;需要联网搜索时请选择一个搜索模型": "The conversation model has no built-in search; pick a search model to search the web",
|
||||
"搜索模型的格式应为 服务/模型": "The search model must be written as service/model",
|
||||
"搜索模型所在的连接已被移出": "The search model's connection has been removed",
|
||||
"搜索模型已不在配置里": "The search model is no longer configured",
|
||||
"搜索模型的协议不支持原生搜索,或已关闭搜索": "The search model's protocol has no native search, or search is turned off for it",
|
||||
"搜索模型所在的连接没有凭据": "The search model's connection has no credentials",
|
||||
"项目配置覆盖了这一项;此处保存的是全局设置。": "The project config overrides this; what you set here is saved globally.",
|
||||
"尚无可用搜索模型": "No search models available",
|
||||
"{model}(不可用)": "{model} (unavailable)",
|
||||
"模型用途": "Model uses",
|
||||
"用途": "Use",
|
||||
"使用模型": "Model",
|
||||
@@ -24,8 +54,8 @@ export const EN_SETTINGS: Record<string, string> = {
|
||||
"来自项目配置,需在项目里修改": "Set by the project config; change it there",
|
||||
"在「模型服务」添加决策来源": "Add a decision source under Model services",
|
||||
"按用途选择模型": "Models by use",
|
||||
"默认模型用于当前对话和大多数任务,其他用途默认跟随它;只有要为某件事换一个模型时才改。切换会保留对话并重建运行时,任务执行期间无法修改。":
|
||||
"The default model runs this conversation and most tasks, and every other use follows it unless you give that job a model of its own. Switching keeps the conversation and rebuilds the runtime; it cannot be done while a task runs.",
|
||||
"默认模型用于当前对话和大多数任务,其他用途默认跟随它;只有要为某件事换一个模型时才改。网页搜索默认用对话模型自带的搜索;指定独立搜索模型后,每次搜索多一次计费,且不带对话上下文。切换会保留对话并重建运行时,任务执行期间无法修改。":
|
||||
"The default model runs this conversation and most tasks, and every other use follows it unless you give that job a model of its own. Web search uses the conversation model's built-in search by default; assigning a search model adds one billed request per search and sends it no conversation context. Switching keeps the conversation and rebuilds the runtime; it cannot be done while a task runs.",
|
||||
"添加模型服务": "Add model service",
|
||||
"搜索已添加的服务": "Search added services",
|
||||
"上移 {name}(Alt+上方向键)": "Move {name} up (Alt+Up Arrow)",
|
||||
@@ -583,6 +613,10 @@ export const EN_SETTINGS: Record<string, string> = {
|
||||
"{err} —— 本地功能不受影响,请稍后重试。": "{err} — nothing local is affected; try again later.",
|
||||
"固定版本为 {pinned},当前运行的是 {current}": "Pinned to {pinned}; what is running is {current}",
|
||||
"有新版本 {v}": "{v} is out",
|
||||
"当前版本 {v} 已被撤回": "{v} was withdrawn",
|
||||
"撤回原因:{reason}。": "Reason: {reason}. ",
|
||||
"请更新到 {v},可在下方对应行安装。": "Update to {v} from its row below.",
|
||||
"请在下方选择一个版本。": "Pick a version below.",
|
||||
"切换到 {v} 失败": "Switching to {v} failed",
|
||||
"已固定在 {v},不再提示新版本": "Pinned to {v}; newer versions are not offered",
|
||||
"回退时会固定在所选版本,免得新版本提示把你带回刚离开的版本。": "A rollback pins the version you chose, so an update prompt does not take you back to the one you left.",
|
||||
@@ -996,6 +1030,7 @@ export const EN_SETTINGS: Record<string, string> = {
|
||||
"天": "days",
|
||||
"无法读取自动归档设置。": "Could not read the auto-archive settings.",
|
||||
"已归档": "Archived",
|
||||
"已置顶": "Pinned",
|
||||
"已自动归档 {n} 个会话,可在“已归档”中恢复。": "{n} conversations were archived automatically. You can restore them from Archived.",
|
||||
"图标栏": "Icon rail",
|
||||
"会话侧栏左边的一列图标,直达会话、用量、文件、工具、扩展、记忆、远程、反馈、账号和设置。窗口窄到手机宽度时不显示。": "A column of icons left of the session sidebar that opens conversation, usage, files, tools, extensions, memory, remote, feedback, account and settings. It is not drawn when the window is as narrow as a phone.",
|
||||
|
||||
@@ -24,8 +24,10 @@ export const EN_TRAJECTORY: Record<string, string> = {
|
||||
"系统": "System",
|
||||
"信号轨": "Signal rail",
|
||||
"与下方共用时间轴": "Shares the timeline below",
|
||||
"没有时长、也不是工具调用的运行记录(最多前 8 条),按发生的时刻摆在时间轴上,所以位置就是时间,无法拖动;点一个看它的记录。":
|
||||
"Records of the run that have no duration and are not tool calls (the first 8 at most), placed on the timeline at the moment they happened. Their position is their time, so they cannot be dragged; click one to see its record.",
|
||||
"时间轴缩放": "Timeline zoom",
|
||||
"重置缩放": "Reset zoom",
|
||||
"没有时长、也不是工具调用的运行记录,位置就是时间,无法拖动;缩放后可横向滚动,重叠信号分行显示。点一个看它的记录。":
|
||||
"Records with no duration that are not tool calls. Their position is their time, so they cannot be dragged. Zoom and scroll horizontally; overlapping signals use separate lanes. Select one to inspect its record.",
|
||||
"活动回合": "Activity rounds",
|
||||
"点击一行查看记录": "Select a row to inspect it",
|
||||
"模型回合": "Model round",
|
||||
|
||||
@@ -3,6 +3,21 @@
|
||||
// en_settings and en_kernel were — one screen's worth of wording, read together.
|
||||
|
||||
export const EN_WINDOW: Record<string, string> = {
|
||||
"选择本机会话": "Select local sessions",
|
||||
"选择会话:{name}": "Select session: {name}",
|
||||
"已选 {n} 个会话": "{n} sessions selected",
|
||||
"完成选择": "Done selecting",
|
||||
"全选当前显示的会话": "Select all displayed sessions",
|
||||
"取消当前显示会话的选择": "Deselect displayed sessions",
|
||||
"取消全选": "Deselect all",
|
||||
"全选可见": "Select visible",
|
||||
"归档所选会话": "Archive selected sessions",
|
||||
"取消所选会话归档": "Unarchive selected sessions",
|
||||
"删除所选会话": "Delete selected sessions",
|
||||
"所选会话正在运行,停止后才能操作": "Selected sessions are running. Stop them before continuing.",
|
||||
"正在处理所选会话…": "Processing selected sessions…",
|
||||
"删除 {n} 个会话?": "Delete {n} sessions?",
|
||||
"请先打开一个会话,再查看它的文件。": "Open a session first to browse its files.",
|
||||
"运行指标": "Run metrics",
|
||||
"时间线": "Timeline",
|
||||
"定位": "Locate",
|
||||
@@ -25,6 +40,7 @@ export const EN_WINDOW: Record<string, string> = {
|
||||
"计划已生成,如何执行由你决定。": "The plan is written. What happens next is your call.",
|
||||
"开始执行": "Start execution",
|
||||
"修改计划": "Revise the plan",
|
||||
"目标": "Goal",
|
||||
"暂不执行": "Not now",
|
||||
"「修改计划」将保持在计划模式中,请在下方说明需要修改之处。": "“Revise the plan” stays in plan mode — say what to change below and the planner rewrites it.",
|
||||
"已开始执行。": "Executing.",
|
||||
@@ -34,6 +50,9 @@ export const EN_WINDOW: Record<string, string> = {
|
||||
"继续规划。": "Still planning.",
|
||||
"在下方说明需要修改的内容,规划者将据此重写该计划。": "Say what to change below and the planner rewrites the plan.",
|
||||
"主导航": "Main navigation",
|
||||
"松开以添加项目文件夹": "Drop to add a project folder",
|
||||
"请一次拖入一个文件夹": "Drop one folder at a time",
|
||||
"无法取得本机路径,请使用添加文件夹": "The local path is unavailable. Use Add folder instead.",
|
||||
"浏览器": "Browser",
|
||||
"空白页": "Blank page",
|
||||
"智能体正在查看的页面": "Page the agent is viewing",
|
||||
@@ -83,7 +102,7 @@ export const EN_WINDOW: Record<string, string> = {
|
||||
"失败步数": "Failed",
|
||||
"待确认": "Awaiting you",
|
||||
"内置 Browser": "Built-in Browser",
|
||||
"新建浏览器标签": "New browser tab",
|
||||
"新建标签": "New tab",
|
||||
"调整浏览器宽度": "Resize the browser",
|
||||
"模型正在操作这个页面": "The model is working on this page",
|
||||
"网页地址": "Web address",
|
||||
|
||||
@@ -66,6 +66,7 @@ const SAID: Record<string, string> = {
|
||||
"provider.default_not_selected": "默认模型「{model}」不在已选择的模型中",
|
||||
"provider.display_name_too_long": "显示名称最多 {max} 个字符",
|
||||
"provider.display_name_invalid": "显示名称不能包含换行或其他控制字符",
|
||||
"provider.model_alias_unlisted": "显示别名必须使用已选择模型的完整 ID",
|
||||
|
||||
// ── 来源:这个协议做不到 ─────────────────────────────────────────
|
||||
"provider.no_thinking_param": "该协议不发送思考参数,启用后不会生效",
|
||||
@@ -105,6 +106,11 @@ const SAID: Record<string, string> = {
|
||||
"model.decision_only": "「{model}」是决策来源,只服务 system_one 的询问,不能用作对话模型",
|
||||
"model.not_decision_source": "「{model}」是对话模型,不是决策来源;决策只能选择「模型服务」里添加的决策来源",
|
||||
"roles.override_not_in_user_config": "「{key}」不在用户配置里,可能已被清除,或来自项目配置",
|
||||
"roles.search_model_bad_ref": "搜索模型「{model}」的格式应为 服务/模型",
|
||||
"roles.search_model_not_added": "搜索模型「{model}」所在的连接已被移出",
|
||||
"roles.search_model_removed": "搜索模型「{model}」已不在配置里",
|
||||
"roles.search_model_unsupported": "搜索模型「{model}」的协议不支持原生搜索,或已关闭搜索",
|
||||
"roles.search_model_no_credentials": "搜索模型「{model}」所在的连接没有凭据",
|
||||
"shell.editing_disabled": "这台服务器未开放 shell 设置",
|
||||
"account.signin_disabled": "这台服务器未开放账号登录",
|
||||
"backup.signed_out": "登录账号后才能使用云备份",
|
||||
@@ -160,6 +166,7 @@ const SAID: Record<string, string> = {
|
||||
|
||||
// ── 来源:连接与授权 ─────────────────────────────────────────────
|
||||
"provider.editing_disabled": "这台服务器不允许修改模型来源",
|
||||
"browser.close_failed": "无法关闭这个网页:{error}",
|
||||
"browser.open_failed": "打不开这个网页:{error}",
|
||||
"browser.network_path": "内置浏览器不会打开指向网络上另一台机器的路径,只按路径写法拒绝,不会去访问",
|
||||
"browser.engine_missing": "没有找到可用的浏览器。请安装 Chrome、Edge 或 Chromium,或在配置里用 [browser] executable 指定路径,新会话才会读到",
|
||||
@@ -244,6 +251,10 @@ const SAID: Record<string, string> = {
|
||||
"prompt_refine.failed": "优化失败,请重试",
|
||||
"prompt_refine.bad_request": "优化请求格式不正确",
|
||||
|
||||
"conversation_branch.turn_running": "回合运行中,等它结束后再分支",
|
||||
"conversation_branch.unavailable": "这个位置无法分支(重启后恢复的会话,或已被压缩的历史)",
|
||||
"conversation_branch.failed": "分支失败,请重试",
|
||||
|
||||
"commit.no_repository": "这个工作区不是 git 仓库,无法提交",
|
||||
"commit.nothing_staged": "暂存区是空的,先用 git add 暂存要提交的文件",
|
||||
"commit.staged_changed": "暂存区在你确认之后又变了,请重新起草",
|
||||
|
||||
@@ -21,6 +21,11 @@ export const NOTICE_TEXT: Record<string, string> = {
|
||||
suspected_injection: "一条外部内容看起来在向智能体下指令,已提醒它只当资料看待",
|
||||
await_user: "等待你的输入",
|
||||
default_model_unavailable: "配置里保存的默认模型已不在已配置的供应商中,本次改用第一个可用的模型;在设置里重新选择默认模型即可替换,配置文件未被改动",
|
||||
web_search_model_bad_ref: "搜索模型的格式应为 服务/模型,搜索已关闭;到模型偏好里重新选择,或恢复自动",
|
||||
web_search_model_not_added: "所选搜索模型所在的连接已被移出,搜索已关闭;到模型偏好里重新选择,或恢复自动",
|
||||
web_search_model_removed: "所选搜索模型已不在配置里,搜索已关闭;到模型偏好里重新选择,或恢复自动",
|
||||
web_search_model_unsupported: "所选搜索模型的协议不支持原生搜索,或已关闭搜索,搜索已关闭;到模型偏好里重新选择,或恢复自动",
|
||||
web_search_model_no_credentials: "所选搜索模型所在的连接没有凭据,搜索已关闭;到模型偏好里重新选择,或恢复自动",
|
||||
approval_mode_unrecognized: "配置里的默认审批档位本版本不认识,已按每次询问处理;在界面里选一个档位即可替换",
|
||||
memory_migration_backup: "旧版记忆文件没能先备份,所以没有迁移,保持原样;详情里有具体原因",
|
||||
inbox_recovered: "已恢复 {n} 条未完成的指令。待发送已暂停,请先在输入框上方的队列里查看,再点“继续派发”",
|
||||
|
||||
@@ -7,6 +7,8 @@ import "./styles/tokens.css";
|
||||
import "./styles/app.css";
|
||||
import "./styles/dock.css";
|
||||
import "./styles/studio.css";
|
||||
import "./styles/planpeek.css";
|
||||
import "./styles/phonecomposer.css";
|
||||
import "./styles/tips.css";
|
||||
import "./styles/waitnote.css";
|
||||
import "./styles/narrow.css";
|
||||
@@ -21,6 +23,9 @@ import "./styles/versions.css";
|
||||
import "./styles/changedpill.css";
|
||||
import "./styles/sessionnotes.css";
|
||||
import "./styles/providerpresets.css";
|
||||
import "./styles/sidebardrop.css";
|
||||
import "./styles/sessionquick.css";
|
||||
import "./styles/sessionpin.css";
|
||||
import { App } from "./ui/App";
|
||||
import { SseHub } from "./port/hub";
|
||||
import { HttpError } from "./port/http_error";
|
||||
|
||||
@@ -55,6 +55,11 @@ export interface HostPort {
|
||||
/** Show a project the sidebar lists, which needs no pane. The hub answers
|
||||
* where it is and refuses any folder it does not list. */
|
||||
revealWorkspace(root: string): Promise<Refusal | null>;
|
||||
/** Whether this shell can open the folder it writes its own logs to. */
|
||||
revealsLogs(): boolean;
|
||||
/** Open that folder in the platform's file manager; null once shown,
|
||||
* otherwise why it was not. */
|
||||
revealLogs(): Promise<Refusal | null>;
|
||||
/** Whether this shell draws the agent's browser pages inside the window. */
|
||||
drawsBrowserViews(): boolean;
|
||||
/** Draw one of the agent's pages over rect, in on-screen coordinates, and
|
||||
@@ -150,6 +155,7 @@ interface ElectronBridge {
|
||||
pickFolder(startIn: string): Promise<string>;
|
||||
revealPath?(base: string, path: string): Promise<Refusal | null>;
|
||||
revealWorkspace?(root: string): Promise<Refusal | null>;
|
||||
revealLogs?(): Promise<Refusal | null>;
|
||||
showBrowserView?(target: string, rect: ViewRect): Promise<void>;
|
||||
hideBrowserView?(): Promise<void>;
|
||||
freezeBrowserView?(): Promise<string>;
|
||||
@@ -223,6 +229,12 @@ class ElectronHost implements HostPort {
|
||||
revealWorkspace(root: string) {
|
||||
return this.api.revealWorkspace?.(root) ?? Promise.resolve({ error: "this shell cannot show files" });
|
||||
}
|
||||
revealsLogs() {
|
||||
return typeof this.api.revealLogs === "function";
|
||||
}
|
||||
revealLogs() {
|
||||
return this.api.revealLogs?.() ?? Promise.resolve({ error: "this shell cannot show files" });
|
||||
}
|
||||
// A shell older than the verbs has no views to draw, and says so by lacking them.
|
||||
drawsBrowserViews() {
|
||||
return typeof this.api.showBrowserView === "function";
|
||||
@@ -298,6 +310,12 @@ class BrowserHost implements HostPort {
|
||||
revealWorkspace() {
|
||||
return Promise.resolve({ error: "a browser tab cannot show files" });
|
||||
}
|
||||
revealsLogs() {
|
||||
return false;
|
||||
}
|
||||
revealLogs() {
|
||||
return Promise.resolve({ error: "a browser tab cannot show files" });
|
||||
}
|
||||
drawsBrowserViews() {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -12,3 +12,11 @@ it("offers reveal only when the shell can show both a pane's file and a project"
|
||||
expect((await hostOver({ revealPath: async () => null, revealWorkspace: async () => null })).revealsFiles()).toBe(true);
|
||||
expect((await hostOver({ revealPath: async () => null })).revealsFiles()).toBe(false);
|
||||
});
|
||||
|
||||
it("offers the logs folder only when the shell can open it, and passes its refusal through", async () => {
|
||||
expect((await hostOver({})).revealsLogs()).toBe(false);
|
||||
const refusing = await hostOver({ revealLogs: async () => ({ error: "no such folder" }) });
|
||||
expect(refusing.revealsLogs()).toBe(true);
|
||||
expect(await refusing.revealLogs()).toEqual({ error: "no such folder" });
|
||||
expect(await (await hostOver({ revealLogs: async () => null })).revealLogs()).toBeNull();
|
||||
});
|
||||
@@ -13,6 +13,7 @@ export interface ThemeBackground {
|
||||
homeOpacity: number;
|
||||
taskOpacity: number;
|
||||
overlayStrength: number;
|
||||
textGlow?: "default" | "none";
|
||||
}
|
||||
|
||||
// A live backdrop the window draws, rather than a picture it places. Absent on
|
||||
@@ -36,7 +37,7 @@ export interface ThemePack {
|
||||
sky?: ThemeSky;
|
||||
hasPreview?: boolean;
|
||||
removable?: boolean;
|
||||
// Tokens the kernel dropped and why: an unknown name, or a value that could
|
||||
// Values the kernel dropped and why: an unknown name, or a value that could
|
||||
// not be let into a stylesheet. The pack still loads without them.
|
||||
warnings?: string[];
|
||||
}
|
||||
|
||||
@@ -28,6 +28,20 @@ export interface McpEntry {
|
||||
launch?: string;
|
||||
// Why a pending server waits for the user, as the kernel's code.
|
||||
pendingReason?: string;
|
||||
// Tools this connection withheld from the model because their definition is
|
||||
// not the one the user approved. heldDigest names exactly that set, and
|
||||
// approving it hands the digest back so what is approved is what was shown.
|
||||
// heldMore counts withheld tools past the list's cap.
|
||||
held?: McpHeld[];
|
||||
heldDigest?: string;
|
||||
heldMore?: number;
|
||||
}
|
||||
|
||||
// One withheld tool and how it differs from what was approved: added, changed,
|
||||
// or unverifiable when the approval record could not be read.
|
||||
export interface McpHeld {
|
||||
name: string;
|
||||
class: 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, Completion, CompletionItem, DeviceGrant, StudioLogs, VersionHub, VersionNotes, ApprovalMode, ApprovalVerdict, Checkpoint, RewindPlan, RewindResult, RewindScope, HistoryMessage, HostTodo, BrowserTab, ModelEntry, Preset, ProviderSetup, RoleAssignments, RoleOverride, SessionEntry, SessionStatus, WalletReading, MemoryCatalog, MemoryEdit, UsageReport, MemoryEntry, WorkspaceInfo, Attachment, DroppedRef, Queue, QueueItem, Queued, ChipCall, NotifyPrefs, TrayPrefs, UsageQuery } from "./port";
|
||||
import type { AccountState, AgentPort, Completion, CompletionItem, DeviceGrant, StudioLogs, VersionHub, VersionNotes, ApprovalMode, ApprovalVerdict, Checkpoint, RewindPlan, RewindResult, RewindUndo, RewindScope, HistoryMessage, HostTodo, BrowserTab, ModelEntry, Preset, ProviderSetup, RoleAssignments, RoleOverride, SessionEntry, SessionStatus, WalletReading, MemoryCatalog, MemoryEdit, UsageReport, MemoryEntry, WorkspaceInfo, Attachment, DroppedRef, Queue, QueueItem, Queued, ChipCall, NotifyPrefs, TrayPrefs, UsageQuery } from "./port";
|
||||
import type { ExecutionGraphRead, TrajectoryRead, WireEvent } from "./wire";
|
||||
import { MockWorkspace } from "./mock_workspace";
|
||||
import { SCRIPT, mockMsgIndex, mockTurnStart } from "./fixture";
|
||||
@@ -17,7 +17,7 @@ export class MockPort extends MockWorkspace implements AgentPort {
|
||||
private log: WireEvent[] = [];
|
||||
// What the user has sent, so checkpoints() can mirror one per turn.
|
||||
private prompts: string[] = [];
|
||||
private undone: string[] | null = null;
|
||||
private undone: { prompts: string[]; offer: RewindUndo } | null = null;
|
||||
private at = 0;
|
||||
// The kernel numbers every frame a client cannot afford to miss, and a
|
||||
// bootstrap cut is read against those numbers.
|
||||
@@ -67,6 +67,7 @@ export class MockPort extends MockWorkspace implements AgentPort {
|
||||
|
||||
// The subagent runs somewhere cheaper; everything else rides the main model.
|
||||
private assigned: RoleAssignments = {
|
||||
web_search: "",
|
||||
planner: "",
|
||||
subagent: "deepseek/deepseek-flash",
|
||||
guardian: "",
|
||||
@@ -335,6 +336,7 @@ export class MockPort extends MockWorkspace implements AgentPort {
|
||||
}
|
||||
|
||||
async newSession() {
|
||||
this.clearUndo();
|
||||
this.log = [];
|
||||
this.at = 0;
|
||||
this.state.goal = "";
|
||||
@@ -404,6 +406,10 @@ export class MockPort extends MockWorkspace implements AgentPort {
|
||||
return this.prompts.map((prompt, i) => ({ turn: i, prompt, files: this.prompts.length > 1 ? 3 : 0, msgIndex: mockMsgIndex(i + 1) }));
|
||||
}
|
||||
|
||||
async branchAt(turn: number): Promise<string> {
|
||||
return `/mock/branch-${turn}.jsonl`;
|
||||
}
|
||||
|
||||
// The second prompt onwards is scripted to have run bash, so mock mode can
|
||||
// show the consent stage the real kernel demands on partial coverage.
|
||||
async prepareRewind(turn: number, scope: RewindScope): Promise<RewindPlan> {
|
||||
@@ -425,13 +431,26 @@ export class MockPort extends MockWorkspace implements AgentPort {
|
||||
|
||||
async commitRewind(planId: string): Promise<RewindResult> {
|
||||
const turn = Number(planId.split("-")[2] ?? 0), files = !planId.endsWith("conversation") && this.prompts.length > 1;
|
||||
this.undone = this.prompts.slice();
|
||||
this.prompts = this.prompts.slice(0, turn);
|
||||
return { ok: true, transactionId: `mock-tx-${turn}`, undoAvailable: true, written: files ? ["main.ts", "style.css"] : [], deleted: files ? ["note.txt"] : [] };
|
||||
const scope = planId.split("-")[3];
|
||||
const offer = { transactionId: `mock-tx-${turn}-${crypto.randomUUID()}`, turn, files: files ? 3 : 0 };
|
||||
this.undone = { prompts: this.prompts.slice(), offer };
|
||||
this.emit({ kind: "rewind_changed" });
|
||||
if (scope !== "code") this.prompts = this.prompts.slice(0, turn);
|
||||
return { ok: true, transactionId: offer.transactionId, undoAvailable: true, conversationOk: scope !== "code",
|
||||
written: files ? ["main.ts", "style.css"] : [], deleted: files ? ["note.txt"] : [] };
|
||||
}
|
||||
|
||||
private clearUndo() {
|
||||
if (!this.undone) return;
|
||||
this.undone = null;
|
||||
this.emit({ kind: "rewind_changed" });
|
||||
}
|
||||
|
||||
async availableUndo(): Promise<RewindUndo | null> { return this.state.running ? null : this.undone?.offer ?? null; }
|
||||
|
||||
async undoRewind(_transactionId: string): Promise<void> {
|
||||
if (this.undone) this.prompts = this.undone;
|
||||
if (this.undone) this.prompts = this.undone.prompts;
|
||||
this.clearUndo();
|
||||
}
|
||||
|
||||
subscribe(onEvent: (ev: WireEvent) => void, _onGap?: () => void, bootstrap?: () => Promise<number>) {
|
||||
@@ -585,6 +604,8 @@ export class MockPort extends MockWorkspace implements AgentPort {
|
||||
return { id: "mock", target: "mock", url, title: url, active: true };
|
||||
}
|
||||
|
||||
async browserClose(_tab: string): Promise<void> {}
|
||||
|
||||
async queue(): Promise<Queue> {
|
||||
return {
|
||||
revision: this.queueRevision,
|
||||
@@ -660,6 +681,7 @@ export class MockPort extends MockWorkspace implements AgentPort {
|
||||
this.emit({ kind: "steer", text });
|
||||
return;
|
||||
}
|
||||
this.clearUndo();
|
||||
this.prompts.push(text);
|
||||
// The first turn is what puts the session on disk. serve answers with a
|
||||
// truncated first message straight away and swaps in the generated title
|
||||
@@ -685,6 +707,7 @@ export class MockPort extends MockWorkspace implements AgentPort {
|
||||
}
|
||||
|
||||
async resume(_path: string) {
|
||||
this.clearUndo();
|
||||
this.log = [];
|
||||
this.at = 0;
|
||||
}
|
||||
|
||||
@@ -107,6 +107,16 @@ export class MockMcp extends MockProvider {
|
||||
];
|
||||
}
|
||||
|
||||
async trustMcp(name: string, digest: string) {
|
||||
const s = this.servers.find((x) => x.name === name);
|
||||
if (!s) return { state: "failed", error: "no configured MCP server named " + name };
|
||||
if (!s.held?.length || s.heldDigest !== digest) return { state: "failed", error: "the held MCP tool definitions changed since they were shown" };
|
||||
s.held = undefined;
|
||||
s.heldDigest = undefined;
|
||||
s.tools = s.tools + 1;
|
||||
return { state: "ready", tools: s.tools };
|
||||
}
|
||||
|
||||
async reconnectMcp(name: string) {
|
||||
const s = this.servers.find((x) => x.name === name);
|
||||
if (!s) return { state: "failed", error: "no configured MCP server named " + name };
|
||||
|
||||
@@ -8,7 +8,7 @@ export function mockModels(efforts: string[]): ModelEntry[] {
|
||||
return [
|
||||
{
|
||||
ref: "deepseek/deepseek-v4-pro", provider: "deepseek", model: "deepseek-v4-pro",
|
||||
kind: "openai", vendor: "api.deepseek.com", keyEnv: "DEEPSEEK_API_KEY", active: true, efforts, effort: "high",
|
||||
kind: "openai", vendor: "api.deepseek.com", keyEnv: "DEEPSEEK_API_KEY", active: true, efforts, effort: "high", webSearch: true,
|
||||
contextWindow: 131072, price: { input: 2, output: 8, currency: "CNY" },
|
||||
},
|
||||
{
|
||||
|
||||
@@ -49,6 +49,7 @@ export interface ModelEntry {
|
||||
// What the user renamed provider to; provider stays the identity.
|
||||
displayName?: string;
|
||||
model: string;
|
||||
modelAlias?: string;
|
||||
kind?: string;
|
||||
active?: boolean;
|
||||
default?: boolean;
|
||||
@@ -59,6 +60,7 @@ export interface ModelEntry {
|
||||
// nothing declares it — never "no". Rendering a guess here sends the user to
|
||||
// a rejected request they cannot explain.
|
||||
vision?: boolean;
|
||||
webSearch?: boolean;
|
||||
// What this model produces. Absent means chat: it is what every model was
|
||||
// before decision backends, so a kernel predating the field still answers.
|
||||
answers?: "chat" | "decision";
|
||||
@@ -91,6 +93,10 @@ export interface ModelMode {
|
||||
// kernel appear here; image routing joins once it can name its own model
|
||||
// instead of borrowing whatever the subagent runs.
|
||||
export interface RoleAssignments {
|
||||
web_search: string;
|
||||
web_search_effective?: string;
|
||||
web_search_reason?: string;
|
||||
web_search_source?: string;
|
||||
planner: string;
|
||||
subagent: string;
|
||||
guardian: string;
|
||||
|
||||
@@ -108,6 +108,7 @@ export interface PluginAction {
|
||||
warnings?: string[];
|
||||
error?: string;
|
||||
next?: string;
|
||||
errorCode?: string;
|
||||
// The kernel shortened a field or list of this step; what is shown is not all of it.
|
||||
previewTruncated?: boolean;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
// @vitest-environment jsdom
|
||||
import { afterEach, expect, it, vi } from "vitest";
|
||||
import { SsePort } from "./sse";
|
||||
import { SseExtensions } from "./sse_ext";
|
||||
import type { PluginPlan } from "./plugin";
|
||||
|
||||
afterEach(() => { vi.restoreAllMocks(); vi.unstubAllGlobals(); });
|
||||
|
||||
it.each([
|
||||
["Studio", () => new SsePort("/rt/neutral")],
|
||||
["extension port", () => new SseExtensions("/rt/neutral")],
|
||||
] as const)("preserves publication and content causes in %s", async (_name, make) => {
|
||||
const port = make();
|
||||
const plan: PluginPlan = {
|
||||
ok: false, status: "failed", applied: true,
|
||||
actions: [{ kind: "plugin", action: "install_plugin_package", status: "failed", riskLevel: "medium", error: "detail", errorCode: "install.publication_failed" }],
|
||||
};
|
||||
vi.stubGlobal("fetch", vi.fn().mockImplementation(async () => Response.json(plan)));
|
||||
expect((await port.installPlugin({ source: "/neutral", replace: true, planId: "approved" })).actions?.[0].errorCode).toBe("install.publication_failed");
|
||||
plan.actions![0].errorCode = "install.digest_mismatch";
|
||||
expect((await port.installPlugin({ source: "/neutral", replace: true, planId: "approved" })).actions?.[0].errorCode).toBe("install.digest_mismatch");
|
||||
plan.ok = true; plan.status = "planned"; plan.applied = false;
|
||||
plan.actions![0].status = "planned"; delete plan.actions![0].error; delete plan.actions![0].errorCode;
|
||||
expect((await port.planPlugin({ source: "/neutral" })).status).toBe("planned");
|
||||
});
|
||||
@@ -19,7 +19,7 @@ export type { MemoryEdit } from "./memory";
|
||||
export type { Money, UsageDay, UsageModel, UsageProvider, UsageQuery, UsageReport } from "./usage";
|
||||
import type { CompactionSettings, Completion, CompletionItem, ModelEntry, ModelMode, ModelPrice, RoleAssignments, RoleOverride } from "./model";
|
||||
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 { ApprovalDefault, ApprovalMode, WorkspaceTrust, ApprovalVerdict, BrowserTab, Checkpoint, HistoryMessage, HostTodo, JobEntry, Preset, RewindPlan, RewindResult, RewindUndo, RewindScope, SessionEntry, SessionStatus, WalletLine, WalletReading, PlanAction } from "./session";
|
||||
import type { ContextBreakdown, ShellOption, ShellSettings } from "./shell";
|
||||
import type { SkillCatalog, SkillEntry } from "./skill";
|
||||
import type { StudioCrash, StudioLogs, UpdateProgress, VersionEntry, VersionHub, VersionNotes } from "./version";
|
||||
@@ -33,7 +33,7 @@ export type { AccountState, AccountUser, ApprovalDefault, ApprovalMode, Approval
|
||||
HookCatalog, HookDryRun, HookEntry, HookEventInfo, HookSource, JobEntry, McpCatalog, McpDraft,
|
||||
McpDraftServer, McpEntry, McpInstallResult, McpInstallScope, McpLoad, McpRisk, McpTool, MemoryCatalog,
|
||||
MemoryEntry, ModelEntry, ModelMode, ModelPrice, NetworkProbe, NetworkSettings, Preset, RewindPlan,
|
||||
RewindResult, RewindScope, RoleAssignments, RoleOverride, ScopeLayer, SessionEntry, SessionStatus,
|
||||
RewindResult, RewindUndo, RewindScope, RoleAssignments, RoleOverride, ScopeLayer, SessionEntry, SessionStatus,
|
||||
ShellOption, ShellSettings, SkillCatalog, SkillEntry, UpdateProgress, VersionEntry,
|
||||
StudioCrash, StudioLogs, VersionHub, VersionNotes, WalletLine, WalletReading, ChangeDiff, CommitFile, CommitProposal, CommitRequest, CommitResult, WorkspaceBranch, WorkspaceBranches, WorkspaceChange, WorkspaceChanges, WorkspaceEntry, WorkspaceFile, WorkspaceFiles, WorkspaceGit, WorkspaceInfo };
|
||||
|
||||
@@ -300,6 +300,9 @@ export interface AgentPort {
|
||||
// Retries a failed or disconnected server and answers with its new state, so
|
||||
// the caller never has to race a follow-up GET against the connect.
|
||||
reconnectMcp(name: string): Promise<{ state: string; tools?: number; error?: string }>;
|
||||
// Approves the withheld definitions the digest names, then reconnects. The
|
||||
// kernel refuses a digest that no longer matches what it withholds.
|
||||
trustMcp(name: string, digest: string): Promise<{ state: string; tools?: number; error?: string }>;
|
||||
setMcpEnabled(name: string, enabled: boolean, scope?: ScopeLayer, root?: string): Promise<void>;
|
||||
clearMcpOverride(name: string, root?: string): Promise<void>;
|
||||
setMcpLoad(name: string, load: McpLoad): Promise<void>;
|
||||
@@ -456,6 +459,10 @@ export interface AgentPort {
|
||||
// the user agreed to.
|
||||
prepareRewind(turn: number, scope: RewindScope): Promise<RewindPlan>;
|
||||
commitRewind(planId: string): Promise<RewindResult>;
|
||||
// Copies the conversation up to the end of a turn into a new session and
|
||||
// answers with its path; this session stays where it is.
|
||||
branchAt(turn: number, name: string): Promise<string>;
|
||||
availableUndo(): Promise<RewindUndo | null>;
|
||||
// Reverses a committed rewind. Only reachable while the caller still holds the
|
||||
// transaction id the commit returned.
|
||||
undoRewind(transactionId: string): Promise<void>;
|
||||
@@ -542,6 +549,7 @@ export interface AgentPort {
|
||||
// the agent can read and drive; the window keeping its own would be a page
|
||||
// neither could hand to the other.
|
||||
browserOpen(url: string, newTab: boolean): Promise<BrowserTab>;
|
||||
browserClose(tab: string): Promise<void>;
|
||||
// The entry's full text. preview is cut to a manifest-sized line, so editing
|
||||
// against it would silently shorten what the user actually said.
|
||||
readQueued(itemId: string): Promise<string>;
|
||||
|
||||
@@ -9,6 +9,7 @@ export interface ProviderEntry {
|
||||
kind: string;
|
||||
baseUrl: string;
|
||||
models: string[];
|
||||
modelAliases?: Record<string, string>;
|
||||
default: string;
|
||||
hasKey: boolean;
|
||||
keyEnv?: string;
|
||||
@@ -215,6 +216,8 @@ export interface ProviderModelCheckRequest {
|
||||
// Changing a source that already exists: everything else on the entry stays.
|
||||
export interface ProviderEdit {
|
||||
name: string;
|
||||
// Omitted preserves aliases; blank or missing values reset listed models.
|
||||
modelAliases?: Record<string, string>;
|
||||
// Omitted leaves the wire format alone.
|
||||
kind?: string;
|
||||
baseUrl?: string;
|
||||
|
||||
@@ -102,6 +102,12 @@ export interface RewindFileStage {
|
||||
compensateError?: string;
|
||||
}
|
||||
|
||||
export interface RewindUndo {
|
||||
transactionId: string;
|
||||
turn: number;
|
||||
files: number;
|
||||
}
|
||||
|
||||
export interface RewindResult {
|
||||
ok?: boolean;
|
||||
transactionId?: string;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { PLAN_ACTIONS, type PlanAction } from "./session";
|
||||
import type { AccountState, AgentPort, ChipCall, CompactionSettings, Completion, DeviceGrant, ProviderProbe, UpdateProgress, StudioLogs, 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 type { AccountState, AgentPort, ChipCall, CompactionSettings, Completion, DeviceGrant, ProviderProbe, UpdateProgress, StudioLogs, VersionHub, VersionNotes, ApprovalMode, ApprovalVerdict, Checkpoint, RewindPlan, RewindResult, RewindUndo, 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 CommitProposal, type CommitRequest, type CommitResult, type WorkspaceFile, type WorkspaceFiles } from "./port";
|
||||
import { SseWorkspace } from "./sse_workspace";
|
||||
import type { StoragePlan, StorageQuery, StorageState } from "./storage";
|
||||
@@ -138,6 +138,17 @@ export class SsePort extends SseWorkspace implements AgentPort {
|
||||
return this.post("/memory/restore", { name, revision });
|
||||
}
|
||||
|
||||
async trustMcp(name: string, digest: string) {
|
||||
const res = await fetch(this.base + "/mcp/trust", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
credentials: "same-origin",
|
||||
body: JSON.stringify({ name, digest }),
|
||||
});
|
||||
const body = (await res.json().catch(() => ({}))) as { state?: string; tools?: number | unknown[]; error?: string };
|
||||
if (!res.ok && !body.error) throw new Error(`/mcp/trust: ${res.status}`);
|
||||
return { state: body.state ?? (res.ok ? "ready" : "failed"), tools: typeof body.tools === "number" ? body.tools : body.tools?.length, error: body.error };
|
||||
}
|
||||
async reconnectMcp(name: string) {
|
||||
const res = await fetch(this.base + "/mcp/reconnect", {
|
||||
method: "POST",
|
||||
@@ -497,6 +508,11 @@ export class SsePort extends SseWorkspace implements AgentPort {
|
||||
return this.post0<RewindResult>("/rewind/commit", { planId });
|
||||
}
|
||||
|
||||
async branchAt(turn: number, name: string) {
|
||||
return (await this.post0<{ path: string }>("/branch/at", { turn, name })).path;
|
||||
}
|
||||
availableUndo() { return this.get<RewindUndo | null>("/rewind/undo"); }
|
||||
|
||||
undoRewind(transactionId: string) {
|
||||
return this.post("/rewind/undo", { transactionId });
|
||||
}
|
||||
@@ -684,6 +700,9 @@ export class SsePort extends SseWorkspace implements AgentPort {
|
||||
browserOpen(url: string, newTab: boolean) {
|
||||
return this.post0<BrowserTab>("/browser/open", { url, newTab });
|
||||
}
|
||||
browserClose(tab: string) {
|
||||
return this.post0<void>("/browser/close", { tab });
|
||||
}
|
||||
async readQueued(itemId: string) {
|
||||
const r = await this.get<{ envelope?: { displayText?: string } }>("/inbox/items/" + encodeURIComponent(itemId));
|
||||
// Every stored entry has a body — the kernel refuses an empty one at
|
||||
|
||||
@@ -21,6 +21,17 @@ export class SseMcp extends SseProvider {
|
||||
// whole point of the button, so it is read rather than thrown away.
|
||||
// 502 carries the diagnosis in the body — the reason the retry failed is the
|
||||
// whole point of the button, so it is read rather than thrown away.
|
||||
async trustMcp(name: string, digest: string) {
|
||||
const res = await fetch(this.base + "/mcp/trust", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
credentials: "same-origin",
|
||||
body: JSON.stringify({ name, digest }),
|
||||
});
|
||||
const body = (await res.json().catch(() => ({}))) as { state?: string; tools?: number | unknown[]; error?: string };
|
||||
if (!res.ok && !body.error) throw new Error(`/mcp/trust: ${res.status}`);
|
||||
return { state: body.state ?? (res.ok ? "ready" : "failed"), tools: typeof body.tools === "number" ? body.tools : body.tools?.length, error: body.error };
|
||||
}
|
||||
async reconnectMcp(name: string) {
|
||||
const res = await fetch(this.base + "/mcp/reconnect", {
|
||||
method: "POST",
|
||||
|
||||
@@ -15,6 +15,12 @@ export interface VersionNotes {
|
||||
cached: boolean;
|
||||
}
|
||||
|
||||
// The running version was pulled after publication; reason is the publisher's.
|
||||
export interface WithdrawnNote {
|
||||
version: string;
|
||||
reason: string;
|
||||
}
|
||||
|
||||
// err rides alongside the data: an unreachable catalog must not hide which
|
||||
// version is running.
|
||||
export interface VersionHub {
|
||||
@@ -24,6 +30,7 @@ export interface VersionHub {
|
||||
latest: string;
|
||||
newer: boolean;
|
||||
versions: VersionEntry[];
|
||||
withdrawn?: WithdrawnNote;
|
||||
err?: string;
|
||||
}
|
||||
|
||||
|
||||
@@ -32,6 +32,7 @@ export type Kind =
|
||||
| "workspace_changed"
|
||||
| "completion_summary"
|
||||
| "inbox_changed"
|
||||
| "rewind_changed"
|
||||
| "adjudications_changed"
|
||||
| "browser_tabs_changed"
|
||||
| "graph_delta"
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
// Per-machine display choices. They live in localStorage rather than in the
|
||||
// kernel's settings because they answer "what does this screen show me",
|
||||
// which is not a fact about the session and does not travel with it.
|
||||
import { useSyncExternalStore } from "react";
|
||||
|
||||
// Per-machine interface choices. They live in localStorage rather than in the
|
||||
// kernel's settings because they govern this screen's presentation and input,
|
||||
// not session facts, and do not travel with the session.
|
||||
|
||||
// On unless this machine turned it off. The desktop shell reads the same key
|
||||
// from its saved preferences, so a flip here reaches it without a request.
|
||||
@@ -310,3 +312,70 @@ export function setShowsHiddenFiles(on: boolean): void {
|
||||
/* the choice holds for this window and is forgotten on the next */
|
||||
}
|
||||
}
|
||||
|
||||
// The send key is local to this machine, never read from project settings.
|
||||
export type SendShortcut = "enter" | "modifier_enter";
|
||||
const SEND_SHORTCUT_KEY = "rx-send-shortcut";
|
||||
const sendShortcutListeners = new Set<() => void>();
|
||||
let transientSendShortcut: SendShortcut = "enter";
|
||||
// A refused write can still leave reads returning the old choice.
|
||||
let sendShortcutStorageFailed = false;
|
||||
|
||||
export function sendShortcut(): SendShortcut {
|
||||
if (sendShortcutStorageFailed) return transientSendShortcut;
|
||||
try {
|
||||
return localStorage.getItem(SEND_SHORTCUT_KEY) === "modifier_enter" ? "modifier_enter" : "enter";
|
||||
} catch {
|
||||
return transientSendShortcut;
|
||||
}
|
||||
}
|
||||
|
||||
export function setSendShortcut(next: SendShortcut): void {
|
||||
transientSendShortcut = next;
|
||||
try { localStorage.setItem(SEND_SHORTCUT_KEY, next); sendShortcutStorageFailed = false; } catch { sendShortcutStorageFailed = true; }
|
||||
sendShortcutListeners.forEach((fn) => fn());
|
||||
}
|
||||
|
||||
function subscribeSendShortcut(fn: () => void): () => void {
|
||||
sendShortcutListeners.add(fn);
|
||||
const storage = (event: StorageEvent) => {
|
||||
if (event.key === SEND_SHORTCUT_KEY || event.key === null) fn();
|
||||
};
|
||||
window.addEventListener("storage", storage);
|
||||
return () => { sendShortcutListeners.delete(fn); window.removeEventListener("storage", storage); };
|
||||
}
|
||||
|
||||
export function useSendShortcut(): SendShortcut {
|
||||
return useSyncExternalStore(subscribeSendShortcut, sendShortcut, sendShortcut);
|
||||
}
|
||||
|
||||
// The sidebar remains the default session switcher. Tabs are a local display
|
||||
// choice and do not change which sessions stay open in the kernel.
|
||||
const SESSION_TABS_KEY = "rx-show-session-tabs";
|
||||
let sessionTabsVisible = false;
|
||||
const sessionTabListeners = new Set<() => void>();
|
||||
|
||||
export function showsSessionTabs(): boolean {
|
||||
try {
|
||||
return localStorage.getItem(SESSION_TABS_KEY) === "on";
|
||||
} catch {
|
||||
return sessionTabsVisible;
|
||||
}
|
||||
}
|
||||
|
||||
export function setShowsSessionTabs(on: boolean): void {
|
||||
sessionTabsVisible = on;
|
||||
try {
|
||||
localStorage.setItem(SESSION_TABS_KEY, on ? "on" : "off");
|
||||
} catch {
|
||||
/* the choice holds for this window and is forgotten on the next */
|
||||
}
|
||||
sessionTabListeners.forEach((fn) => fn());
|
||||
}
|
||||
|
||||
export function onShowsSessionTabsChange(fn: () => void): () => void {
|
||||
sessionTabListeners.add(fn);
|
||||
return () => {
|
||||
sessionTabListeners.delete(fn);
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { useSyncExternalStore } from "react";
|
||||
|
||||
const PREFIX = "rx-rewind-notice-dismissed:";
|
||||
const listeners = new Set<() => void>();
|
||||
|
||||
function dismissed(transactionId: string | undefined): boolean {
|
||||
if (!transactionId) return false;
|
||||
try { return localStorage.getItem(PREFIX + transactionId) === "1"; }
|
||||
catch { return false; }
|
||||
}
|
||||
|
||||
function refresh(event: StorageEvent) {
|
||||
if (event.key === null || event.key.startsWith(PREFIX)) listeners.forEach((fn) => fn());
|
||||
}
|
||||
|
||||
function subscribe(fn: () => void): () => void {
|
||||
listeners.add(fn);
|
||||
if (listeners.size === 1) window.addEventListener("storage", refresh);
|
||||
return () => {
|
||||
listeners.delete(fn);
|
||||
if (listeners.size === 0) window.removeEventListener("storage", refresh);
|
||||
};
|
||||
}
|
||||
|
||||
export function dismissRewindNotice(transactionId: string): void {
|
||||
localStorage.setItem(PREFIX + transactionId, "1");
|
||||
listeners.forEach((fn) => fn());
|
||||
}
|
||||
|
||||
export function useRewindNoticeDismissed(transactionId: string | undefined): boolean {
|
||||
return useSyncExternalStore(subscribe, () => dismissed(transactionId), () => false);
|
||||
}
|
||||
@@ -55,6 +55,7 @@ export const initialState: SessionState = {
|
||||
steerQueue: [], takenBack: [], subagentPhase: {},
|
||||
awaitingTurnStart: [],
|
||||
queueMoved: 0,
|
||||
rewindMoved: 0,
|
||||
browserTabsMoved: 0,
|
||||
panels: [],
|
||||
views: [],
|
||||
@@ -221,6 +222,7 @@ function apply(s: SessionState, ev: SessionEvent): SessionState {
|
||||
return left.length === s.entranceOwed.length ? s : { ...s, entranceOwed: left };
|
||||
}
|
||||
if (ev.kind === "__runtime_seen") return { ...s, runtime: s.runtime.filter((n) => n.id !== ev.id) };
|
||||
if (ev.kind === "rewind_changed") return { ...s, rewindMoved: s.rewindMoved + 1 };
|
||||
if (ev.kind === "inbox_changed") return { ...s, queueMoved: s.queueMoved + 1 };
|
||||
if (ev.kind === "browser_tabs_changed") return { ...s, browserTabsMoved: s.browserTabsMoved + 1 };
|
||||
// Both event.Message emitters carry assistant text, so nothing on the wire
|
||||
|
||||
@@ -243,6 +243,7 @@ export interface SessionState {
|
||||
// and this is what asks it again. Counting, not the kernel's own revision:
|
||||
// the point is that it changed, and a counter cannot arrive out of order.
|
||||
queueMoved: number;
|
||||
rewindMoved: number;
|
||||
// How many times the agent's browser tabs moved; the tabs are read back.
|
||||
browserTabsMoved: number;
|
||||
// Standing extension surfaces, keyed by plugin and surface id. They describe
|
||||
|
||||
@@ -256,6 +256,19 @@ rules are written in.
|
||||
the global icon navigation is not visible. It follows the window's existing
|
||||
navigation visibility state, including collapsed-only mode and narrow-window
|
||||
folds, so both columns never offer the same footer actions together.
|
||||
- Browser wide mode changes the existing pane's grid tracks, keeping the
|
||||
workbench, pages, transcript and composer in place. The conversation takes
|
||||
34% of the pane up to 380px; the browser takes the remaining width.
|
||||
- The saved split width is untouched, and its divider is absent while wide so dragging
|
||||
cannot silently overwrite a width that is not being drawn.
|
||||
- The compact composer's metrics row wraps inside the composer. Absolute
|
||||
positioning below the box lets balance and task controls cross the browser seam.
|
||||
- Under the side fold, wide mode stacks the browser above the conversation and
|
||||
composer. Two columns at phone width cannot keep either surface usable.
|
||||
- The workbench toggle and Ctrl/Cmd+Shift+B share `browser.wide`. Escape returns
|
||||
to split before the window can close the browser or stop the run. Closing the
|
||||
dock or leaving the conversation resets wide mode; mounting never expands it.
|
||||
|
||||
- Column widths change on `.app`, so the tween belongs there and `.cols` only
|
||||
reads the result into a track. The tween must be cut during a drag: a .34s
|
||||
tween makes the column chase the pointer.
|
||||
@@ -1192,6 +1205,11 @@ what exists.
|
||||
|
||||
## Panes
|
||||
|
||||
- The sidebar is the default session switcher. Settings > Appearance offers
|
||||
session tabs as an opt-in, off by default and saved on this machine. The strip
|
||||
only mounts when the choice is on and more than one pane is open; changing the
|
||||
choice does not close or stop any session.
|
||||
|
||||
- Panes are stacked, one visible at a time. Side by side they squeeze each other
|
||||
and a glance cannot tell which composer belongs to which run; as tabs the
|
||||
background one keeps running.
|
||||
|
||||
@@ -82,6 +82,9 @@ grid to break.
|
||||
|
||||
## Surfaces
|
||||
|
||||
- Studio's default light palette separates the frame (`#F0F0ED`), reading
|
||||
surface (`#FAFAF8`) and composer (`--raised`). Its composer uses a short
|
||||
`--compose-shadow`; installed packs retain their existing surface recipe.
|
||||
- `--float` / `--float-hi` exist apart from `--raised`. In dark, `--raised` and
|
||||
what it covers are nearly the same lightness, and a black shadow on a
|
||||
near-black ground paints nothing, so a menu read as transparent. A floating
|
||||
@@ -238,6 +241,14 @@ raised with its ratios kept.
|
||||
|
||||
## Weight and contrast
|
||||
|
||||
- Default light Studio replies use `--w-read: 400` for prose only. Headings,
|
||||
emphasis and small UI text keep their existing weights. A chosen weight,
|
||||
installed pack or dark scheme falls back to `--w-reg`.
|
||||
- `background.textGlow: none` paints `--flow-text-shadow: none` through the
|
||||
pack sheet. The backdrop transcript rule defaults to its 2px shadow and 7px
|
||||
halo when this variable is absent. The choice affects text only; the frosted
|
||||
backing remains. Reduced effects overrides the text shadow.
|
||||
|
||||
- Stroke weight is an axis beside contrast, not part of it: however bright the
|
||||
colour, 400 Han at 11px is thin. Han strokes are dense and blur at a size
|
||||
Latin still holds, so the Chinese interface starts at medium.
|
||||
|
||||
@@ -82,8 +82,8 @@
|
||||
}
|
||||
|
||||
:root[data-bg] .flow:not([data-empty]) {
|
||||
text-shadow: 0 1px 2px color-mix(in srgb, var(--page) 90%, transparent),
|
||||
0 0 7px color-mix(in srgb, var(--page) 72%, transparent);
|
||||
text-shadow: var(--flow-text-shadow, 0 1px 2px color-mix(in srgb, var(--page) 90%, transparent),
|
||||
0 0 7px color-mix(in srgb, var(--page) 72%, transparent));
|
||||
}
|
||||
:root[data-bg] .compose {
|
||||
background: color-mix(in srgb, var(--raised) 80%, transparent);
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
.pane[data-browser-size="wide"]:has(> .pbody[data-dock]) {
|
||||
grid-template-columns: minmax(0, min(34%, 380px)) minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.pane[data-browser-size="wide"] .studio-meterrail {
|
||||
position: static;
|
||||
flex-wrap: wrap;
|
||||
margin-top: 8px;
|
||||
padding-inline: 0;
|
||||
}
|
||||
|
||||
[data-fold~="side"] .app[data-browser="on"] .pane[data-browser-size="wide"]:has(> .pbody[data-dock]) {
|
||||
grid-template: "nav" auto "find" auto "side" minmax(0, 1.2fr) "body" minmax(0, 1fr) "compose" auto / minmax(0, 1fr);
|
||||
}
|
||||
|
||||
[data-fold~="side"] .app[data-browser="on"] .pane[data-browser-size="wide"]:has(> .pbody[data-dock]) > .pbody {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
[data-fold~="side"] .app[data-browser="on"] .pane[data-browser-size="wide"]:has(> .pbody[data-dock]) > .compose {
|
||||
display: block;
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
.compact-now .wsconfirm { display: grid; gap: 8px; padding: 8px 0; border: 0; background: transparent; }
|
||||
.compact-now .wsconfirm-t .q { overflow: visible; white-space: normal; }
|
||||
.compact-now .wsconfirm-a { justify-content: flex-end; flex-wrap: wrap; }
|
||||
.compact-now .wsconfirm-a button:disabled { opacity: .4; cursor: default; }
|
||||
@@ -0,0 +1 @@
|
||||
.comp-live { min-height: 1lh; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; }
|
||||
@@ -107,6 +107,7 @@
|
||||
.fbk-env dt { color: var(--muted); font-size: var(--fs-meta); }
|
||||
.fbk-env dd { margin: 0; overflow-wrap: anywhere; font: var(--w-reg) var(--fs-meta) var(--mono); color: var(--text); }
|
||||
.fbk-env .fbk-hint { margin: 0; }
|
||||
.fbk-logs { display: grid; justify-items: start; gap: 6px; margin-top: 10px; }
|
||||
|
||||
.fbk-note {
|
||||
display: flex; align-items: center; flex-wrap: wrap; gap: 8px 10px; padding: 8px 12px; border: 1px solid var(--border);
|
||||
@@ -196,6 +197,9 @@
|
||||
}
|
||||
|
||||
.fbk-thread { display: flex; flex-direction: column; align-items: flex-start; gap: 8px; }
|
||||
.fbk-thread-body:not([hidden]) { display: flex; flex-direction: column; align-items: flex-start; gap: 8px; width: 100%; }
|
||||
.fbk-thread-toggle { gap: 6px; flex-wrap: wrap; }
|
||||
.fbk-thread-toggle .studio-icon { width: 12px; height: 12px; }
|
||||
.fbk-thread ol { display: flex; flex-direction: column; gap: 8px; width: 100%; margin: 0; padding: 0; list-style: none; }
|
||||
.fbk-thread li {
|
||||
display: flex; flex-direction: column; gap: 3px; max-width: min(92%, 560px); padding: 8px 11px;
|
||||
@@ -208,7 +212,7 @@
|
||||
.fbk-msg-hd { display: flex; align-items: center; gap: 8px; color: var(--muted); font-size: var(--fs-meta); }
|
||||
.fbk-msg-hd b { color: var(--text); font-weight: var(--w-med); }
|
||||
.fbk-msg-hd time { margin-left: auto; }
|
||||
.fbk-new {
|
||||
.fbk-new, .fbk-thread-new {
|
||||
padding: 0 7px; border-radius: var(--r-pill); background: var(--accent); color: var(--accent-fg);
|
||||
font: var(--w-med) var(--fs-micro)/1.6 var(--ui);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const SHEETS = import.meta.glob(["./studio.css", "./tokens.css"], { query: "?raw", import: "default", eager: true }) as Record<string, string>;
|
||||
const SHEET = Object.values(SHEETS).join("\n");
|
||||
|
||||
const faces = (family: string) =>
|
||||
[...SHEET.matchAll(/@font-face\s*\{([^{}]*)\}/g)].map((m) => m[1]).filter((body) => body.includes(`font-family: "${family}"`));
|
||||
|
||||
describe("the CJK font stack", () => {
|
||||
it("serves a first screen from the core face and keeps the full font as a later family", () => {
|
||||
const [core] = faces("Noto Studio");
|
||||
expect(core, "a Noto Studio face").toBeTruthy();
|
||||
expect(core).toContain("noto-studio-core.woff2");
|
||||
expect(core).toMatch(/unicode-range\s*:/);
|
||||
const [full] = faces("Noto Studio Full");
|
||||
expect(full, "a Noto Studio Full face").toBeTruthy();
|
||||
expect(full).toContain("noto-studio.woff2");
|
||||
});
|
||||
|
||||
it("names the full font after the core one in every stack that uses either", () => {
|
||||
const stacks = [...SHEET.matchAll(/--(?:ui|mono)\s*:\s*([^;]*);/g)].map((m) => m[1]).filter((s) => s.includes("Noto Studio"));
|
||||
expect(stacks.length).toBeGreaterThan(0);
|
||||
for (const stack of stacks) {
|
||||
const core = stack.indexOf('"Noto Studio"');
|
||||
const full = stack.indexOf('"Noto Studio Full"');
|
||||
expect(core, stack).toBeGreaterThan(-1);
|
||||
expect(full, stack).toBeGreaterThan(core);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,6 @@
|
||||
.srv-held { margin: 6px 0 0 15px; padding: 8px 10px; border: 1px solid var(--border); border-radius: var(--r-md); background: var(--overlay); font: var(--w-reg) var(--fs-ui)/1.55 var(--ui); color: var(--text); display: grid; gap: 6px; justify-items: start }
|
||||
.srv-held ul { margin: 0; padding: 0; list-style: none; display: grid; gap: 2px; max-width: 100% }
|
||||
.srv-held li { display: flex; gap: 8px; align-items: baseline; min-width: 0 }
|
||||
.srv-held li b { font-weight: var(--w-med); overflow-wrap: anywhere }
|
||||
.srv-held li i { font-style: normal; color: var(--muted); white-space: nowrap }
|
||||
.srv-held .hn { color: var(--muted) }
|
||||
@@ -0,0 +1,6 @@
|
||||
[data-fold~="scene"] .compose:not(:focus-within):not(:has(.shots, .refine-card, [aria-expanded="true"])):has(.fmain > textarea:placeholder-shown) textarea { min-height: 1lh; }
|
||||
[data-fold~="scene"] .compose:not(:focus-within):not(:has(.shots, .refine-card, [aria-expanded="true"])):has(.fmain > textarea:placeholder-shown) .studio-refine { display: none; }
|
||||
[data-fold~="scene"] .compose:not(:focus-within):not(:has(.shots, .refine-card, [aria-expanded="true"])):has(.fmain > textarea:placeholder-shown) .studio-mode-picker { width: 30px; justify-content: center; padding-inline: 0; }
|
||||
[data-fold~="scene"] .compose:not(:focus-within):not(:has(.shots, .refine-card, [aria-expanded="true"])):has(.fmain > textarea:placeholder-shown) :is(.studio-mode-picker > span:not(.studio-sr-label), .studio-mode-picker > .studio-icon:last-child) { display: none; }
|
||||
[data-fold~="scene"] .compose:not(:focus-within):not(:has(.shots, .refine-card, [aria-expanded="true"])):has(.fmain > textarea:placeholder-shown) .turntools { flex-wrap: nowrap; }
|
||||
[data-fold~="scene"] .compose:not(:focus-within):not(:has(.shots, .refine-card, [aria-expanded="true"])):has(.fmain > textarea:placeholder-shown) .studio-model-group { flex-basis: 0; flex-wrap: nowrap; }
|
||||
@@ -0,0 +1,19 @@
|
||||
.tabs:has(.plan-peek-pop) { z-index: 25; }
|
||||
.plan-peek { margin-inline-start: auto; flex: none; }
|
||||
.plan-peek-trigger { display: flex; align-items: center; gap: 6px; }
|
||||
.plan-peek-trigger .studio-icon { width: 13px; height: 13px; }
|
||||
.plan-peek-trigger small { font: 500 10px/1 var(--mono); font-variant-numeric: tabular-nums; }
|
||||
.plan-peek-trigger[aria-expanded="true"] { background: var(--overlay); color: var(--text); }
|
||||
.plan-peek-pop {
|
||||
position: absolute; inset-inline-end: var(--pane-gutter); top: calc(100% + 6px);
|
||||
width: min(360px, calc(100% - 2 * var(--pane-gutter))); max-height: min(420px, calc(55vh / var(--zoom, 1)));
|
||||
overflow-y: auto; overscroll-behavior: contain; padding: 14px; border-radius: var(--r-lg);
|
||||
background: var(--surface); color: var(--text); box-shadow: var(--shadow-float);
|
||||
}
|
||||
.plan-peek-pop:focus-visible { outline: 2px solid var(--focus); }
|
||||
.plan-peek-head { display: flex; align-items: center; justify-content: space-between; gap: 12px; }
|
||||
.plan-peek-head b { font: 600 var(--fs-ui)/1.4 var(--ui); }
|
||||
.plan-peek-goal { margin: 12px 0; color: var(--muted); font: 450 var(--fs-ui)/1.6 var(--ui); overflow-wrap: anywhere; white-space: pre-wrap; }
|
||||
.plan-peek-pop .block[data-b="plan"] { margin: 12px 0 0; padding: 0; border: 0; background: none; }
|
||||
.plan-peek-pop .block[data-b="plan"] > .lbl { display: none; }
|
||||
.plan-peek-pop .s { animation: none; }
|
||||
@@ -0,0 +1,50 @@
|
||||
.pbody.rewind-notice-host { position: relative; }
|
||||
.rewind-notice {
|
||||
position: absolute;
|
||||
top: 12px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
z-index: 10;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 9px;
|
||||
flex: none;
|
||||
align-self: center;
|
||||
width: fit-content;
|
||||
max-width: min(420px, calc(100% - 24px));
|
||||
margin: 0;
|
||||
padding: 7px 36px 7px 10px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--r-lg);
|
||||
background: var(--float);
|
||||
color: var(--text);
|
||||
box-shadow: var(--shadow-float);
|
||||
font: var(--w-med) var(--fs-ui)/1.45 var(--ui);
|
||||
}
|
||||
.rewind-notice > .studio-icon { flex: none; color: var(--muted); }
|
||||
.rewind-notice-copy { display: flex; flex: 0 1 auto; flex-direction: column; gap: 4px; min-width: 0; }
|
||||
.rewind-notice-error { color: var(--err-ink); overflow-wrap: anywhere; }
|
||||
.rewind-notice > .btn { flex: none; padding: 5px 9px; white-space: nowrap; }
|
||||
@container conversation (max-width: 460px) {
|
||||
.rewind-notice { max-width: calc(100% - 20px); flex-wrap: wrap; }
|
||||
}
|
||||
.rewind-notice-close {
|
||||
position: absolute;
|
||||
top: 4px;
|
||||
right: 4px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
border-radius: var(--r-sm);
|
||||
background: transparent;
|
||||
color: var(--muted);
|
||||
cursor: pointer;
|
||||
}
|
||||
.rewind-notice-close:hover:not(:disabled) { color: var(--text); }
|
||||
.rewind-notice-close:focus-visible { outline: 2px solid var(--focus); outline-offset: 1px; }
|
||||
.rewind-notice-close:disabled { opacity: .45; cursor: default; }
|
||||
|
||||
.rewind-notice-count { color: var(--muted); }
|
||||
@@ -0,0 +1,9 @@
|
||||
import { expect, it } from "vitest";
|
||||
|
||||
const CSS = import.meta.glob("./studio.css", { query: "?raw", import: "default", eager: true }) as Record<string, string>;
|
||||
|
||||
it("keeps the activity visibility floor in fixed pixels rather than timeline percentages", () => {
|
||||
const rule = Object.values(CSS)[0].match(/\.run-round-track\s*>\s*i\s*\{([^}]+)\}/)?.[1];
|
||||
expect(rule).toBeDefined();
|
||||
expect(rule).toMatch(/min-width:\s*3px\s*;/);
|
||||
});
|
||||
@@ -0,0 +1,18 @@
|
||||
.session-batch-entry { display: flex; justify-content: flex-end; padding: 3px 18px; }
|
||||
.session-batch-entry button, .session-batch button { font: var(--w-med) var(--fs-micro) var(--ui); }
|
||||
.session-batch-entry button { padding: 4px 6px; border: 0; border-radius: 4px; color: var(--muted); background: transparent; cursor: pointer; }
|
||||
.session-batch-entry button:hover:not(:disabled) { color: var(--text); background: var(--overlay); }
|
||||
.session-batch { margin: 4px 12px 8px; padding: 8px; border: 1px solid var(--border); border-radius: 7px; background: var(--page); }
|
||||
.session-batch-head { display: flex; align-items: center; justify-content: space-between; gap: 8px; margin-bottom: 6px; font-size: var(--fs-micro); }
|
||||
.session-batch-head button { flex: none; color: var(--muted); background: transparent; padding: 4px; border: 0; border-radius: 4px; cursor: pointer; }
|
||||
.session-batch-actions { display: flex; gap: 4px; flex-wrap: wrap; }
|
||||
.session-batch-actions button { padding: 5px 7px; background: var(--raised); color: var(--text); border: 1px solid var(--border); border-radius: 4px; }
|
||||
.session-batch-actions button:hover:not(:disabled) { background: var(--overlay); }
|
||||
.session-batch [data-action="session.batch-delete"] { color: var(--err-ink); }
|
||||
.session-batch p { margin: 6px 0 0; color: var(--muted); font-size: var(--fs-micro); }
|
||||
.session-batch button:disabled, .session-batch-entry button:disabled { opacity: .45; cursor: default; }
|
||||
.session-batch button:focus-visible, .session-batch-entry button:focus-visible { outline: 2px solid var(--focus); outline-offset: 1px; }
|
||||
.session-batch .wsconfirm { margin: 8px 0 0; flex-wrap: wrap; gap: 8px; }
|
||||
.session-batch .wsconfirm-t { flex-basis: 100%; }
|
||||
.session-batch-names { font-size: var(--fs-micro); color: var(--text); overflow-wrap: anywhere; max-height: 100px; overflow-y: auto; }
|
||||
.rail .session-select { flex: none; width: 14px; height: 14px; margin: 0 6px 0 0; accent-color: var(--accent); }
|
||||
@@ -0,0 +1 @@
|
||||
.sesspin { width: 11px; height: 11px; flex: none; margin-inline-end: 4px; color: var(--muted); vertical-align: -1px }
|
||||
@@ -0,0 +1,27 @@
|
||||
.rail .sessquick {
|
||||
flex: none; display: flex; align-items: center; gap: 2px; width: 0; overflow: hidden;
|
||||
opacity: 0; pointer-events: none; transition: opacity 100ms ease;
|
||||
}
|
||||
.rail .sessrow:hover > .sessquick,
|
||||
.rail .sessrow:focus-within > .sessquick { width: 50px; margin-inline-end: 6px; opacity: 1; pointer-events: auto; }
|
||||
.rail .sessquick > button {
|
||||
flex: none; width: 24px; height: 28px; padding: 0; display: grid; place-items: center;
|
||||
border: 0; border-radius: 5px; background: transparent; color: var(--faint); cursor: pointer;
|
||||
}
|
||||
.rail .sessquick .studio-icon { width: 14px; height: 14px; }
|
||||
.rail .sessquick > button:hover { background: var(--raised); color: var(--text); }
|
||||
.rail .sessquick > .danger:hover { color: var(--err-ink); }
|
||||
.rail .sessquick > button:disabled { opacity: .35; cursor: default; background: transparent; color: var(--faint); }
|
||||
.rail .sessquick > button:focus-visible { outline: 1px solid var(--focus); outline-offset: -1px; }
|
||||
.rail .sessrow-context[data-unread]:hover > .sessquick,
|
||||
.rail .sessrow-context[data-unread]:focus-within > .sessquick { margin-inline-end: 26px; }
|
||||
.rail .sessrow-context[data-unread]:hover > .sesscopies,
|
||||
.rail .sessrow-context[data-unread]:focus-within > .sesscopies { margin-inline-end: 8px; }
|
||||
.rail .sessrow-context[data-unread]:hover > .sesstitle,
|
||||
.rail .sessrow-context[data-unread]:focus-within > .sesstitle { padding-inline-end: 9px; }
|
||||
@media (hover: none) {
|
||||
.rail .sessquick { opacity: 1; width: 50px; margin-inline-end: 6px; pointer-events: auto; }
|
||||
.rail .sessrow-context[data-unread] > .sessquick { margin-inline-end: 26px; }
|
||||
.rail .sessrow-context[data-unread] > .sesscopies { margin-inline-end: 8px; }
|
||||
.rail .sessrow-context[data-unread] > .sesstitle { padding-inline-end: 9px; }
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
.rail > .rail-drop {
|
||||
position: absolute;
|
||||
z-index: 1;
|
||||
inset: 8px;
|
||||
width: auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
padding: 16px;
|
||||
border: 1px dashed var(--accent);
|
||||
border-radius: 8px;
|
||||
background: var(--surface);
|
||||
color: var(--text);
|
||||
font: 500 12px/1.5 var(--ui);
|
||||
pointer-events: none;
|
||||
}
|
||||
.rail-drop svg { flex: none; width: 18px; height: 18px; }
|
||||
@@ -21,13 +21,6 @@
|
||||
font-weight: 600;
|
||||
font-display: swap;
|
||||
}
|
||||
@font-face {
|
||||
font-family: "Noto Studio";
|
||||
src: url("/fonts/noto-studio.woff2") format("woff2");
|
||||
font-style: normal;
|
||||
font-weight: 100 900;
|
||||
font-display: swap;
|
||||
}
|
||||
@font-face {
|
||||
font-family: "Studio Mono";
|
||||
src: url("/fonts/studio-mono-400.ttf") format("truetype");
|
||||
@@ -122,28 +115,6 @@
|
||||
--shadow-float: 0 0 0 1px rgb(0 0 0 / .42), 0 20px 56px -18px rgb(0 0 0 / .75);
|
||||
}
|
||||
|
||||
:root[data-theme="light"] {
|
||||
--page: #f6f6f4;
|
||||
--surface: #ffffff;
|
||||
--raised: #ffffff;
|
||||
--overlay: #efefec;
|
||||
--border: #deded9;
|
||||
--hair: #e9e9e5;
|
||||
--float: #ffffff;
|
||||
--float-hi: #f4f4f1;
|
||||
--accent: #282a28;
|
||||
--accent-fg: #ffffff;
|
||||
--accent-ink: #303330;
|
||||
--accent-wash: #e9eae6;
|
||||
--net: #566aa9;
|
||||
--net-wash: #eef1fa;
|
||||
--focus: #7388c8;
|
||||
--text: #292b29;
|
||||
--muted: #5f625d;
|
||||
--faint: #666a63;
|
||||
--ghost: #93968f;
|
||||
}
|
||||
|
||||
body { background: var(--page); }
|
||||
.app { --rail-open: 238px; background: var(--page); }
|
||||
.app[data-nav="on"] { --nav-w: var(--nav-open); }
|
||||
@@ -433,8 +404,8 @@ body { background: var(--page); }
|
||||
}
|
||||
|
||||
:root[data-theme="light"] {
|
||||
--page: #efefec;
|
||||
--surface: #f8f8f5;
|
||||
--page: #f0f0ed;
|
||||
--surface: #fafaf8;
|
||||
--raised: #ffffff;
|
||||
--overlay: #e9e9e5;
|
||||
--border: #d6d7d1;
|
||||
@@ -445,6 +416,8 @@ body { background: var(--page); }
|
||||
--accent-fg: #ffffff;
|
||||
--accent-ink: #252725;
|
||||
--accent-wash: #e4e5e0;
|
||||
--net: #566aa9;
|
||||
--net-wash: #eef1fa;
|
||||
--focus: #68736d;
|
||||
--text: #292b29;
|
||||
--muted: #5e625c;
|
||||
@@ -452,6 +425,16 @@ body { background: var(--page); }
|
||||
--ghost: #666a63;
|
||||
}
|
||||
|
||||
:root[data-theme="light"]:not([data-pack]):not([data-bg]) {
|
||||
--reading-surface: var(--surface);
|
||||
--compose-surface: var(--raised);
|
||||
--compose-shadow: 0 3px 12px rgb(25 30 25 / .07);
|
||||
}
|
||||
|
||||
:root[data-theme="light"]:not([data-pack]):not([data-weight]) {
|
||||
--w-read: 400;
|
||||
}
|
||||
|
||||
body { background: var(--page) !important; }
|
||||
:root[data-bg] .app,
|
||||
:root[data-sky] .app { background: transparent !important; }
|
||||
@@ -492,11 +475,7 @@ body { background: var(--page) !important; }
|
||||
}
|
||||
.main { background: var(--surface) !important; }
|
||||
.panes { background: var(--surface) !important; }
|
||||
/* The prototype hid the pane navigation entirely, which also made trajectory,
|
||||
task progress and the run graph unreachable. Keep the legacy pane strip
|
||||
hidden, but expose the current purpose-based navigation. */
|
||||
.tabs { display: flex !important; }
|
||||
.panetabs { display: none !important; }
|
||||
.railhost { display: block; }
|
||||
.app { --srail-w: 36px; }
|
||||
|
||||
@@ -807,7 +786,7 @@ body { background: var(--page) !important; }
|
||||
}
|
||||
.flow .call[data-k="say"] > .c > .out { margin-top: 0; }
|
||||
.flow .call[data-k="say"] > .c > .out > .txt {
|
||||
max-width: none; color: var(--text); font-family: var(--ui); font-size: var(--read); font-weight: var(--w-reg); line-height: 1.72;
|
||||
max-width: none; color: var(--text); font-family: var(--ui); font-size: var(--read); font-weight: var(--w-read, var(--w-reg)); line-height: 1.72;
|
||||
letter-spacing: 0; -webkit-font-smoothing: antialiased;
|
||||
}
|
||||
.flow .call[data-k="say"] .acts {
|
||||
@@ -1393,7 +1372,7 @@ body { background: var(--page) !important; }
|
||||
.workbench-tabs svg { width: 13px; height: 13px; flex: none; }
|
||||
.workbench-body { flex: 1; min-height: 0; min-width: 0; display: grid; grid-template-columns: minmax(0, 1fr) clamp(190px, 25%, 300px); grid-template-rows: minmax(0, 1fr); }
|
||||
.workbench-canvas { min-width: 0; min-height: 0; display: flex; flex-direction: column; }
|
||||
.workbench-canvas > .studio-browser, .workbench-canvas > .bpanel { width: 100%; height: 100%; min-width: 0; }
|
||||
.workbench-content > .studio-browser, .workbench-content > .bpanel { width: 100%; height: 100%; min-width: 0; }
|
||||
.workbench-canvas .bbar > .vtabs { display: none; }
|
||||
.workbench-explorer { min-width: 0; display: flex; flex-direction: column; border-left: 1px solid var(--hair); background: var(--page); }
|
||||
.workbench-explorer-head { height: 36px; padding: 0 12px; flex: none; display: flex; align-items: center; justify-content: space-between; border-bottom: 1px solid var(--hair); color: var(--muted); font: 620 10px/1 var(--ui); text-transform: uppercase; }
|
||||
@@ -1461,6 +1440,7 @@ body { background: var(--page) !important; }
|
||||
.workbench-read > .md { max-width: 820px; margin: 0 auto; font-size: var(--read, 14px); line-height: 1.75; }
|
||||
.workbench-empty { flex: 1; display: flex; place-content: center; align-items: center; gap: 8px; color: var(--faint); font: 500 11px/1 var(--ui); }
|
||||
.workbench-empty svg { width: 18px; height: 18px; }
|
||||
.workbench-error.workbench-close-error { flex: none; font-size: 12px; white-space: pre-line; overflow-wrap: anywhere; }
|
||||
.workbench-error { padding: 8px 12px; border-bottom: 1px solid var(--err); color: var(--err-ink); font: 500 10px/1.4 var(--ui); }
|
||||
@media (max-width: 760px) {
|
||||
.workbench-body { grid-template-columns: minmax(0, 1fr); }
|
||||
@@ -1521,7 +1501,7 @@ body { background: var(--page) !important; }
|
||||
:root[data-theme="light"] .main,
|
||||
:root[data-theme="light"] .panes,
|
||||
:root[data-theme="light"] .pane,
|
||||
:root[data-theme="light"] .pane > .scroll { background: var(--page) !important; }
|
||||
:root[data-theme="light"] .pane > .scroll { background: var(--reading-surface, var(--page)) !important; }
|
||||
:root[data-theme="light"] .flow .call[data-k="say"] > .g .sym { background: var(--accent); }
|
||||
:root[data-theme="light"] .flow .call[data-k="say"] > .g .sym::after { color: var(--accent-fg); }
|
||||
:root[data-theme="light"] .flow .call[data-k="say"] > .c > .hl .nm { color: var(--muted); }
|
||||
@@ -1587,7 +1567,10 @@ body { background: var(--page) !important; }
|
||||
:root[data-theme="light"] .flow .call[data-k="me"]:hover {
|
||||
background: transparent; border-color: transparent; box-shadow: none;
|
||||
}
|
||||
:root[data-theme="light"] .compose { background: var(--surface) !important; box-shadow: 0 12px 40px -30px #555a53; }
|
||||
:root[data-theme="light"] .compose {
|
||||
background: var(--compose-surface, var(--surface)) !important;
|
||||
box-shadow: var(--compose-shadow, 0 12px 40px -30px #555a53);
|
||||
}
|
||||
:root[data-theme="light"] .compose::after { border-color: var(--border); }
|
||||
:root[data-theme="light"] .compose:hover::after,
|
||||
:root[data-theme="light"] .compose:focus-within::after { border-color: #9da198; }
|
||||
@@ -1870,8 +1853,8 @@ body { background: var(--page) !important; }
|
||||
No !important: this sheet already loads after the tokens it is replacing, and
|
||||
marking one would outrank the reader's own font and size as well. */
|
||||
:root {
|
||||
--ui: "Inter Studio", "Noto Studio", "PingFang SC", "Microsoft YaHei UI", sans-serif;
|
||||
--mono: "Studio Mono", "Noto Studio", Consolas, monospace;
|
||||
--ui: "Inter Studio", "Noto Studio", "Noto Studio Full", "PingFang SC", "Microsoft YaHei UI", sans-serif;
|
||||
--mono: "Studio Mono", "Noto Studio", "Noto Studio Full", Consolas, monospace;
|
||||
--fs-lead: 14px;
|
||||
}
|
||||
body,
|
||||
@@ -2335,7 +2318,7 @@ body { font-size: 14px; line-height: 1.75; }
|
||||
min-width: 29px !important;
|
||||
height: 29px !important;
|
||||
padding: 0 !important;
|
||||
display: grid !important;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border: 1px solid transparent !important;
|
||||
border-radius: 999px !important;
|
||||
@@ -3294,11 +3277,9 @@ body { font-size: 14px; line-height: 1.75; }
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
/* View switching belongs to app chrome. Keeping this row on a stable surface
|
||||
prevents wallpapers from changing the apparent contrast of inactive tabs. */
|
||||
.pane > .tabs {
|
||||
background: color-mix(in srgb, var(--surface) 94%, transparent);
|
||||
border-bottom-color: var(--border);
|
||||
background: transparent;
|
||||
border-bottom-color: transparent;
|
||||
}
|
||||
.pane > .tabs .tab { color: var(--muted); }
|
||||
.pane > .tabs .tab:hover { color: var(--text); background: var(--overlay); }
|
||||
@@ -3469,6 +3450,7 @@ body { font-size: 14px; line-height: 1.75; }
|
||||
events and the execution graph stay in Runtime details; this surface answers
|
||||
the common question first: where did the turn spend its time? */
|
||||
.run-analysis {
|
||||
--ra-columns: 34px minmax(180px, 1fr) 180px 116px;
|
||||
--ra-model: color-mix(in srgb, var(--accent) 82%, var(--text));
|
||||
--ra-tool: color-mix(in srgb, var(--net) 78%, var(--text));
|
||||
--ra-recovery: color-mix(in srgb, var(--err) 76%, var(--text));
|
||||
@@ -3551,7 +3533,14 @@ body { font-size: 14px; line-height: 1.75; }
|
||||
.run-legend { display: flex; flex-wrap: wrap; gap: 12px; margin-top: 7px; }
|
||||
.run-legend span { display: inline-flex; align-items: center; gap: 6px; color: var(--muted); font: var(--w-reg) 10.5px var(--ui); }
|
||||
.run-legend i { width: 8px; height: 8px; border-radius: 2px; background: var(--ra-color); }
|
||||
.run-signal-line { position: relative; height: 34px; border-block: 1px dashed var(--border); background: color-mix(in srgb, var(--surface) 56%, transparent); }
|
||||
.run-timeline-controls { display: flex; align-items: center; flex-wrap: wrap; gap: 10px; margin-top: 14px; font: var(--w-reg) 11px var(--ui); }
|
||||
.run-timeline-controls label { display: flex; align-items: center; gap: 10px; }
|
||||
.run-timeline-controls input { width: 130px; accent-color: var(--accent); }
|
||||
.run-timeline-scroll { overflow-x: auto; container-type: inline-size; }
|
||||
.run-timeline-scroll:focus-visible { outline: 2px solid var(--focus); outline-offset: 2px; }
|
||||
.run-timeline-canvas .run-inspect { position: sticky; left: 16px; max-width: calc(100cqw - 76px); }
|
||||
.run-signal-track { display: grid; grid-template-columns: var(--ra-columns); gap: 12px; padding: 5px 10px; }
|
||||
.run-signal-line { grid-column: 2; position: relative; height: 34px; border-block: 1px dashed var(--border); background: color-mix(in srgb, var(--surface) 56%, transparent); }
|
||||
.run-signal-line button { position: absolute; top: 50%; width: 20px; height: 30px; translate: -50% -50%; border: 0; background: transparent; cursor: pointer; }
|
||||
.run-signal-line button::after { content: ""; position: absolute; left: 50%; top: 3px; bottom: 3px; width: 1px; background: var(--border); }
|
||||
.run-signal-line button i { position: absolute; z-index: 1; left: 50%; top: 50%; width: 9px; height: 9px; translate: -50% -50%; rotate: 45deg; border: 2px solid var(--raised); background: var(--ra-color); box-shadow: 0 0 0 1px color-mix(in srgb, var(--ra-color) 62%, var(--border)); }
|
||||
@@ -3568,11 +3557,11 @@ body { font-size: 14px; line-height: 1.75; }
|
||||
.run-rounds li[data-on] .run-round-id,
|
||||
.run-rounds li[data-on] .run-round-copy b { color: var(--text); }
|
||||
.run-rounds button {
|
||||
width: 100%; min-height: 38px; display: grid; grid-template-columns: 34px minmax(180px, 1fr) minmax(150px, .72fr) 116px;
|
||||
width: 100%; min-height: 38px; display: grid; grid-template-columns: var(--ra-columns);
|
||||
align-items: center; gap: 12px; padding: 5px 10px; border: 0; color: inherit; background: transparent; text-align: left; cursor: pointer;
|
||||
}
|
||||
.run-round-id { color: var(--muted); font: var(--w-reg) 10.5px var(--mono); }
|
||||
.run-round-track { position: relative; height: 14px; overflow: hidden; border-radius: 3px; border-left: 1px solid var(--border); background-color: var(--surface); background-image: linear-gradient(90deg, var(--hair) 1px, transparent 1px); background-size: 25% 100%; }
|
||||
.run-round-track { position: relative; height: 14px; overflow: hidden; border-radius: 3px; background-color: var(--surface); background-image: linear-gradient(90deg, var(--hair) 1px, transparent 1px); background-size: 25% 100%; }
|
||||
.run-round-track > i { position: absolute; top: 3px; height: 8px; min-width: 3px; border-radius: 2px; background: var(--ra-color, var(--ra-tool)); }
|
||||
.run-round-track > i[data-c="round"] { --ra-color: var(--ra-model); }
|
||||
.run-round-track > i[data-c="read"] { --ra-color: var(--ghost); }
|
||||
@@ -3597,7 +3586,7 @@ body { font-size: 14px; line-height: 1.75; }
|
||||
.run-kpis { grid-template-columns: repeat(3, 1fr); }
|
||||
.run-kpis > div:nth-child(3) { border-right: 0; }
|
||||
.run-kpis > div:nth-child(-n+3) { border-bottom: 1px solid var(--hair); }
|
||||
.run-rounds button { grid-template-columns: 30px minmax(120px, 1fr) minmax(120px, .8fr); }
|
||||
.run-analysis { --ra-columns: 30px minmax(120px, 1fr) 140px; }
|
||||
.run-round-kind { display: none; }
|
||||
}
|
||||
@media (max-width: 580px) {
|
||||
@@ -3606,7 +3595,7 @@ body { font-size: 14px; line-height: 1.75; }
|
||||
.run-kpis > div:nth-child(2n) { border-right: 0; }
|
||||
.run-kpis > div:nth-child(3) { border-right: 1px solid var(--hair); }
|
||||
.run-kpis > div:nth-child(-n+4) { border-bottom: 1px solid var(--hair); }
|
||||
.run-rounds button { grid-template-columns: 26px minmax(100px, 1fr); }
|
||||
.run-analysis { --ra-columns: 26px minmax(100px, 1fr); }
|
||||
.run-round-copy { display: none; }
|
||||
}
|
||||
:root[data-theme="light"] .grp,
|
||||
@@ -3689,6 +3678,7 @@ body { font-size: 14px; line-height: 1.75; }
|
||||
.addp .fields select,
|
||||
.addp .fields textarea,
|
||||
.mline .nm { font-size: var(--fs-ui); }
|
||||
.mline .malias { min-width: 0; width: 100%; box-sizing: border-box; font-size: var(--fs-ui); }
|
||||
.prefs-main { padding-top: 28px; }
|
||||
.prefs-col { max-width: 820px; }
|
||||
.grp { padding: 20px 22px 18px; }
|
||||
@@ -4234,3 +4224,6 @@ body { font-size: 14px; line-height: 1.75; }
|
||||
.nest-bd :is(.term, .term-l) { white-space: pre-wrap; overflow-wrap: anywhere; }
|
||||
.agent-tx .call { content-visibility: auto; contain-intrinsic-size: auto 80px; }
|
||||
.agent-tx:focus { outline: none; }
|
||||
|
||||
.workbench-content { flex: 1; min-height: 0; display: flex; flex-direction: column; }
|
||||
.workbench-content[hidden] { display: none; }
|
||||
@@ -15,6 +15,22 @@
|
||||
unicode-range: U+2E80-9FFF, U+3000-303F, U+FF00-FFEF, U+2010-2027, U+00B7;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: "Noto Studio";
|
||||
src: url("/fonts/noto-studio-core.woff2") format("woff2");
|
||||
font-style: normal;
|
||||
font-weight: 100 900;
|
||||
font-display: swap;
|
||||
unicode-range: U+20-24F, U+2000-206F, U+2190-23FF, U+2500-257F, U+25A0-25FF, U+2600-27BF, U+2E80-9FFF, U+3000-303F, U+FF00-FFEF;
|
||||
}
|
||||
@font-face {
|
||||
font-family: "Noto Studio Full";
|
||||
src: url("/fonts/noto-studio.woff2") format("woff2");
|
||||
font-style: normal;
|
||||
font-weight: 100 900;
|
||||
font-display: swap;
|
||||
unicode-range: U+2E80-9FFF, U+F900-FAFF, U+FE30-FE4F, U+3000-303F, U+FF00-FFEF, U+20000-2FFFF;
|
||||
}
|
||||
@font-face {
|
||||
font-family: "IBM Plex Mono var";
|
||||
src: url("/fonts/IBMPlexMonoVariable.woff2") format("woff2");
|
||||
|
||||
@@ -0,0 +1,220 @@
|
||||
// @vitest-environment jsdom
|
||||
import { StrictMode } from "react";
|
||||
import { afterEach, expect, it, vi } from "vitest";
|
||||
import { act, cleanup, render, screen, waitFor, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import "./testkit";
|
||||
import { SsePort } from "../port/sse";
|
||||
import type { ProviderModelCheck, ProviderModelCheckRequest } from "../port/port";
|
||||
import { AddProvider } from "./AddProvider";
|
||||
|
||||
afterEach(() => { cleanup(); vi.unstubAllGlobals(); });
|
||||
|
||||
const names = ["m1", "m2", "m3", "m4", "m5"];
|
||||
const all = () => screen.getByRole("button", { name: /^测试已启用模型/ }) as HTMLButtonElement;
|
||||
const summary = () => document.querySelector(".msummary")?.textContent;
|
||||
const row = (model: string) => screen.getByText(model).closest(".mline") as HTMLElement;
|
||||
|
||||
async function draft({ strict = false, noProxy = false, probe = true } = {}) {
|
||||
const pending: Array<{ model: string; settle: (result: ProviderModelCheck | Error) => void }> = [];
|
||||
const calls: ProviderModelCheckRequest[] = [];
|
||||
let peak = 0;
|
||||
let live = 0;
|
||||
const fetchMock = vi.fn(async (url: string, init?: RequestInit): Promise<Response> => {
|
||||
if (url.endsWith("/providers/protocols")) return Response.json([
|
||||
{ kind: "openai", discovery: "openai", reasoningParams: true },
|
||||
{ kind: "anthropic", discovery: "anthropic", reasoningParams: true },
|
||||
]);
|
||||
if (url.endsWith("/providers/probe")) return Response.json({
|
||||
kind: "openai", models: names, default: names[0], efforts: [], effort: "",
|
||||
vision: ["m1"], authHeader: true, noProxy,
|
||||
});
|
||||
if (url.endsWith("/providers/check/model")) {
|
||||
const req = JSON.parse(init!.body as string) as ProviderModelCheckRequest;
|
||||
calls.push(req);
|
||||
peak = Math.max(peak, ++live);
|
||||
return new Promise((resolve, reject) => pending.push({ model: req.model, settle: (result) => {
|
||||
live--;
|
||||
if (result instanceof Error) reject(result); else resolve(Response.json(result));
|
||||
} }));
|
||||
}
|
||||
if (url.endsWith("/providers") && init?.method === "POST") return new Response(null, { status: 204 });
|
||||
throw new Error(`Unexpected request: ${url}`);
|
||||
});
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
const onDone = vi.fn();
|
||||
const onCancel = vi.fn();
|
||||
const port = new SsePort("/batch-kernel");
|
||||
const form = (p = port) => <AddProvider port={p} taken={[]} known={[]} onDone={onDone} onCancel={onCancel} />;
|
||||
const view = render(strict ? <StrictMode>{form()}</StrictMode> : form());
|
||||
const user = userEvent.setup();
|
||||
await screen.findByRole("option", { name: "Anthropic 兼容" });
|
||||
await user.type(screen.getByLabelText("来源名称"), "relay-draft");
|
||||
await user.type(screen.getByLabelText("接口地址"), " https://relay.example/v1 ");
|
||||
await user.type(screen.getByLabelText("API Key"), " draft-key ");
|
||||
if (probe) {
|
||||
await user.click(screen.getByRole("button", { name: "验证连接并读取" }));
|
||||
await screen.findByText("连接可用 · 找到 5 个模型");
|
||||
}
|
||||
const finish = async (model: string, result: ProviderModelCheck | Error = { model, status: "available" }) => {
|
||||
const index = pending.findIndex((p) => p.model === model);
|
||||
expect(index).toBeGreaterThanOrEqual(0);
|
||||
const [request] = pending.splice(index, 1);
|
||||
await act(async () => request.settle(result));
|
||||
};
|
||||
const add = async (model: string) => {
|
||||
const search = screen.getByRole("searchbox", { name: "搜索或添加模型" });
|
||||
await user.clear(search);
|
||||
await user.type(search, `${model}{enter}`);
|
||||
await user.clear(search);
|
||||
};
|
||||
const saveCalls = () => fetchMock.mock.calls.filter(([url, init]) => url.endsWith("/providers") && init?.method === "POST");
|
||||
return { user, calls, pending, finish, add, peak: () => peak, saveCalls, onDone, onCancel, view, form };
|
||||
}
|
||||
|
||||
for (const strict of [false, true]) it(`sends at most two requests for enabled discovered and manual models (StrictMode=${strict})`, async () => {
|
||||
const g = await draft({ strict });
|
||||
await g.user.click(screen.getByRole("checkbox", { name: "选用 m5" }));
|
||||
await g.add("manual/Exact-ID");
|
||||
await g.user.selectOptions(screen.getByLabelText("接口协议"), "anthropic");
|
||||
const liveRegion = document.querySelector(".msummary[role=status]");
|
||||
await g.user.click(all());
|
||||
expect(all().getAttribute("aria-busy")).toBe("true");
|
||||
expect(summary()).toBe("2 个验证中 · 3 个未验证");
|
||||
expect(g.calls.map((r) => r.model)).toEqual(["m1", "m2"]);
|
||||
await g.user.click(all());
|
||||
expect(g.calls).toHaveLength(2);
|
||||
for (const model of ["m1", "m2", "m3", "m4", "manual/Exact-ID"]) await g.finish(model);
|
||||
await waitFor(() => expect(all().disabled).toBe(false));
|
||||
expect(g.calls.map((r) => r.model)).toEqual(["m1", "m2", "m3", "m4", "manual/Exact-ID"]);
|
||||
expect(g.peak()).toBe(2);
|
||||
for (const req of g.calls) expect(req).toMatchObject({ baseUrl: "https://relay.example/v1", apiKey: "draft-key", kind: "anthropic", authHeader: true, noProxy: false });
|
||||
expect(summary()).toBe("5 个可用");
|
||||
expect(document.querySelector(".msummary[role=status]")).toBe(liveRegion);
|
||||
expect(within(row("manual/Exact-ID")).getByText("用户添加")).toBeTruthy();
|
||||
expect(g.saveCalls()).toHaveLength(0);
|
||||
await g.user.click(screen.getByRole("button", { name: "添加来源" }));
|
||||
await waitFor(() => expect(g.onDone).toHaveBeenCalledOnce());
|
||||
expect(JSON.parse(g.saveCalls()[0][1]!.body as string)).toMatchObject({
|
||||
name: "relay-draft", kind: "anthropic", models: ["m1", "m2", "m3", "m4", "manual/Exact-ID"], default: "m1", vision: ["m1"],
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps per-model HTTP evidence and continues after partial failures without changing selection", async () => {
|
||||
const g = await draft();
|
||||
await g.user.click(all());
|
||||
await g.finish("m1");
|
||||
await g.finish("m2", { model: "m2", status: "unavailable", reason: "not_found", httpStatus: 404, detail: "no such model" });
|
||||
await g.finish("m3", new Error("connection closed"));
|
||||
await g.finish("m4", { model: "m4", status: "unknown", reason: "rejected", httpStatus: 400, detail: "tools unsupported" });
|
||||
await g.finish("m5");
|
||||
expect(summary()).toBe("2 个可用 · 1 个不可用 · 2 个无法确定");
|
||||
expect(row("m2").textContent).toContain("HTTP 404");
|
||||
expect(row("m2").textContent).toContain("no such model");
|
||||
expect(row("m4").textContent).toContain("HTTP 400");
|
||||
expect(row("m4").textContent).toContain("tools unsupported");
|
||||
for (const model of names) expect(within(row(model)).getByRole("checkbox").getAttribute("aria-checked")).toBe("true");
|
||||
expect(document.querySelectorAll(".mevidence[aria-live]")).toHaveLength(0);
|
||||
expect(document.querySelectorAll(".msummary[role=status]")).toHaveLength(1);
|
||||
await g.user.click(all());
|
||||
await g.finish("m2", new Error("connection closed"));
|
||||
expect(row("m2").querySelector(".mhttp")).toBeNull();
|
||||
expect(row("m2").querySelector(".mdetail")).toBeNull();
|
||||
expect(row("m2").querySelector(".mevidence i")?.getAttribute("data-state")).toBe("unknown");
|
||||
});
|
||||
|
||||
it.each([false, true])("uses the displayed proxy route for the whole batch (probe bypass=%s)", async (noProxy) => {
|
||||
const g = await draft({ noProxy });
|
||||
await g.user.click(screen.getByText("高级连接选项"));
|
||||
if (!noProxy) await g.user.click(screen.getByRole("switch", { name: "绕过系统代理" }));
|
||||
await g.user.click(all());
|
||||
for (const model of names) await g.finish(model);
|
||||
expect(g.calls.every((r) => r.noProxy && r.authHeader)).toBe(true);
|
||||
});
|
||||
|
||||
it("holds conflicting actions during a batch and restores them when it settles", async () => {
|
||||
const g = await draft();
|
||||
await g.user.click(all());
|
||||
const controls = [all(), screen.getByRole("button", { name: "添加来源" }), screen.getByRole("button", { name: "验证连接并读取" }), screen.getByRole("button", { name: "验证模型 m3" }), ...screen.getAllByRole("button", { name: "取消" })];
|
||||
for (const control of controls) {
|
||||
expect((control as HTMLButtonElement).disabled).toBe(true);
|
||||
await g.user.click(control);
|
||||
}
|
||||
for (const label of ["来源名称", "接口地址", "API Key", "接口协议"]) expect((screen.getByLabelText(label) as HTMLInputElement).disabled).toBe(true);
|
||||
expect(g.calls).toHaveLength(2);
|
||||
expect(g.saveCalls()).toHaveLength(0);
|
||||
expect(g.onCancel).not.toHaveBeenCalled();
|
||||
for (const model of names) await g.finish(model);
|
||||
for (const control of controls) expect((control as HTMLButtonElement).disabled).toBe(false);
|
||||
});
|
||||
|
||||
it.each(["limit", "selection", "manual model", "extra body"])("stops queued checks and discards old answers after changing %s", async (change) => {
|
||||
const g = await draft();
|
||||
await g.user.click(all());
|
||||
if (change === "limit") await g.user.type(screen.getByLabelText("上下文窗口"), "1");
|
||||
if (change === "selection") await g.user.click(screen.getByRole("checkbox", { name: "选用 m5" }));
|
||||
if (change === "manual model") await g.add("new/model");
|
||||
if (change === "extra body") {
|
||||
await g.user.click(screen.getByText("高级连接选项"));
|
||||
await g.user.type(screen.getByRole("textbox", { name: /^额外请求体/ }), " ");
|
||||
}
|
||||
expect(screen.getByText("已停止启动新的验证:草稿已改动")).toBeTruthy();
|
||||
expect(document.querySelectorAll('.mevidence i[data-state="checking"]')).toHaveLength(0);
|
||||
await g.finish("m1");
|
||||
await g.finish("m2");
|
||||
expect(g.calls).toHaveLength(2);
|
||||
expect(document.querySelectorAll('.mevidence i[data-state="available"]')).toHaveLength(0);
|
||||
expect(all().disabled).toBe(false);
|
||||
await g.user.click(all());
|
||||
expect(screen.queryByText("已停止启动新的验证:草稿已改动")).toBeNull();
|
||||
expect(g.calls).toHaveLength(4);
|
||||
});
|
||||
|
||||
it("a stopped batch cannot overwrite a newer run, and a completed receipt survives cancellation", async () => {
|
||||
const g = await draft();
|
||||
await g.user.click(all());
|
||||
await g.finish("m1");
|
||||
await g.user.type(screen.getByLabelText("上下文窗口"), "1");
|
||||
expect(summary()).toBe("1 个可用 · 4 个未验证");
|
||||
await g.user.click(all());
|
||||
await g.finish("m2", { model: "m2", status: "unavailable", reason: "not_found" });
|
||||
expect(all().getAttribute("aria-busy")).toBe("true");
|
||||
expect(row("m2").querySelector(".mevidence i")?.getAttribute("data-state")).toBe("checking");
|
||||
await g.finish("m3");
|
||||
expect(g.calls.map((r) => r.model)).toEqual(["m1", "m2", "m3", "m1", "m2"]);
|
||||
await g.finish("m1");
|
||||
await g.finish("m2");
|
||||
for (const model of ["m3", "m4", "m5"]) await g.finish(model);
|
||||
expect(summary()).toBe("5 个可用");
|
||||
});
|
||||
|
||||
it.each(["unmount", "port change"])("stops queued checks on %s", async (change) => {
|
||||
const g = await draft();
|
||||
await g.user.click(all());
|
||||
if (change === "unmount") g.view.unmount(); else g.view.rerender(g.form(new SsePort("/other-kernel")));
|
||||
await g.finish("m1");
|
||||
await g.finish("m2");
|
||||
expect(g.calls).toHaveLength(2);
|
||||
if (change === "port change") {
|
||||
expect(all().disabled).toBe(false);
|
||||
expect(document.querySelectorAll('.mevidence i[data-state="checking"]')).toHaveLength(0);
|
||||
}
|
||||
});
|
||||
|
||||
it("requires an address and an enabled model, and excludes a concurrent single check", async () => {
|
||||
const g = await draft({ probe: false });
|
||||
expect(all().disabled).toBe(true);
|
||||
expect(screen.getByText(/每个已勾选的模型各发送一次小请求/)).toBeTruthy();
|
||||
await g.add("manual/model");
|
||||
await g.user.clear(screen.getByLabelText("接口地址"));
|
||||
expect(all().disabled).toBe(true);
|
||||
await g.user.type(screen.getByLabelText("接口地址"), "https://relay.example/v1");
|
||||
await g.user.click(screen.getByRole("button", { name: "验证模型 manual/model" }));
|
||||
expect(all().disabled).toBe(true);
|
||||
await g.user.click(all());
|
||||
expect(g.calls).toHaveLength(1);
|
||||
await g.finish("manual/model");
|
||||
expect(all().disabled).toBe(false);
|
||||
await g.user.click(screen.getByRole("checkbox", { name: "选用 manual/model" }));
|
||||
expect(all().disabled).toBe(true);
|
||||
});
|
||||
@@ -10,6 +10,7 @@ import { reason } from "../i18n/kernel";
|
||||
import { HttpError } from "../port/http_error";
|
||||
import { EffortShape } from "./EffortShape";
|
||||
import { THINKING, parseExtraBody, parseHeaders } from "./provider_compat";
|
||||
import { useModelBatch } from "./useModelBatch";
|
||||
|
||||
// Detection is assistance, not authority. Compatible relays often expose one
|
||||
// model-list shape while expecting another request protocol, so every durable
|
||||
@@ -146,7 +147,7 @@ export function AddProvider({
|
||||
};
|
||||
|
||||
const checkModel = async (model: string) => {
|
||||
if (checkingModel || busy) return;
|
||||
if (testing || busy) return;
|
||||
setCheckingModel(model);
|
||||
setFacts((current) => ({
|
||||
...current,
|
||||
@@ -179,6 +180,16 @@ export function AddProvider({
|
||||
}
|
||||
};
|
||||
|
||||
const draftKey = JSON.stringify([name, baseUrl, apiKey, kind, picked, win, maxOut, thinkingOn, thinkingProtocol, heads, extra, noProxy, probe?.authHeader, probe?.noProxy]);
|
||||
const { batch, testAll } = useModelBatch({
|
||||
port, picked, draftKey, setFacts, origin: "manual",
|
||||
blocked: checkingModel !== "" || busy || baseUrl.trim() === "",
|
||||
request: {
|
||||
baseUrl: baseUrl.trim(), apiKey: apiKey.trim(), kind,
|
||||
authHeader: probe?.authHeader ?? false, noProxy: noProxy || (probe?.noProxy ?? false),
|
||||
},
|
||||
});
|
||||
const testing = checkingModel !== "" || batch === "running";
|
||||
if (preset) return <PresetInstall port={port} preset={preset} onDone={onDone} onCancel={onCancel} onBack={() => setPreset(null)} />;
|
||||
|
||||
return (
|
||||
@@ -189,7 +200,7 @@ export function AddProvider({
|
||||
<strong>{t("添加模型来源")}</strong>
|
||||
<p>{t("按服务文档填写。连接检测只帮助读取模型,不会替你决定协议。")}</p>
|
||||
</div>
|
||||
<button className="addp-close" onClick={onCancel} disabled={busy || checkingModel !== ""} aria-label={t("取消")}>×</button>
|
||||
<button className="addp-close" onClick={onCancel} disabled={busy || testing} aria-label={t("取消")}>×</button>
|
||||
</div>
|
||||
{baseUrl.trim() === "" && <PresetGallery load={loadPresets} onPick={setPreset} disabled={busy || checkingModel !== ""} />}
|
||||
<div className="fields">
|
||||
@@ -202,7 +213,7 @@ export function AddProvider({
|
||||
value={name}
|
||||
placeholder={t("例如:公司中转站")}
|
||||
onChange={(e) => { setName(e.target.value); setNameErr(""); }}
|
||||
disabled={busy || checkingModel !== ""}
|
||||
disabled={busy || testing}
|
||||
spellCheck={false}
|
||||
aria-invalid={nameBad || nameErr !== "" || undefined}
|
||||
aria-describedby="addp-name-rule"
|
||||
@@ -219,7 +230,7 @@ export function AddProvider({
|
||||
<select aria-label={t("接口协议")} data-action="provider.draft" data-value="protocol" value={kind} onChange={(e) => {
|
||||
setKind(e.target.value);
|
||||
setFacts(clearModelCheckFacts);
|
||||
}} disabled={busy || checkingModel !== ""}>
|
||||
}} disabled={busy || testing}>
|
||||
{choices.map((k) => <option key={k} value={k}>{t(KIND_LABEL[k] ?? k)}</option>)}
|
||||
</select>
|
||||
<i className="tip">{t("以服务商文档为准;模型列表无法可靠判断聊天协议。")}</i>
|
||||
@@ -239,7 +250,7 @@ export function AddProvider({
|
||||
setBaseUrl(e.target.value);
|
||||
setFacts(clearModelCheckFacts);
|
||||
}}
|
||||
disabled={busy || checkingModel !== ""}
|
||||
disabled={busy || testing}
|
||||
spellCheck={false}
|
||||
/>
|
||||
{wire?.answers === "decision" && <i className="tip">{t("填写服务的基地址,不要带 /v1/systemone;Reasonix 会自己在后面追加。本地的 Ollama 之类地址不适用这个协议。")}</i>}
|
||||
@@ -250,7 +261,7 @@ export function AddProvider({
|
||||
onChange={(e) => {
|
||||
setApiKey(e.target.value);
|
||||
setFacts(clearModelCheckFacts);
|
||||
}} disabled={busy || checkingModel !== ""} spellCheck={false} />
|
||||
}} disabled={busy || testing} spellCheck={false} />
|
||||
</label>
|
||||
<p className="addp-privacy">{t("API Key 仅保存在运行内核的这台机器上。")}</p>
|
||||
{sibling && (
|
||||
@@ -300,7 +311,7 @@ export function AddProvider({
|
||||
role="switch"
|
||||
aria-label={t("发送思考控制")}
|
||||
aria-checked={protocolCanThink && thinkingOn}
|
||||
disabled={!protocolCanThink || busy || checkingModel !== ""}
|
||||
disabled={!protocolCanThink || busy || testing}
|
||||
onClick={() => setThinkingOn((v) => !v)}
|
||||
><span /></button>
|
||||
</div>
|
||||
@@ -322,7 +333,7 @@ export function AddProvider({
|
||||
<small>{t("仅当该地址通过系统代理无法连接、直连可用时开启。")}</small>
|
||||
</span>
|
||||
<button type="button" className="switch-control" data-action="provider.draft" data-value="no-proxy" role="switch" aria-label={t("绕过系统代理")} aria-checked={noProxy || (probe?.noProxy ?? false)}
|
||||
disabled={busy || checkingModel !== "" || probe?.noProxy === true} onClick={() => {
|
||||
disabled={busy || testing || probe?.noProxy === true} onClick={() => {
|
||||
setNoProxy((v) => !v);
|
||||
setFacts(clearModelCheckFacts);
|
||||
}}><span /></button>
|
||||
@@ -351,7 +362,7 @@ export function AddProvider({
|
||||
<span>{t(protocolCanProbe ? "至少手动添加一个模型 ID,也可尝试从接口读取" : "这个协议没有模型列表接口,直接填写服务商给出的模型 ID")}</span>
|
||||
</div>
|
||||
{protocolCanProbe && (
|
||||
<button className="act quiet" data-action="provider.probe" onClick={connect} disabled={busy || checkingModel !== "" || baseUrl.trim() === ""}>
|
||||
<button className="act quiet" data-action="provider.probe" onClick={connect} disabled={busy || testing || baseUrl.trim() === ""}>
|
||||
{t(busy ? "检测中…" : "验证连接并读取")}
|
||||
</button>
|
||||
)}
|
||||
@@ -368,15 +379,20 @@ export function AddProvider({
|
||||
<div className="mlhead">
|
||||
<span className="ttl">{t("启用的模型")}</span>
|
||||
<span className="count">{t("已启用 {on}/{all}", { on: picked.length, all: models.length })}</span>
|
||||
<button className="mrefresh" data-action="provider.model-check-all" onClick={testAll}
|
||||
disabled={busy || testing || picked.length === 0 || baseUrl.trim() === ""} aria-busy={batch === "running" || undefined}>
|
||||
{t("测试已启用模型({n})", { n: picked.length })}
|
||||
</button>
|
||||
</div>
|
||||
<p className="mguide">{t("目录只用于发现,不是白名单。直接输入服务商给出的原始模型 ID 即可。")}</p>
|
||||
<p className="mguide">{t("目录只用于发现,不是白名单。未列出的模型会按原始 ID 保存;「测试已启用模型」会给每个已勾选的模型各发送一次小请求,可能产生少量 Token 费用。")}</p>
|
||||
{batch === "stopped" && <p className="mdiff" role="status">{t("已停止启动新的验证:草稿已改动")}</p>}
|
||||
<ModelChoice
|
||||
models={models}
|
||||
picked={picked}
|
||||
vision={probe?.vision ?? []}
|
||||
facts={facts}
|
||||
onCheck={checkModel}
|
||||
checkDisabled={busy || checkingModel !== "" || baseUrl.trim() === ""}
|
||||
checkDisabled={busy || testing || baseUrl.trim() === ""}
|
||||
onToggle={toggle}
|
||||
onAdd={addModel}
|
||||
/>
|
||||
@@ -411,10 +427,10 @@ export function AddProvider({
|
||||
</div>
|
||||
)}
|
||||
<div className="acts addp-footer">
|
||||
<button className="act" data-action="provider.add" data-primary onClick={save} aria-describedby={nameBad ? "addp-name-rule" : undefined} disabled={busy || composing || checkingModel !== "" || picked.length === 0 || name.trim() === "" || nameBad || kind === "" || baseUrl.trim() === "" || extraBad}>
|
||||
<button className="act" data-action="provider.add" data-primary onClick={save} aria-describedby={nameBad ? "addp-name-rule" : undefined} disabled={busy || composing || testing || picked.length === 0 || name.trim() === "" || nameBad || kind === "" || baseUrl.trim() === "" || extraBad}>
|
||||
{t(busy ? "保存中…" : "添加来源")}
|
||||
</button>
|
||||
<button className="act" onClick={onCancel} disabled={busy || checkingModel !== ""}>{t("取消")}</button>
|
||||
<button className="act" onClick={onCancel} disabled={busy || testing}>{t("取消")}</button>
|
||||
</div>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { watchTreeWake } from "./treewake";
|
||||
import { usePinnedSessions } from "./usePinnedSessions";
|
||||
import { AutoArchiveNotice } from "./AutoArchiveNotice";
|
||||
import { lazy, Suspense, useCallback, useEffect, useMemo, useRef, useState, type CSSProperties } from "react";
|
||||
import { lazy, Suspense, useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore, type CSSProperties } from "react";
|
||||
import { reason } from "../i18n/kernel";
|
||||
import { t } from "../i18n";
|
||||
import type { AccountState, AgentPort, ProviderSetup } from "../port/port";
|
||||
@@ -36,6 +36,7 @@ import { Sidebar } from "./Sidebar";
|
||||
import { Sky } from "./Sky";import { useAddWorkspace } from "./addws";
|
||||
import { AddWorkspacePrompt } from "./AddWorkspacePrompt";
|
||||
import { PaneTabs } from "./PaneTabs";
|
||||
import { onShowsSessionTabsChange, showsSessionTabs } from "../state/prefs";
|
||||
import { isUnread, useViewed } from "./unread";
|
||||
import { Onboarding } from "./Onboarding";
|
||||
import { Welcome } from "./Welcome";
|
||||
@@ -75,6 +76,7 @@ const NO_REPORT: PaneReport = {
|
||||
// conversation — its transcript, metrics and event stream — lives in the Pane
|
||||
// that owns it. That split is what lets two sessions run side by side.
|
||||
export function App({ hub }: { hub: HubPort }) {
|
||||
const sessionTabs = useSyncExternalStore(onShowsSessionTabsChange, showsSessionTabs, showsSessionTabs);
|
||||
const [runtimes, setRuntimes] = useState<RuntimeView[]>([]);
|
||||
const [panesRead, setPanesRead] = useState(false);
|
||||
const [active, setActive] = useState(savedActivePane);
|
||||
@@ -102,6 +104,7 @@ export function App({ hub }: { hub: HubPort }) {
|
||||
const [dockLimit, setDockLimit] = useState(() => dockMax(document.body.clientWidth));
|
||||
const [report, setReport] = useState<PaneReport>(NO_REPORT);
|
||||
const [findPulse, setFindPulse] = useState(0);
|
||||
const [filesRequests, setFilesRequests] = useState<Record<string, number>>({});
|
||||
const [error, setError] = useState("");
|
||||
// false = closed, true = open at its last section, a string = open there.
|
||||
const [settings, setSettings] = useState<string | boolean>(false);
|
||||
@@ -342,6 +345,16 @@ export function App({ hub }: { hub: HubPort }) {
|
||||
void reloadRemotes();
|
||||
}, [reloadRemotes]);
|
||||
|
||||
const openFiles = useCallback(() => {
|
||||
if (!panePorts.has(active)) {
|
||||
setError(t("请先打开一个会话,再查看它的文件。"));
|
||||
return;
|
||||
}
|
||||
hidePrefs();
|
||||
if (roomGaveUp("scene")) setRail(false);
|
||||
setFilesRequests((requests) => ({ ...requests, [active]: (requests[active] ?? 0) + 1 }));
|
||||
}, [active, panePorts, hidePrefs]);
|
||||
|
||||
const openPane = useOpenPane({ hub, runtimes, panePorts, active, reportsRef, runsRef, setRuntimes, setActive, setTakeover, reloadPanes: refreshPanes, sw });
|
||||
|
||||
// Awaitable because deleting a conversation has to close its pane first and
|
||||
@@ -565,7 +578,6 @@ export function App({ hub }: { hub: HubPort }) {
|
||||
rail={rail}
|
||||
theme={scheme}
|
||||
onRail={() => setRail((v) => !v)}
|
||||
railLocked={!!settings}
|
||||
onTheme={() => setTheme(scheme === "dark" ? "light" : "dark")}
|
||||
onFind={openFind}
|
||||
hub={hub} onError={fail}
|
||||
@@ -578,6 +590,7 @@ export function App({ hub }: { hub: HubPort }) {
|
||||
<Nav
|
||||
at={settings === false ? null : settings === true ? "" : settings}
|
||||
onGo={showPrefs}
|
||||
onFiles={openFiles}
|
||||
onHome={hidePrefs}
|
||||
onRail={() => setRail((v) => !v)}
|
||||
onFeedback={() => setFeedback(feedbackEntryTab(feedbackUnread))}
|
||||
@@ -619,6 +632,7 @@ export function App({ hub }: { hub: HubPort }) {
|
||||
accountUnread={accountUnread}
|
||||
wallet={report.wallet}
|
||||
onSettings={showPrefs}
|
||||
onFiles={openFiles}
|
||||
onFeedback={(tab) => setFeedback(tab ?? "send")}
|
||||
feedbackUnread={feedbackUnread}
|
||||
onFind={openFind}
|
||||
@@ -638,7 +652,7 @@ export function App({ hub }: { hub: HubPort }) {
|
||||
{/* One conversation on screen at a time. Side by side, two panes
|
||||
squeezed each other and a glance could not tell which composer
|
||||
belonged to which run; the ones behind keep streaming either way. */}
|
||||
{tabs.length > 1 && (
|
||||
{sessionTabs && tabs.length > 1 && (
|
||||
<PaneTabs
|
||||
tabs={tabs}
|
||||
active={active}
|
||||
@@ -671,6 +685,7 @@ export function App({ hub }: { hub: HubPort }) {
|
||||
onSessionChanged={refreshPanes}
|
||||
pulse={settingsPulse}
|
||||
findPulse={findPulse}
|
||||
filesPulse={filesRequests[rt.id] ?? 0}
|
||||
alert={rt.id === active ? bar : undefined}
|
||||
needsProject={needsProject}
|
||||
onOpenProject={() => {
|
||||
@@ -682,6 +697,7 @@ export function App({ hub }: { hub: HubPort }) {
|
||||
setClaimed(true);
|
||||
}}
|
||||
onSettings={(section) => section ? showPrefs(section) : showPrefs()}
|
||||
onOpenSession={(path) => void openPane({ root: rt.root, sessionPath: path }).catch(fail)}
|
||||
theme={scheme}
|
||||
dockW={shownDockW}
|
||||
dockMax={dockLimit}
|
||||
|
||||
@@ -14,6 +14,8 @@ import { Folding } from "./Folding";
|
||||
import { WindowSection } from "./WindowSection";
|
||||
import { NavRailSection } from "./NavRailSection";
|
||||
import { GraphicsSection } from "./GraphicsSection";
|
||||
import { SendShortcut } from "./SendShortcut";
|
||||
import { SessionTabsSetting } from "./SessionTabsSetting";
|
||||
import { ThemeImport } from "./ThemeImport";
|
||||
import { ThemeRemove } from "./ThemeRemove";
|
||||
import { useThemeInventory } from "./useThemeInventory";
|
||||
@@ -545,6 +547,8 @@ export function Appearance({ port, theme, onTheme, contrast, onContrast, weight,
|
||||
<WindowSection port={port} />
|
||||
<NavRailSection />
|
||||
<GraphicsSection />
|
||||
<SendShortcut />
|
||||
<SessionTabsSetting />
|
||||
<Folding />
|
||||
<Notifications port={port} />
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import { SWAP_MARK } from "./swap";
|
||||
import { occluded, visibleBox } from "./occlusion";
|
||||
import { BrowserFailure } from "./BrowserFailure";
|
||||
import { AgentPageNote } from "./AgentPageNote";
|
||||
import { readPageTabs } from "./browser_open";
|
||||
|
||||
const START = "reasonix://start";
|
||||
|
||||
@@ -162,7 +163,7 @@ export function useBrowserTabs(port: AgentPort, moved: number): BrowserTab[] {
|
||||
const [tabs, setTabs] = useState<BrowserTab[]>(UNREAD_TABS);
|
||||
useEffect(() => {
|
||||
let live = true;
|
||||
port.browserTabs().then((next) => live && setTabs(next), () => {});
|
||||
readPageTabs(port).then((next) => live && setTabs(next), () => {});
|
||||
return () => { live = false; };
|
||||
}, [port, moved]);
|
||||
return tabs;
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { t } from "../i18n";
|
||||
import { keyOf, type Surface } from "./workbench_tabs";
|
||||
import { listenAction } from "./listen";
|
||||
import { pinToViewport } from "./place";
|
||||
|
||||
interface Props {
|
||||
menu: { surface: Surface; x: number; y: number; anchor: HTMLElement };
|
||||
others: boolean; onDismiss: () => void; onClose: (mode: "one" | "others" | "all") => void;
|
||||
}
|
||||
|
||||
export function BrowserTabMenu({ menu, others, onDismiss, onClose }: Props) {
|
||||
const box = useRef<HTMLDivElement>(null);
|
||||
useEffect(() => {
|
||||
const dismiss = (event: Event) => { if (!box.current?.contains(event.target as Node)) onDismiss(); };
|
||||
const blur = () => onDismiss();
|
||||
const off = listenAction(document, "pointerdown", { action: "layer.dismiss", listener: dismiss });
|
||||
window.addEventListener("blur", blur);
|
||||
box.current?.querySelector<HTMLButtonElement>("button")?.focus();
|
||||
return () => {
|
||||
off();
|
||||
window.removeEventListener("blur", blur);
|
||||
if (menu.anchor.isConnected) menu.anchor.focus();
|
||||
};
|
||||
}, [menu, onDismiss]);
|
||||
return createPortal(
|
||||
<div className="tabmenu" data-action-keydown="layer.dismiss" role="menu" aria-label={t("浏览器标签")} ref={(el) => { box.current = el; if (el) pinToViewport(el, menu.x, menu.y); }} onKeyDown={(e) => {
|
||||
if (e.key === "Escape" || e.key === "Tab") { e.preventDefault(); e.stopPropagation(); onDismiss(); }
|
||||
if (!["ArrowDown", "ArrowUp", "Home", "End"].includes(e.key)) return;
|
||||
e.preventDefault();
|
||||
const items = [...e.currentTarget.querySelectorAll<HTMLButtonElement>("button:not(:disabled)")];
|
||||
const at = items.indexOf(document.activeElement as HTMLButtonElement);
|
||||
const next = e.key === "Home" ? 0 : e.key === "End" ? items.length - 1 : (at + (e.key === "ArrowUp" ? -1 : 1) + items.length) % items.length;
|
||||
items[next]?.focus();
|
||||
}}>
|
||||
<button role="menuitem" data-action="workbench.close" data-target={keyOf(menu.surface)} onClick={() => onClose("one")}>{t("关闭")}</button>
|
||||
<button role="menuitem" data-action="workbench.browser-close" data-target={keyOf(menu.surface)} data-value="others" disabled={!others} onClick={() => onClose("others")}>{t("关闭其他浏览器标签")}</button>
|
||||
<button role="menuitem" data-action="workbench.browser-close" data-target={keyOf(menu.surface)} data-value="all" onClick={() => onClose("all")}>{t("关闭全部浏览器标签")}</button>
|
||||
</div>, document.body,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { t } from "../i18n";
|
||||
import { StudioIcon } from "./StudioIcon";
|
||||
import { ariaChord, chord } from "./keys";
|
||||
|
||||
export function BrowserWideToggle({ wide, onToggle }: { wide: boolean; onToggle: () => void }) {
|
||||
const label = wide ? t("恢复浏览器分栏") : t("加宽浏览器");
|
||||
return (
|
||||
<button className="workbench-files" data-action="browser.wide" aria-pressed={wide}
|
||||
aria-label={label} aria-keyshortcuts={`${ariaChord("B")}+Shift`}
|
||||
title={`${label} (${chord("Shift+B")})`} onClick={onToggle}>
|
||||
<StudioIcon name="panel" />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -32,8 +32,6 @@ interface Props {
|
||||
rail: boolean;
|
||||
theme: string;
|
||||
onRail: () => void;
|
||||
/** The settings sheet is a modal over the window: what is behind it is not for pressing. */
|
||||
railLocked?: boolean;
|
||||
onTheme: () => void;
|
||||
onFind: () => void;
|
||||
// The window's port, for what belongs to the window rather than a pane.
|
||||
@@ -41,7 +39,7 @@ interface Props {
|
||||
onError?: (e: unknown) => void;
|
||||
}
|
||||
|
||||
export function Chrome({ port, status, title, steer, onSettings, onBrowser, browser, account, host, rail, theme, onRail, railLocked = false, onTheme, onFind, hub, onError }: Props) {
|
||||
export function Chrome({ port, status, title, steer, onSettings, onBrowser, browser, account, host, rail, theme, onRail, onTheme, onFind, hub, onError }: Props) {
|
||||
const root = status?.workspaceRoot || status?.cwd || "";
|
||||
const project = root ? base(root) : "—";
|
||||
// Only for the "隔离" tag: the folder list and the switch itself moved to the
|
||||
@@ -73,7 +71,7 @@ export function Chrome({ port, status, title, steer, onSettings, onBrowser, brow
|
||||
return (
|
||||
<>
|
||||
{hub && <DeviceBar hub={hub} />}
|
||||
<button className="studio-menu" data-action="chrome.rail" onClick={onRail} disabled={railLocked} aria-pressed={rail} aria-label={rail ? t("收起工作区栏") : t("展开工作区栏")}>
|
||||
<button className="studio-menu" data-action="chrome.rail" onClick={onRail} aria-pressed={rail} aria-label={rail ? t("收起工作区栏") : t("展开工作区栏")}>
|
||||
<svg viewBox="0 0 16 16" aria-hidden="true"><path d="M3 4h10M3 8h10M3 12h10" /></svg>
|
||||
</button>
|
||||
<div className="chrome" onDoubleClick={zoomOnTitleBar}>
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import { useCallback, useMemo, useRef, useState } from "react";
|
||||
import { t } from "../i18n";
|
||||
import type { AgentPort } from "../port/port";
|
||||
import type { WireEvent } from "../port/wire";
|
||||
import { useDismiss } from "./dismiss";
|
||||
import "../styles/compact-now.css";
|
||||
|
||||
export interface CompactAction {
|
||||
busy: boolean;
|
||||
pending: boolean;
|
||||
run: () => void;
|
||||
}
|
||||
|
||||
export function useCompactNow(port: AgentPort, blocked: boolean, onError: (error: unknown) => void) {
|
||||
const [pending, setPending] = useState(false);
|
||||
const held = useRef(false);
|
||||
const release = useCallback(() => { held.current = false; setPending(false); }, []);
|
||||
const onEvent = useCallback((event: WireEvent) => {
|
||||
if (event.kind === "notice" && (event.code === "compacted" || event.code === "compact_declined" || event.code === "compact_failed")) release();
|
||||
}, [release]);
|
||||
const run = useCallback(() => {
|
||||
if (blocked || held.current) return;
|
||||
held.current = true;
|
||||
setPending(true);
|
||||
// /submit acknowledges admission; the typed notice settles the operation.
|
||||
void port.submit("/compact").catch((error) => { release(); onError(error); });
|
||||
}, [port, blocked, onError, release]);
|
||||
return useMemo(() => ({ busy: blocked || pending, pending, run, onEvent, release }), [blocked, pending, run, onEvent, release]);
|
||||
}
|
||||
|
||||
export function CompactNow({ action }: { action: CompactAction }) {
|
||||
const [asking, setAsking] = useState(false);
|
||||
const box = useRef<HTMLDivElement>(null);
|
||||
const trigger = useRef<HTMLButtonElement>(null);
|
||||
const close = useCallback(() => setAsking(false), []);
|
||||
useDismiss(asking, box, close);
|
||||
const cancel = () => { close(); trigger.current?.focus(); };
|
||||
return (
|
||||
<div className="compact-now" ref={box}>
|
||||
<button ref={trigger} className="btn sm" data-action="compaction.confirm" disabled={action.busy} aria-expanded={asking} onClick={() => setAsking(true)}>
|
||||
{action.pending ? t("正在压缩…") : t("立即压缩")}
|
||||
</button>
|
||||
{asking && (
|
||||
<div className="wsconfirm" role="alertdialog" aria-label={t("压缩当前上下文?")}>
|
||||
<div className="wsconfirm-t"><span className="q">{t("压缩当前上下文?")}</span><span className="h">{t("模型将依据摘要继续工作;完整对话记录会保留。")}</span></div>
|
||||
<div className="wsconfirm-a">
|
||||
<button autoFocus data-action="layer.dismiss" onClick={cancel}>{t("取消")}</button>
|
||||
<button data-action="compaction.now" disabled={action.busy} onClick={() => { close(); action.run(); trigger.current?.focus(); }}>{t("确认压缩")}</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -10,6 +10,16 @@ import { draftKey } from "./drafts";
|
||||
|
||||
afterEach(() => { cleanup(); localStorage.clear(); vi.restoreAllMocks(); });
|
||||
|
||||
it("labels the active model with its alias while keeping the raw reference in details", async () => {
|
||||
const port = new MockPort();
|
||||
vi.spyOn(port, "models").mockResolvedValue([
|
||||
{ ref: "relay/vendor/long-v1", provider: "relay", model: "vendor/long-v1", modelAlias: "Short" },
|
||||
]);
|
||||
draw({ port, st: status({ modelRef: "relay/vendor/long-v1" }) });
|
||||
const trigger = await screen.findByRole("button", { name: /Short/ });
|
||||
expect(trigger.title).toBe("relay/vendor/long-v1");
|
||||
});
|
||||
|
||||
// The workspace's git answer, the way /workspace/git words it. Tests that name
|
||||
// an override get it; the rest get a plain repository on main.
|
||||
const git = (over: Partial<WorkspaceGit> = {}): WorkspaceGit =>
|
||||
@@ -344,6 +354,33 @@ describe("composer stop under a starved window", () => {
|
||||
});
|
||||
|
||||
describe("composer menus", () => {
|
||||
it("shows the session's current model first and refreshes that position with canonical status", async () => {
|
||||
const port = new MockPort();
|
||||
const models: ModelEntry[] = [
|
||||
{ ref: "alpha/first", provider: "alpha", model: "first", vendor: "alpha.example" },
|
||||
{ ref: "alpha/second", provider: "alpha", model: "second", vendor: "alpha.example" },
|
||||
{ ref: "beta/third", provider: "beta", model: "third", vendor: "beta.example" },
|
||||
];
|
||||
vi.spyOn(port, "models").mockResolvedValue(models);
|
||||
const setModel = vi.spyOn(port, "setModel").mockResolvedValue(undefined);
|
||||
const onChanged = vi.fn();
|
||||
const props = { port, status: status({ modelRef: "beta/third" }), running: false,
|
||||
onSubmit: async () => true, onChanged, onError: vi.fn() };
|
||||
const view = render(<Composer {...props} />);
|
||||
fireEvent.click(view.container.querySelector('button.model-picker')!);
|
||||
await waitFor(() => expect(screen.getAllByRole("menuitem")).toHaveLength(4));
|
||||
expect(screen.getAllByRole("menuitem")[0].getAttribute("data-value")).toBe("beta/third");
|
||||
expect(screen.getAllByRole("menuitem")[0].hasAttribute("data-on")).toBe(true);
|
||||
fireEvent.click(screen.getAllByRole("menuitem").find((el) => el.getAttribute("data-value") === "alpha/second")!);
|
||||
await waitFor(() => expect(onChanged).toHaveBeenCalledOnce());
|
||||
expect(setModel).toHaveBeenCalledExactlyOnceWith("alpha/second");
|
||||
view.rerender(<Composer {...props} status={status({ modelRef: "alpha/second" })} />);
|
||||
fireEvent.click(view.container.querySelector('button.model-picker')!);
|
||||
expect(screen.getAllByRole("menuitem")[0].getAttribute("data-value")).toBe("alpha/second");
|
||||
expect(screen.getAllByRole("menuitem")[0].hasAttribute("data-on")).toBe(true);
|
||||
expect(screen.getAllByRole("menuitem").filter((el) => el.getAttribute("data-value") === "alpha/second")).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("shows that slash skills are loading until the catalog answers", async () => {
|
||||
vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] });
|
||||
try {
|
||||
@@ -691,3 +728,69 @@ describe("a line taken back from the queue", () => {
|
||||
expect(box.value).toBe("甲");
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
describe("send shortcut preference", () => {
|
||||
it("keeps Enter for multiline drafting and sends with Control+Enter", async () => {
|
||||
localStorage.setItem("rx-send-shortcut", "modifier_enter");
|
||||
const { box, onSubmit } = draw();
|
||||
fireEvent.change(box, { target: { value: "第一行\n第二行", selectionStart: 7 } });
|
||||
expect(fireEvent.keyDown(box, { key: "Enter" })).toBe(true);
|
||||
expect(onSubmit).not.toHaveBeenCalled();
|
||||
expect(box.getAttribute("aria-keyshortcuts")).toBe("Control+Enter");
|
||||
expect(screen.getByText("Ctrl Enter 发送 · Enter 换行")).toBeTruthy();
|
||||
fireEvent.keyDown(box, { key: "Enter", ctrlKey: true });
|
||||
await waitFor(() => expect(onSubmit).toHaveBeenCalledWith("第一行\n第二行"));
|
||||
});
|
||||
|
||||
it("never sends an IME confirmation in modifier mode", async () => {
|
||||
localStorage.setItem("rx-send-shortcut", "modifier_enter");
|
||||
const { box, onSubmit } = draw();
|
||||
fireEvent.change(box, { target: { value: "中文候选", selectionStart: 4 } });
|
||||
fireEvent.compositionStart(box);
|
||||
fireEvent.keyDown(box, { key: "Enter", ctrlKey: true, isComposing: true });
|
||||
fireEvent.compositionEnd(box);
|
||||
fireEvent.keyDown(box, { key: "Enter", ctrlKey: true });
|
||||
expect(onSubmit).not.toHaveBeenCalled();
|
||||
await act(async () => { await new Promise((resolve) => setTimeout(resolve, 120)); });
|
||||
fireEvent.keyDown(box, { key: "Enter" });
|
||||
expect(onSubmit).not.toHaveBeenCalled();
|
||||
fireEvent.keyDown(box, { key: "Enter", ctrlKey: true });
|
||||
await waitFor(() => expect(onSubmit).toHaveBeenCalledWith("中文候选"));
|
||||
});
|
||||
|
||||
it("preserves Alt+Enter submission in Enter mode", async () => {
|
||||
const { box, onSubmit } = draw();
|
||||
fireEvent.change(box, { target: { value: "兼容已有快捷键", selectionStart: 7 } });
|
||||
fireEvent.keyDown(box, { key: "Enter", altKey: true });
|
||||
await waitFor(() => expect(onSubmit).toHaveBeenCalledWith("兼容已有快捷键"));
|
||||
});
|
||||
|
||||
it.each([false, true])("uses tap hints on touch screens in modifier mode (running=%s)", (running) => {
|
||||
localStorage.setItem("rx-send-shortcut", "modifier_enter");
|
||||
touchPointer();
|
||||
draw({ running });
|
||||
expect(screen.getByText(running ? "点按插话 · 回车换行" : "点按发送 · 回车换行")).toBeTruthy();
|
||||
});
|
||||
|
||||
it.each(["Enter", "Tab", "Control+Enter"])("routes %s correctly while a slash completion owns Enter", async (key) => {
|
||||
localStorage.setItem("rx-send-shortcut", "modifier_enter");
|
||||
const port = new MockPort();
|
||||
vi.spyOn(port, "complete").mockResolvedValue({ kind: "slash", from: 0, to: 4, query: "/rev", items: [{ label: "/review", insert: "/review ", kind: "skill" }] });
|
||||
const { box, onSubmit } = draw({ port });
|
||||
fireEvent.change(box, { target: { value: "/rev", selectionStart: 4 } });
|
||||
await screen.findByRole("option", { name: /review/ });
|
||||
fireEvent.keyDown(box, { key: key === "Tab" ? "Tab" : "Enter", ctrlKey: key === "Control+Enter" });
|
||||
if (key === "Control+Enter") await waitFor(() => expect(onSubmit).toHaveBeenCalledWith("/rev"));
|
||||
else { expect(box.value).toBe("/review "); expect(onSubmit).not.toHaveBeenCalled(); }
|
||||
});
|
||||
|
||||
it("keeps the modifier-to-steer hint while a turn runs", () => {
|
||||
localStorage.setItem("rx-send-shortcut", "modifier_enter");
|
||||
const { box, onSubmit } = draw({ running: true });
|
||||
fireEvent.change(box, { target: { value: "补充一句", selectionStart: 4 } });
|
||||
fireEvent.keyDown(box, { key: "Enter" });
|
||||
expect(onSubmit).not.toHaveBeenCalled();
|
||||
expect(screen.getByText("Ctrl Enter 插话 · Enter 换行")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -12,6 +12,8 @@ import { ChipMirror, useSkillChips } from "./ChipMirror";
|
||||
import { useIme } from "./ime";
|
||||
import { usePlaceholderFit } from "./placeholderfit";
|
||||
import { useFitHeight } from "./fitHeight";
|
||||
import { useSendShortcut } from "../state/prefs";
|
||||
import { sendAria, sendHint, sendsOnKey } from "./sendkey";
|
||||
import { countLines, pasteIsLong, planTone, planVerb } from "./intake";
|
||||
import { kindOf, nameOf, previewURL } from "./chipfile";
|
||||
import { useIntake } from "./useIntake";
|
||||
@@ -179,6 +181,7 @@ export function Composer({ port, status, running, quote, restore, focus, onSubmi
|
||||
box.current?.focus();
|
||||
}, chips);
|
||||
const ime = useIme();
|
||||
const sendShortcut = useSendShortcut();
|
||||
|
||||
// A counter, not a boolean: asking twice in a row has to move the cursor twice.
|
||||
useEffect(() => {
|
||||
@@ -355,7 +358,8 @@ export function Composer({ port, status, running, quote, restore, focus, onSubmi
|
||||
// composer answering for an endpoint that never spoke.
|
||||
const declared = efforts.length > 0;
|
||||
const forcedThinking = forcesThinkingFor(models, status?.modelRef);
|
||||
const modelLb = status?.modelRef?.replace(/^[^/]+\//, "") ?? status?.label ?? "—";
|
||||
const modelLb = models.find((m) => m.ref === status?.modelRef)?.modelAlias?.trim()
|
||||
|| (status?.modelRef?.replace(/^[^/]+\//, "") ?? status?.label ?? "—");
|
||||
// A model switch rebuilds the runtime kernel-side; other controls here may
|
||||
// land at once. Each click needs its own pending state: greying the whole
|
||||
// shelf says nothing can change while only one control waits on the kernel.
|
||||
@@ -500,7 +504,7 @@ export function Composer({ port, status, running, quote, restore, focus, onSubmi
|
||||
role="combobox"
|
||||
aria-label={t("任务输入")}
|
||||
aria-describedby={guide}
|
||||
aria-keyshortcuts={touch ? undefined : "Enter Shift+Enter"}
|
||||
aria-keyshortcuts={sendAria(sendShortcut, touch)}
|
||||
aria-busy={submitting}
|
||||
readOnly={submitting}
|
||||
aria-expanded={menu.open}
|
||||
@@ -552,15 +556,15 @@ export function Composer({ port, status, running, quote, restore, focus, onSubmi
|
||||
if (e.key === "Enter") e.preventDefault();
|
||||
return;
|
||||
}
|
||||
const sendKey = sendsOnKey(e, sendShortcut, touch);
|
||||
if (menu.open && (e.key === "ArrowDown" || e.key === "ArrowUp")) {
|
||||
e.preventDefault();
|
||||
menu.move(e.key === "ArrowDown" ? 1 : -1);
|
||||
return;
|
||||
}
|
||||
// Tab completes, always. Enter belongs to the menu only where the
|
||||
// line is not yet a message — a half-typed command — or where the
|
||||
// user went looking through the list themselves.
|
||||
if (menu.open && (e.key === "Tab" || (e.key === "Enter" && menu.ownsEnter)) && !e.shiftKey) {
|
||||
// Tab completes; Enter completes a partial command or an explicitly selected item.
|
||||
// The configured modifier chord sends the draft even with a menu open.
|
||||
if (menu.open && (e.key === "Tab" || (e.key === "Enter" && menu.ownsEnter && !(sendShortcut === "modifier_enter" && sendKey))) && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
menu.accept();
|
||||
return;
|
||||
@@ -573,7 +577,7 @@ export function Composer({ port, status, running, quote, restore, focus, onSubmi
|
||||
menu.dismiss();
|
||||
return;
|
||||
}
|
||||
if (e.key === "Enter" && !e.shiftKey && !touch) {
|
||||
if (sendKey) {
|
||||
e.preventDefault();
|
||||
send();
|
||||
}
|
||||
@@ -595,9 +599,7 @@ export function Composer({ port, status, running, quote, restore, focus, onSubmi
|
||||
? t("正在添加附件…")
|
||||
: failed
|
||||
? t("有附件添加失败,请重试或移除")
|
||||
: t(touch
|
||||
? running ? "点按插话 · 回车换行" : "点按发送 · 回车换行"
|
||||
: running ? "Enter 插话 · Shift+Enter 换行" : "Enter 发送 · Shift+Enter 换行")}
|
||||
: sendHint(sendShortcut, running, touch)}
|
||||
</span>
|
||||
{showCount && <span className="fcount">{t("{n} 字 · {lines} 行", { n: text.length, lines })}</span>}
|
||||
</div>
|
||||
@@ -668,7 +670,7 @@ export function Composer({ port, status, running, quote, restore, focus, onSubmi
|
||||
place="bottom"
|
||||
title={status?.modelRef ?? modelLb}
|
||||
current={status?.modelRef}
|
||||
items={modelMenu(models, providerOrder)}
|
||||
items={modelMenu(models, providerOrder, status?.modelRef)}
|
||||
menuClassName="studio-model-menu"
|
||||
searchAlways
|
||||
searchPlaceholder={t("搜索模型或服务商…")}
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
import { t } from "../i18n";
|
||||
import type { ContextBreakdown, McpEntry } from "../port/port";
|
||||
import { CompactNow, type CompactAction } from "./CompactNow";
|
||||
|
||||
const compact = (n: number): string => n >= 1_000_000 ? `${(n / 1_000_000).toFixed(1)}M` : n >= 1_000 ? `${Math.round(n / 100) / 10}k` : String(n);
|
||||
|
||||
export function ContextSummaryCard({ context, mcp, percent, onManage, className = "" }: {
|
||||
export function ContextSummaryCard({ context, mcp, percent, onManage, compact: action, className = "" }: {
|
||||
context: ContextBreakdown | null;
|
||||
mcp: McpEntry[];
|
||||
percent: number | null;
|
||||
onManage: () => void;
|
||||
compact?: CompactAction;
|
||||
className?: string;
|
||||
}) {
|
||||
const active = mcp.filter((entry) => entry.enabled);
|
||||
@@ -31,6 +33,7 @@ export function ContextSummaryCard({ context, mcp, percent, onManage, className
|
||||
<div className="chrome-context-parts">{parts.map(([label, value, key]) => <div key={key}><i data-part={key} /><span>{label}</span><b>{compact(value)}</b></div>)}</div>
|
||||
</>
|
||||
) : <p>{t("暂时没有上下文数据")}</p>}
|
||||
{action && <CompactNow action={action} />}
|
||||
<section>
|
||||
<div><b>MCP</b><small>{ready.length} / {active.length} {t("个已连接")} · {tools} {t("个工具")}</small></div>
|
||||
{active.slice(0, 4).map((server) => <div className="chrome-mcp-row" key={server.name}><i data-ready={server.state === "ready" ? "" : undefined} /><span>{server.name}</span><small>{server.state === "ready" ? `${server.tools} ${t("个工具")}` : t("等待连接")}</small></div>)}
|
||||
|
||||
@@ -22,7 +22,7 @@ export async function copyText(text: string) {
|
||||
}
|
||||
}
|
||||
|
||||
export function CopyButton({ text, iconOnly = false, showFeedback = false, className, label: what, ariaLabel }: { text: string; iconOnly?: boolean; showFeedback?: boolean; className?: string; label?: string; ariaLabel?: string }) {
|
||||
export function CopyButton({ text, iconOnly = false, showFeedback = false, className, label: what, ariaLabel, role }: { text: string; iconOnly?: boolean; showFeedback?: boolean; className?: string; label?: string; ariaLabel?: string; role?: "menuitem" }) {
|
||||
const [state, setState] = useState<"idle" | "done" | "failed">("idle");
|
||||
const timer = useRef<number | null>(null);
|
||||
|
||||
@@ -44,6 +44,7 @@ export function CopyButton({ text, iconOnly = false, showFeedback = false, class
|
||||
<button
|
||||
className={className ? `copy ${className}` : "copy"}
|
||||
type="button"
|
||||
role={role}
|
||||
data-icon={iconOnly ? "" : undefined}
|
||||
data-state={state}
|
||||
data-feedback={showFeedback && state !== "idle" ? "" : undefined}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { t } from "../i18n";
|
||||
import type { Protocol, ProviderCheck, ProviderEntry } from "../port/port";
|
||||
import { checkedFact, clearModelCheckFacts, ModelChoice, type ModelFact } from "./ModelChoice";
|
||||
@@ -12,6 +12,7 @@ import { EffortShape } from "./EffortShape";
|
||||
import type { ModelEffort, ModelLimit, ProviderEdit } from "../port/port";
|
||||
import { KIND_LABEL } from "./vendors";
|
||||
import { ModelLimits, limitTextOf, limitsToSend, type LimitText } from "./ModelLimits";
|
||||
import { useModelBatch } from "./useModelBatch";
|
||||
|
||||
// Only what this form owns is sent: the entry keeps its prices, effort
|
||||
// vocabularies and everything else the panel cannot show.
|
||||
@@ -41,6 +42,7 @@ export function EditConn({
|
||||
const [apiKey, setApiKey] = useState("");
|
||||
const [models, setModels] = useState<string[]>(seededModels);
|
||||
const [picked, setPicked] = useState<string[]>(entry.models);
|
||||
const [aliases, setAliases] = useState<Record<string, string>>(entry.modelAliases ?? {});
|
||||
const [vision, setVision] = useState<string[]>(seededVision);
|
||||
const [visionSettable, setVisionSettable] = useState<string[] | undefined>(
|
||||
entry.visionSettable ? [...new Set([...entry.visionSettable, ...(initialCheck?.vision ?? [])])] : undefined,
|
||||
@@ -48,9 +50,6 @@ export function EditConn({
|
||||
const [facts, setFacts] = useState<Record<string, ModelFact>>(() => modelFacts(entry.models, initialCheck));
|
||||
const [diff, setDiff] = useState(() => initialCheck?.ok ? catalogDiff(entry.models, initialCheck.models ?? [], false) : null);
|
||||
const [checkingModel, setCheckingModel] = useState("");
|
||||
const [batch, setBatch] = useState<"idle" | "running" | "stopped">("idle");
|
||||
const run = useRef<AbortController | null>(null);
|
||||
const testing = checkingModel !== "" || batch === "running";
|
||||
const [def, setDef] = useState(entry.default || entry.models[0] || "");
|
||||
const [err, setErr] = useState<{ text: string; kind: "save" | "unapplied" } | null>(null);
|
||||
const [refreshFail, setRefreshFail] = useState("");
|
||||
@@ -181,43 +180,6 @@ export function EditConn({
|
||||
}
|
||||
};
|
||||
|
||||
const stopBatch = useCallback(() => {
|
||||
if (!run.current) return false;
|
||||
run.current.abort();
|
||||
run.current = null;
|
||||
setFacts(stopChecking);
|
||||
setBatch("stopped");
|
||||
return true;
|
||||
}, []);
|
||||
|
||||
const testAll = async () => {
|
||||
if (run.current || picked.length === 0) return;
|
||||
const ctl = new AbortController();
|
||||
run.current = ctl;
|
||||
setBatch("running");
|
||||
const queue = [...picked];
|
||||
const lane = async () => {
|
||||
for (let model = queue.shift(); model !== undefined && !ctl.signal.aborted; model = queue.shift()) {
|
||||
const m = model;
|
||||
setFacts((cur) => ({ ...cur, [m]: { ...(cur[m] ?? { origin: "configured" }), checking: true } }));
|
||||
let next: (fact: ModelFact | undefined) => ModelFact;
|
||||
try {
|
||||
const got = await port.checkProviderModel({ name: entry.name, model: m, baseUrl: baseUrl.trim(), apiKey: apiKey.trim(), kind });
|
||||
next = (fact) => ({ ...checkedFact(fact, "configured", got), checking: false });
|
||||
} catch {
|
||||
next = (fact) => ({ ...(fact ?? { origin: "configured" }), status: "unknown", reason: "network", checking: false });
|
||||
}
|
||||
if (ctl.signal.aborted) return;
|
||||
setFacts((cur) => ({ ...cur, [m]: next(cur[m]) }));
|
||||
}
|
||||
};
|
||||
await Promise.all([lane(), lane()]);
|
||||
if (run.current === ctl) {
|
||||
run.current = null;
|
||||
setBatch("idle");
|
||||
}
|
||||
};
|
||||
|
||||
// A connection list typed here is what every inheriting model gets; with
|
||||
// none typed, the kernel's answer holds only while the form still matches
|
||||
// what was saved.
|
||||
@@ -251,6 +213,7 @@ export function EditConn({
|
||||
baseUrl: baseUrl.trim(),
|
||||
apiKey: apiKey.trim(),
|
||||
models: picked,
|
||||
modelAliases: Object.fromEntries(Object.entries(aliases).filter(([m, value]) => picked.includes(m) && value.trim()).map(([m, value]) => [m, value.trim()])),
|
||||
default: picked.includes(def) ? def : picked[0] ?? "",
|
||||
vision: vision.filter((m) => picked.includes(m)),
|
||||
contextWindow: Number(win.replace(/\D/g, "")) || 0,
|
||||
@@ -278,10 +241,12 @@ export function EditConn({
|
||||
const dirty = draftKey !== stored;
|
||||
useEffect(() => onDirty?.(dirty), [dirty, onDirty]);
|
||||
useEffect(() => () => onDirty?.(false), [onDirty]);
|
||||
useEffect(() => {
|
||||
if (!stopBatch()) setBatch((b) => (b === "stopped" ? "idle" : b));
|
||||
}, [draftKey, stopBatch]);
|
||||
useEffect(() => () => run.current?.abort(), []);
|
||||
const { batch, testAll } = useModelBatch({
|
||||
port, picked, draftKey, setFacts, origin: "configured",
|
||||
blocked: checkingModel !== "" || busy !== "",
|
||||
request: { name: entry.name, baseUrl: baseUrl.trim(), apiKey: apiKey.trim(), kind },
|
||||
});
|
||||
const testing = checkingModel !== "" || batch === "running";
|
||||
const [edited, setEdited] = useState(false);
|
||||
if (dirty && !edited) setEdited(true);
|
||||
|
||||
@@ -413,6 +378,8 @@ export function EditConn({
|
||||
facts={facts}
|
||||
visionLocked={visionLocked}
|
||||
onCheck={checkModel}
|
||||
aliases={aliases}
|
||||
onAlias={(m, value) => setAliases((current) => ({ ...current, [m]: value }))}
|
||||
checkDisabled={busy !== "" || testing}
|
||||
onToggle={(m) => toggle(picked, setPicked, m)}
|
||||
onVision={(m) => toggle(vision, setVision, m)}
|
||||
@@ -614,7 +581,3 @@ const PHASE_TEXT: Record<Phase, string> = {
|
||||
clean: "没有更改", dirty: "有未保存的更改", failed: "有未保存的更改", saving: "正在保存…",
|
||||
saved: "已保存", unapplied: "已保存,尚未生效",
|
||||
};
|
||||
|
||||
function stopChecking(facts: Record<string, ModelFact>): Record<string, ModelFact> {
|
||||
return Object.fromEntries(Object.entries(facts).map(([model, fact]) => [model, fact.checking ? { ...fact, checking: false } : fact]));
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import { t } from "../i18n";
|
||||
import { systemLabel } from "./osname";
|
||||
import { tx } from "../i18n/rich";
|
||||
import { FEEDBACK_CATEGORIES, FEEDBACK_CODE, FEEDBACK_REPO_ISSUES, type FeedbackCategory, type FeedbackEnv, type FeedbackReceipt } from "../port/feedback";
|
||||
import { host } from "../port/host";
|
||||
import type { AgentPort } from "../port/port";
|
||||
import { CopyButton } from "./CopyButton";
|
||||
import { StudioIcon } from "./StudioIcon";
|
||||
@@ -52,6 +53,7 @@ export function FeedbackForm({ port, onMine, onClose, onFile }: Props) {
|
||||
const [over, setOver] = useState(false);
|
||||
const [phase, setPhase] = useState<"editing" | "sending" | "sent">("editing");
|
||||
const [failure, setFailure] = useState<FeedbackFailure | null>(null);
|
||||
const [logsFailed, setLogsFailed] = useState(false);
|
||||
const [sent, setSent] = useState<FeedbackReceipt | null>(null);
|
||||
const key = useRef(uuid());
|
||||
const attempt = useRef("");
|
||||
@@ -383,6 +385,23 @@ export function FeedbackForm({ port, onMine, onClose, onFile }: Props) {
|
||||
<dd>{env.env.providerKind}</dd>
|
||||
</dl>
|
||||
<p className="fbk-hint">{t("只读。不包含密钥、地址或任何文件内容。")}</p>
|
||||
{host().revealsLogs() && (
|
||||
<div className="fbk-logs">
|
||||
<button
|
||||
type="button"
|
||||
className="btn sm"
|
||||
data-action="feedback.logs"
|
||||
onClick={() => {
|
||||
setLogsFailed(false);
|
||||
void host().revealLogs().then((why) => setLogsFailed(why !== null));
|
||||
}}
|
||||
>
|
||||
{t("打开日志文件夹")}
|
||||
</button>
|
||||
<p className="fbk-hint">{t("维护者向你要日志时,打开这个文件夹,把最新的 shell.log 和 host.log 发给我们。发之前可以先自己看一眼。")}</p>
|
||||
{logsFailed && <p className="fbk-hint" role="alert" data-tone="error">{t("无法打开日志文件夹")}</p>}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
|
||||
|
||||
@@ -108,8 +108,7 @@ interface Props {
|
||||
onUnread?: (n: number) => void;
|
||||
}
|
||||
|
||||
// Opening the list is what reads the replies: each report with new ones is
|
||||
// marked seen, and its "new" marks stay until the page is closed.
|
||||
// A reply is read only in an expanded thread; its "new" mark stays for this visit.
|
||||
export function FeedbackMine({ port, onFile, onUnread }: Props) {
|
||||
const [mine, setMine] = useState<Mine | null>(null);
|
||||
const [failure, setFailure] = useState<FeedbackFailure | null>(null);
|
||||
@@ -122,12 +121,24 @@ export function FeedbackMine({ port, onFile, onUnread }: Props) {
|
||||
report.current = onUnread;
|
||||
const itemsRef = useRef<Mine["items"]>([]);
|
||||
const pending = useRef(new Set<string>());
|
||||
const tell = () => report.current?.(itemsRef.current.filter((i) => i.needsInput || pending.current.has(i.receipt)).length);
|
||||
const unfolded = (receipt: string) =>
|
||||
port.feedbackSeen(receipt, newest(itemsRef.current.find((i) => i.receipt === receipt)!)).then(() => {
|
||||
pending.current.delete(receipt);
|
||||
tell();
|
||||
}).catch(() => {});
|
||||
const views = useRef(new Map<string, boolean>());
|
||||
const tell = useCallback(() => report.current?.(itemsRef.current.filter((i) => i.needsInput || pending.current.has(i.receipt)).length), []);
|
||||
const markSeen = useCallback(async (item: FeedbackItem) => {
|
||||
const upTo = newest(item);
|
||||
await port.feedbackSeen(item.receipt, upTo);
|
||||
const current = itemsRef.current.find((i) => i.receipt === item.receipt);
|
||||
if (current && newest(current) <= upTo) pending.current.delete(item.receipt);
|
||||
tell();
|
||||
}, [port, tell]);
|
||||
const view = (receipt: string, all: boolean | null) => {
|
||||
if (all === null) {
|
||||
views.current.delete(receipt);
|
||||
return;
|
||||
}
|
||||
views.current.set(receipt, all);
|
||||
const item = itemsRef.current.find((i) => i.receipt === receipt)!;
|
||||
if (pending.current.has(receipt) && (all || !foldedUnread(item))) void markSeen(item).catch(() => {});
|
||||
};
|
||||
|
||||
const load = useCallback(() => {
|
||||
setLoading(true);
|
||||
@@ -142,17 +153,16 @@ export function FeedbackMine({ port, onFile, onUnread }: Props) {
|
||||
return next;
|
||||
});
|
||||
itemsRef.current = m.items;
|
||||
pending.current = new Set(m.items.filter((i) => i.unreadReplies > 0 && foldedUnread(i)).map((i) => i.receipt));
|
||||
const shown = m.items.filter((i) => i.unreadReplies > 0 && !pending.current.has(i.receipt));
|
||||
const read = await Promise.allSettled(shown.map((i) => port.feedbackSeen(i.receipt, newest(i))));
|
||||
shown.forEach((i, k) => read[k]!.status === "rejected" && pending.current.add(i.receipt));
|
||||
pending.current = new Set(m.items.filter((i) => i.unreadReplies > 0).map((i) => i.receipt));
|
||||
const shown = m.items.filter((i) => i.unreadReplies > 0 && views.current.has(i.receipt) && (views.current.get(i.receipt) || !foldedUnread(i)));
|
||||
await Promise.allSettled(shown.map(markSeen));
|
||||
tell();
|
||||
const fresher = m.items.filter((i) => i.unreadReplies > 0).length;
|
||||
if (fresher > 0) setSaid(t("有 {n} 份反馈收到了新回复。", { n: fresher }));
|
||||
})
|
||||
.catch((e) => setFailure(feedbackFailure(e)))
|
||||
.finally(() => setLoading(false));
|
||||
}, [port]);
|
||||
}, [port, markSeen, tell]);
|
||||
|
||||
useEffect(load, [load]);
|
||||
|
||||
@@ -263,7 +273,7 @@ export function FeedbackMine({ port, onFile, onUnread }: Props) {
|
||||
))}
|
||||
</ol>}
|
||||
<FeedbackReviewNote item={item} />
|
||||
<FeedbackThread item={item} fresh={fresh[item.receipt] ?? 0} onShowAll={unfolded} />
|
||||
<FeedbackThread item={item} fresh={fresh[item.receipt] ?? 0} onView={view} />
|
||||
<FeedbackReplyBox port={port} item={item} limit={replyBytes} offline={mine.offline} onSent={sent} onFile={onFile} onStale={load} />
|
||||
</li>
|
||||
))}
|
||||
|
||||
@@ -38,6 +38,7 @@ describe("the thread under a report", () => {
|
||||
const port = portWith(() => mine([item({ status: "answered", replies: [reply(1, "maintainer", hostile), reply(2, "user", "thanks\nsecond line")] })]));
|
||||
render(<FeedbackMine port={port} onFile={() => {}} />);
|
||||
await screen.findByText("FB-AAAA-0001");
|
||||
await userEvent.click(screen.getByRole("button", { name: /展开对话/ }));
|
||||
const msgs = [...row("FB-AAAA-0001").querySelectorAll(".fbk-thread li")];
|
||||
expect(msgs.map((li) => li.getAttribute("data-author"))).toEqual(["maintainer", "user"]);
|
||||
expect(msgs[0]!.querySelector(".fbk-msg")!.textContent).toBe(hostile);
|
||||
@@ -55,6 +56,7 @@ describe("the thread under a report", () => {
|
||||
await screen.findByText("FB-AAAA-0001");
|
||||
expect(screen.queryByText("message 3")).toBeNull();
|
||||
expect(screen.getByText("message 4")).toBeTruthy();
|
||||
await userEvent.click(screen.getByRole("button", { name: /展开对话/ }));
|
||||
await userEvent.click(screen.getByRole("button", { name: "显示更早的 3 条" }));
|
||||
expect(screen.getByText("message 1")).toBeTruthy();
|
||||
expect(screen.queryByRole("button", { name: /显示更早/ })).toBeNull();
|
||||
@@ -239,7 +241,7 @@ describe("who may reply", () => {
|
||||
});
|
||||
|
||||
describe("reading the replies", () => {
|
||||
it("marks each report with new replies seen, keeps its New mark for this visit, and reports what still waits", async () => {
|
||||
it("marks an opened report seen, keeps its New mark for this visit, and reports what still waits", async () => {
|
||||
const items = [
|
||||
item({ receipt: "FB-AAAA-0001", status: "answered", unreadReplies: 1, replies: [reply(1, "maintainer", "old"), reply(2, "user", "ok"), reply(3, "maintainer", "fresh")] }),
|
||||
item({ receipt: "FB-AAAA-0002", status: "needs_info", needsInput: true, replies: [reply(4, "maintainer", "q")] }),
|
||||
@@ -249,6 +251,8 @@ describe("reading the replies", () => {
|
||||
const onUnread = vi.fn();
|
||||
render(<FeedbackMine port={port} onFile={() => {}} onUnread={onUnread} />);
|
||||
await screen.findByText("FB-AAAA-0001");
|
||||
expect(port.feedbackSeen).not.toHaveBeenCalled();
|
||||
await userEvent.click(within(row("FB-AAAA-0001")).getByRole("button", { name: /展开对话/ }));
|
||||
await waitFor(() => expect(onUnread).toHaveBeenCalledWith(1));
|
||||
expect(port.feedbackSeen).toHaveBeenCalledTimes(1);
|
||||
expect(port.feedbackSeen).toHaveBeenCalledWith("FB-AAAA-0001", 3);
|
||||
@@ -265,6 +269,8 @@ describe("reading the replies", () => {
|
||||
});
|
||||
const onUnread = vi.fn();
|
||||
render(<FeedbackMine port={port} onFile={() => {}} onUnread={onUnread} />);
|
||||
await userEvent.click(await screen.findByRole("button", { name: /展开对话/ }));
|
||||
await waitFor(() => expect(port.feedbackSeen).toHaveBeenCalledWith("FB-AAAA-0001", 1));
|
||||
await waitFor(() => expect(onUnread).toHaveBeenCalledWith(1));
|
||||
});
|
||||
|
||||
@@ -301,6 +307,7 @@ describe("review fixes", () => {
|
||||
port.feedbackSeen = vi.fn(async () => { throw new Error("disk"); });
|
||||
const onUnread = vi.fn();
|
||||
render(<FeedbackMine port={port} onFile={() => {}} onUnread={onUnread} />);
|
||||
await waitFor(() => expect(port.feedbackSeen).toHaveBeenCalledWith("FB-AAAA-0001", 1));
|
||||
await waitFor(() => expect(onUnread).toHaveBeenCalledWith(1));
|
||||
expect(onUnread).not.toHaveBeenCalledWith(2);
|
||||
});
|
||||
@@ -339,6 +346,8 @@ describe("review fixes", () => {
|
||||
await screen.findByText("FB-AAAA-0001");
|
||||
await waitFor(() => expect(onUnread).toHaveBeenCalledWith(1));
|
||||
expect(port.feedbackSeen).not.toHaveBeenCalled();
|
||||
await userEvent.click(screen.getByRole("button", { name: /展开对话/ }));
|
||||
expect(port.feedbackSeen).not.toHaveBeenCalled();
|
||||
await userEvent.click(screen.getByRole("button", { name: /显示更早/ }));
|
||||
await waitFor(() => expect(port.feedbackSeen).toHaveBeenCalledWith("FB-AAAA-0001", 6));
|
||||
await waitFor(() => expect(onUnread).toHaveBeenLastCalledWith(0));
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useMemo, useRef, useState } from "react";
|
||||
import { useEffect, useId, useMemo, useRef, useState } from "react";
|
||||
import { current, t } from "../i18n";
|
||||
import { FEEDBACK_CODE, FEEDBACK_REPO_ISSUES, FEEDBACK_REPLYABLE, isUnderReview, type FeedbackItem, type FeedbackReply } from "../port/feedback";
|
||||
import type { AgentPort } from "../port/port";
|
||||
@@ -27,13 +27,16 @@ function when(iso: string): string {
|
||||
interface ThreadProps {
|
||||
item: FeedbackItem;
|
||||
fresh: number;
|
||||
onShowAll?: (receipt: string) => void;
|
||||
onView?: (receipt: string, all: boolean | null) => void;
|
||||
}
|
||||
|
||||
// Replies are plain text by contract: they render as text nodes, never linked,
|
||||
// formatted or interpreted, whoever wrote them.
|
||||
export function FeedbackThread({ item, fresh, onShowAll }: ThreadProps) {
|
||||
export function FeedbackThread({ item, fresh, onView }: ThreadProps) {
|
||||
const [all, setAll] = useState(false);
|
||||
const [expanded, setExpanded] = useState(item.needsInput);
|
||||
const id = useId();
|
||||
useEffect(() => { if (item.needsInput) onView?.(item.receipt, false); }, []); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
const replies = item.replies;
|
||||
const hidden = all ? 0 : Math.max(0, replies.length - SHOWN);
|
||||
const shown = replies.slice(hidden);
|
||||
@@ -42,23 +45,35 @@ export function FeedbackThread({ item, fresh, onShowAll }: ThreadProps) {
|
||||
if (replies.length === 0) return null;
|
||||
return (
|
||||
<section className="fbk-thread" aria-label={t("对话")}>
|
||||
{hidden > 0 && (
|
||||
<button type="button" className="btn sm" data-action="feedback.thread.more" onClick={() => { setAll(true); onShowAll?.(item.receipt); }}>
|
||||
{t("显示更早的 {n} 条", { n: hidden })}
|
||||
</button>
|
||||
)}
|
||||
<ol>
|
||||
{shown.map((r) => (
|
||||
<li key={r.id} data-author={r.author} data-new={isNew(r) ? "" : undefined}>
|
||||
<div className="fbk-msg-hd">
|
||||
<b>{t(AUTHOR_LABEL[r.author])}</b>
|
||||
{isNew(r) && <span className="fbk-new">{t("新")}</span>}
|
||||
<time dateTime={r.createdAt}>{when(r.createdAt)}</time>
|
||||
</div>
|
||||
<p className="fbk-msg" dir="auto">{r.body}</p>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
<button type="button" className="btn sm fbk-thread-toggle" data-action="feedback.thread.toggle" data-target={item.receipt} aria-expanded={expanded} aria-controls={id} onClick={() => {
|
||||
const next = !expanded;
|
||||
setExpanded(next);
|
||||
onView?.(item.receipt, next ? all : null);
|
||||
}}>
|
||||
<StudioIcon name={expanded ? "down" : "chevron"} />
|
||||
<span>{t(expanded ? "收起对话" : "展开对话")}</span>
|
||||
<span className="fbk-meta">{t("{n} 条回复", { n: replies.length })}</span>
|
||||
{fresh > 0 && <span className="fbk-thread-new">{t("{n} 条新回复", { n: fresh })}</span>}
|
||||
</button>
|
||||
<div id={id} className="fbk-thread-body" hidden={!expanded}>
|
||||
{hidden > 0 && (
|
||||
<button type="button" className="btn sm" data-action="feedback.thread.more" onClick={() => { setAll(true); onView?.(item.receipt, true); }}>
|
||||
{t("显示更早的 {n} 条", { n: hidden })}
|
||||
</button>
|
||||
)}
|
||||
<ol>
|
||||
{shown.map((r) => (
|
||||
<li key={r.id} data-author={r.author} data-new={isNew(r) ? "" : undefined}>
|
||||
<div className="fbk-msg-hd">
|
||||
<b>{t(AUTHOR_LABEL[r.author])}</b>
|
||||
{isNew(r) && <span className="fbk-new">{t("新")}</span>}
|
||||
<time dateTime={r.createdAt}>{when(r.createdAt)}</time>
|
||||
</div>
|
||||
<p className="fbk-msg" dir="auto">{r.body}</p>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
Loaded 100 of 398 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user