mirror of
https://github.com/esengine/DeepSeek-Reasonix.git
synced 2026-11-07 10:45:59 -10:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f4ef59f2ff | ||
|
|
7bb7290c2f | ||
|
|
07794a0533 | ||
|
|
bc18992293 | ||
|
|
b942018011 | ||
|
|
080e0d1182 | ||
|
|
f9b2c3abe8 | ||
|
|
2c9f2c7788 | ||
|
|
89c3958040 | ||
|
|
f0221714c9 | ||
|
|
5311e0c9eb | ||
|
|
6159661f92 | ||
|
|
963b43c625 | ||
|
|
e5494594c2 | ||
|
|
56ce1d0a18 | ||
|
|
0f7f43e744 | ||
|
|
564afe1981 | ||
|
|
666a947bce | ||
|
|
06bbd210ee | ||
|
|
118e7f9c3b | ||
|
|
e509626a00 | ||
|
|
f5fb4c5b75 | ||
|
|
ebdfe03167 | ||
|
|
e4272b885b | ||
|
|
155f08893c | ||
|
|
4cf882e80d | ||
|
|
761627c655 | ||
|
|
32fe51e1cb | ||
|
|
e8ebd833b1 | ||
|
|
a43bf21e4d | ||
|
|
efa942eb91 | ||
|
|
d1637e2415 | ||
|
|
f6a2074130 | ||
|
|
451c9f95cc | ||
|
|
f3223bcfbd | ||
|
|
90e7b1d531 | ||
|
|
1d3320e876 | ||
|
|
2d2841fdbd | ||
|
|
a2eebf8f41 | ||
|
|
2c6afe20d4 | ||
|
|
3426dea1f7 | ||
|
|
c541413fde | ||
|
|
09db5dc7ff | ||
|
|
f87c736a96 | ||
|
|
ab3c108b30 | ||
|
|
c7c127c310 | ||
|
|
885c7886ef | ||
|
|
377c3c3f90 | ||
|
|
96ff4b6503 | ||
|
|
a987c902bf | ||
|
|
2ed83f545d | ||
|
|
7fdb4fbc8a | ||
|
|
d75036fd3b | ||
|
|
807b98a077 | ||
|
|
bd7d0fcd67 | ||
|
|
1e4f38271c | ||
|
|
223f34678f | ||
|
|
393f8508ca | ||
|
|
565216c28c | ||
|
|
82e7ba1068 | ||
|
|
24946f31c7 | ||
|
|
dfa0928859 | ||
|
|
11fc7d1b33 | ||
|
|
0a1839adc9 | ||
|
|
c3c3cac1f9 | ||
|
|
9c06b33ffa | ||
|
|
39270df554 | ||
|
|
fd9f8027f4 | ||
|
|
d5949f79bb | ||
|
|
250b9f6f91 | ||
|
|
c454608baf | ||
|
|
b95698d03c | ||
|
|
65118e2d9b | ||
|
|
62cbf89624 | ||
|
|
0bffc871da | ||
|
|
65be8e9d0c | ||
|
|
c0867f60a5 | ||
|
|
020a1f1757 | ||
|
|
02d8bf531f | ||
|
|
76dc8a48bc | ||
|
|
01c4e63e58 | ||
|
|
6abb384aac | ||
|
|
afeb51806f | ||
|
|
af1d9151ec | ||
|
|
f6f90dc279 | ||
|
|
92539bbd1b | ||
|
|
311a613c0c | ||
|
|
e76d093c57 | ||
|
|
7a95eed7c2 | ||
|
|
96795f99d1 | ||
|
|
e6b77215fd | ||
|
|
403bfcab50 | ||
|
|
df5ee95898 | ||
|
|
14396181b8 | ||
|
|
0076b8816a | ||
|
|
99928cebeb | ||
|
|
de1fb49951 | ||
|
|
5c8e238eef | ||
|
|
68a1a47322 | ||
|
|
0f5a7f143c | ||
|
|
4a6dcda402 | ||
|
|
6e6c32627b | ||
|
|
d30e6f1c37 | ||
|
|
7c64489c69 | ||
|
|
73f622326f | ||
|
|
95cf355b25 | ||
|
|
54d5fe65b4 | ||
|
|
9237eb82b6 | ||
|
|
62a95e31b0 | ||
|
|
a1bbcd7194 | ||
|
|
b6f0a8ebbd | ||
|
|
23ce2a7af7 | ||
|
|
c880b7ad73 | ||
|
|
499f7f2e44 | ||
|
|
53086911fd | ||
|
|
850c4b5db5 | ||
|
|
c57062b788 | ||
|
|
26e5b6ad01 | ||
|
|
010b761286 | ||
|
|
519f94232f | ||
|
|
d0e0d4e4f3 | ||
|
|
d451290d46 | ||
|
|
29c859b7b0 | ||
|
|
91035ddd8e | ||
|
|
4eb0b6e1c3 | ||
|
|
3cff8c9ffc | ||
|
|
0003bef4e7 | ||
|
|
b82a97dd1d | ||
|
|
6968f1dca3 | ||
|
|
ecd14ab332 | ||
|
|
174ea10844 | ||
|
|
c87628398f | ||
|
|
e02e62c310 | ||
|
|
fee799a7ed | ||
|
|
1e7379c29d | ||
|
|
0b579f2110 | ||
|
|
1ea049d9c2 | ||
|
|
8e5d9e815d | ||
|
|
cba9390d2b | ||
|
|
34ca109ec4 | ||
|
|
4c7386f921 | ||
|
|
224c6e61d3 | ||
|
|
b44e9af7e5 | ||
|
|
e7a8a1320d | ||
|
|
7f51ad178b | ||
|
|
8664212c45 | ||
|
|
4ec73b2441 | ||
|
|
acd567721c | ||
|
|
a72c392b68 | ||
|
|
2e15cc9d59 | ||
|
|
eaba743bc5 | ||
|
|
157809c4d7 | ||
|
|
1447b62908 | ||
|
|
d3b5111ca8 | ||
|
|
a306a07f82 | ||
|
|
15101b87f4 | ||
|
|
6f989b09c5 | ||
|
|
110c5ece63 | ||
|
|
7837083eba | ||
|
|
f14ce69798 | ||
|
|
bfda54539b | ||
|
|
ee20852d1b | ||
|
|
a7a256adc1 | ||
|
|
0466fef9d6 | ||
|
|
dc68694cdb | ||
|
|
a1029786ad | ||
|
|
50a658e434 | ||
|
|
9a4f56e4cf | ||
|
|
06634777c1 | ||
|
|
7bd9c13f01 | ||
|
|
107456d605 | ||
|
|
f19c00f141 | ||
|
|
2ba719ebab | ||
|
|
7cb64f55e0 | ||
|
|
201423138e | ||
|
|
787fe8a0bf | ||
|
|
9d0890f92a | ||
|
|
79efac68a3 | ||
|
|
608527502a | ||
|
|
61baf002e9 | ||
|
|
0f89cec5ea | ||
|
|
6482375b72 | ||
|
|
5f2eac7053 | ||
|
|
f61118b8fe | ||
|
|
8ecdc894a8 | ||
|
|
faab493b02 | ||
|
|
d7bd736a85 | ||
|
|
4e3a1aea57 | ||
|
|
afa8b710f4 | ||
|
|
02702c8378 | ||
|
|
863d07be20 | ||
|
|
2b105dd575 | ||
|
|
7902c9321d | ||
|
|
7e2a14ef91 | ||
|
|
7135b32e14 | ||
|
|
acc954a506 | ||
|
|
4e440421af | ||
|
|
a9928d3ef5 | ||
|
|
74b9dd77b6 | ||
|
|
1e8fc36577 | ||
|
|
ec814165dd | ||
|
|
4b4b976fcd | ||
|
|
aad432d693 | ||
|
|
a6807da7f8 | ||
|
|
bf24e28377 | ||
|
|
31d3b7c0e7 | ||
|
|
63fbad01d3 | ||
|
|
d2e91a8a11 | ||
|
|
aeb6431f73 | ||
|
|
98ee87101b | ||
|
|
27b86732c2 | ||
|
|
912bbd86ae | ||
|
|
939975881e | ||
|
|
746d06800a | ||
|
|
bd31c9e10e | ||
|
|
f55083fa62 | ||
|
|
23b429d3d5 | ||
|
|
a87adced3c | ||
|
|
573f58a1c2 | ||
|
|
b7d1e95c50 | ||
|
|
a8fcd8c1c8 | ||
|
|
3307be74d9 | ||
|
|
639736f5fa | ||
|
|
2c7ad4973e | ||
|
|
eefcb09038 | ||
|
|
02a1741a3d | ||
|
|
0f2ce6529f | ||
|
|
9bc6344fa1 | ||
|
|
dbced6638a | ||
|
|
253faa132f | ||
|
|
606d782379 | ||
|
|
ad3d06343d | ||
|
|
ac5f37f40b | ||
|
|
9b5c870bd3 | ||
|
|
2b29e2d92f | ||
|
|
bafd9db24d | ||
|
|
8ff0b64884 | ||
|
|
8de3dba0ff | ||
|
|
d2ec3a1d68 | ||
|
|
9b70f121c0 | ||
|
|
a71a850213 | ||
|
|
95d06df682 | ||
|
|
addd1125d9 | ||
|
|
08cf2b4585 | ||
|
|
052708f6f4 | ||
|
|
554e881a69 | ||
|
|
4e6db19690 | ||
|
|
044936cd5b | ||
|
|
48f9b9418a | ||
|
|
8ac66a4b18 | ||
|
|
b99c08a52c | ||
|
|
61e943beb3 | ||
|
|
9e9d9e6e44 | ||
|
|
f7c6048fa4 | ||
|
|
42ee48f214 | ||
|
|
6acdf3edb3 | ||
|
|
5d63d8e29a | ||
|
|
26ef771c7a | ||
|
|
bf53799e8a | ||
|
|
ee9d976622 | ||
|
|
d5ba463964 | ||
|
|
4762cd33d6 | ||
|
|
9838a27280 | ||
|
|
ef1d287fc0 | ||
|
|
5b81febdab | ||
|
|
c58805afea | ||
|
|
44876d2607 | ||
|
|
8ecdf392ad |
No files matched your search
@@ -8,7 +8,8 @@ name: Release desktop
|
||||
# Wails cannot cross-compile a CGO/WebKit binary, so build/ fans out to one native
|
||||
# runner per platform. Artifacts are minisign-signed (MINISIGN_* secrets), a
|
||||
# latest.json manifest is generated, and everything is published to a GitHub
|
||||
# release and mirrored to R2 (the updater reads R2 first, GitHub as fallback).
|
||||
# release and mirrored to R2 (the updater reads R2 first, then the crash worker
|
||||
# release gateway; GitHub's repository-wide "latest" is reserved for the CLI line).
|
||||
#
|
||||
# The same build/sign/manifest pipeline serves two channels. A `desktop-v*` tag
|
||||
# (or manual stable dispatch) publishes a GitHub release and moves R2's latest/
|
||||
@@ -260,9 +261,9 @@ jobs:
|
||||
working-directory: desktop
|
||||
run: go run ./cmd/sign manifest ../dist "${{ steps.ver.outputs.version }}" "${{ steps.ver.outputs.tag }}"
|
||||
|
||||
# Canary is R2-only and never appears on the GitHub releases page; the mirror
|
||||
# job picks up the signed dist via the canary-dist artifact below. Stable
|
||||
# publishes a GitHub release as usual.
|
||||
# Canary never appears on the GitHub releases page; the mirror job picks up
|
||||
# the signed dist via the canary-dist artifact below. Stable publishes a
|
||||
# GitHub release as usual.
|
||||
- name: Publish GitHub release
|
||||
if: steps.ver.outputs.channel != 'canary'
|
||||
env:
|
||||
@@ -285,7 +286,7 @@ jobs:
|
||||
needs: publish
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read # gh release download
|
||||
contents: write # gh release download + compatibility manifest upload
|
||||
actions: write # dispatch pages.yml to re-bake the site version
|
||||
# Skip cleanly when R2 isn't configured; GitHub release still works as fallback.
|
||||
if: ${{ github.repository_owner == 'esengine' }}
|
||||
@@ -366,6 +367,53 @@ jobs:
|
||||
aws s3 cp assets/ "s3://${R2_BUCKET}/latest/" --recursive --endpoint-url "$ENDPOINT"
|
||||
fi
|
||||
|
||||
- name: Smoke desktop release pointers
|
||||
if: env.HAS_R2 == 'true'
|
||||
env:
|
||||
TAG: ${{ steps.ver.outputs.tag }}
|
||||
VERSION: ${{ steps.ver.outputs.version }}
|
||||
CHANNEL: ${{ steps.ver.outputs.channel }}
|
||||
PRERELEASE: ${{ steps.ver.outputs.prerelease }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
f=assets/latest.json
|
||||
jq -e --arg version "$VERSION" --arg prefix "https://dl.reasonix.io/${TAG}/" '
|
||||
.version == $version and
|
||||
.download_page == "https://reasonix.io/#start" and
|
||||
([.platforms[] | (.url, .sig)] |
|
||||
all(type == "string" and startswith($prefix) and (contains("/releases/latest/") | not)))
|
||||
' "$f" >/dev/null
|
||||
|
||||
curl --retry 5 --retry-delay 2 -fsSL "https://dl.reasonix.io/${TAG}/latest.json" >/tmp/reasonix-desktop-tag-latest.json
|
||||
if [ "$CHANNEL" = "canary" ]; then
|
||||
pointer="canary"
|
||||
elif [ "$PRERELEASE" = "true" ]; then
|
||||
pointer=""
|
||||
else
|
||||
pointer="latest"
|
||||
fi
|
||||
if [ -n "$pointer" ]; then
|
||||
curl --retry 5 --retry-delay 2 -fsSL "https://dl.reasonix.io/${pointer}/latest.json" >/tmp/reasonix-desktop-pointer-latest.json
|
||||
jq -e --arg version "$VERSION" '.version == $version' /tmp/reasonix-desktop-pointer-latest.json >/dev/null
|
||||
fi
|
||||
|
||||
jq -r '.platforms[] | .url, .sig' "$f" | while IFS= read -r asset; do
|
||||
curl --retry 5 --retry-delay 2 -fsSI "$asset" >/dev/null
|
||||
done
|
||||
|
||||
- name: Attach desktop manifest to matching CLI release
|
||||
if: env.HAS_R2 == 'true' && steps.ver.outputs.channel != 'canary' && steps.ver.outputs.prerelease != 'true'
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
VERSION: ${{ steps.ver.outputs.version }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if gh release view "$VERSION" >/dev/null 2>&1; then
|
||||
gh release upload "$VERSION" assets/latest.json --clobber
|
||||
else
|
||||
echo "CLI release $VERSION does not exist yet; release.yml will attach the compatibility latest.json when it publishes."
|
||||
fi
|
||||
|
||||
# Stable release moved R2 latest/ — rebuild the site so its build-time baked
|
||||
# version + JSON-LD follow (site.js's runtime .rxv refresh can't touch first paint / SEO).
|
||||
- name: Refresh site to the new version
|
||||
|
||||
@@ -48,3 +48,23 @@ jobs:
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
HOMEBREW_TAP_TOKEN: ${{ secrets.HOMEBREW_TAP_TOKEN }}
|
||||
|
||||
- name: Attach desktop manifest compatibility asset
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
TAG: ${{ github.ref_name }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
case "$TAG" in
|
||||
*-*)
|
||||
echo "prerelease $TAG — GitHub latest does not move here; skipping desktop manifest compatibility asset"
|
||||
exit 0
|
||||
;;
|
||||
esac
|
||||
curl --retry 5 --retry-delay 2 -fsSL https://dl.reasonix.io/latest/latest.json -o latest.raw.json
|
||||
jq '.download_page = "https://reasonix.io/#start"' latest.raw.json > latest.json
|
||||
jq -e '
|
||||
([.platforms[] | (.url, .sig)] |
|
||||
all(type == "string" and startswith("https://dl.reasonix.io/") and (contains("/releases/latest/") | not)))
|
||||
' latest.json >/dev/null
|
||||
gh release upload "$TAG" latest.json --clobber
|
||||
@@ -2,7 +2,7 @@ VERSION := $(shell git describe --tags --always 2>/dev/null || echo dev)
|
||||
LDFLAGS := -s -w -X main.version=$(VERSION)
|
||||
GOEXE := $(shell go env GOEXE)
|
||||
|
||||
.PHONY: build vet fmt test hooks cross clean
|
||||
.PHONY: build vet fmt test desktop-test desktop-test-short desktop-test-times hooks cross clean
|
||||
|
||||
build:
|
||||
CGO_ENABLED=0 go build -ldflags "$(LDFLAGS)" -o bin/reasonix$(GOEXE) ./cmd/reasonix
|
||||
@@ -17,6 +17,15 @@ fmt:
|
||||
test:
|
||||
go test ./...
|
||||
|
||||
desktop-test:
|
||||
cd desktop && go test .
|
||||
|
||||
desktop-test-short:
|
||||
cd desktop && go test -short .
|
||||
|
||||
desktop-test-times:
|
||||
cd desktop && go test -count=1 -json . | python3 ../scripts/desktop-test-times.py
|
||||
|
||||
hooks:
|
||||
@git config core.hooksPath .githooks
|
||||
@echo "installed: core.hooksPath -> .githooks (pre-push runs go vet)"
|
||||
|
||||
@@ -33,12 +33,6 @@
|
||||
<a href="https://discord.gg/XF78rEME2D"><img src="https://img.shields.io/badge/discord-join-5865F2.svg?style=flat-square&labelColor=161b22&logo=discord&logoColor=white" alt="Discord"/></a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://oosmetrics.com/repo/esengine/reasonix"><img src="https://api.oosmetrics.com/api/v1/badge/achievement/9e931d80-2050-4b10-902e-44970cc133ad.svg" alt="oosmetrics — Top 2 in Agents by velocity"/></a>
|
||||
<a href="https://oosmetrics.com/repo/esengine/reasonix"><img src="https://api.oosmetrics.com/api/v1/badge/achievement/556d94b3-61b7-486b-baf2-888b9327deab.svg" alt="oosmetrics — Top 3 in LLMs by velocity"/></a>
|
||||
<a href="https://oosmetrics.com/repo/esengine/reasonix"><img src="https://api.oosmetrics.com/api/v1/badge/achievement/0f457d4c-efca-4d15-ad2b-139691ff342c.svg" alt="oosmetrics — Top 3 in CLI by velocity"/></a>
|
||||
</p>
|
||||
|
||||
<br/>
|
||||
|
||||
<h3 align="center">A DeepSeek-native AI coding agent for your terminal.</h3>
|
||||
|
||||
@@ -33,12 +33,6 @@
|
||||
<a href="https://discord.gg/XF78rEME2D"><img src="https://img.shields.io/badge/discord-join-5865F2.svg?style=flat-square&labelColor=161b22&logo=discord&logoColor=white" alt="Discord"/></a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://oosmetrics.com/repo/esengine/reasonix"><img src="https://api.oosmetrics.com/api/v1/badge/achievement/9e931d80-2050-4b10-902e-44970cc133ad.svg" alt="oosmetrics — Top 2 in Agents by velocity"/></a>
|
||||
<a href="https://oosmetrics.com/repo/esengine/reasonix"><img src="https://api.oosmetrics.com/api/v1/badge/achievement/556d94b3-61b7-486b-baf2-888b9327deab.svg" alt="oosmetrics — Top 3 in LLMs by velocity"/></a>
|
||||
<a href="https://oosmetrics.com/repo/esengine/reasonix"><img src="https://api.oosmetrics.com/api/v1/badge/achievement/0f457d4c-efca-4d15-ad2b-139691ff342c.svg" alt="oosmetrics — Top 3 in CLI by velocity"/></a>
|
||||
</p>
|
||||
|
||||
<br/>
|
||||
|
||||
<h3 align="center">面向终端的 DeepSeek 原生 AI coding agent。</h3>
|
||||
|
||||
+10
-40
@@ -10,6 +10,8 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
"reasonix/internal/shellparse"
|
||||
)
|
||||
|
||||
type diffOpts struct {
|
||||
@@ -546,57 +548,25 @@ func failingTestNames(out string) []string {
|
||||
}
|
||||
|
||||
func runTests(repo, testCmd string, pkgs []string) (bool, string) {
|
||||
fields := splitShellFields(testCmd)
|
||||
test, err := shellparse.ParseStaticCommand(testCmd, shellparse.StaticCommandPolicy{AllowEnvAssignments: true, AllowStderrToStdout: true})
|
||||
if err != nil {
|
||||
return false, "invalid test command: " + err.Error()
|
||||
}
|
||||
fields := test.Argv
|
||||
if len(fields) == 0 {
|
||||
fields = []string{"go", "test"}
|
||||
}
|
||||
args := append(fields[1:], pkgs...)
|
||||
cmd := exec.Command(fields[0], args...)
|
||||
cmd.Dir = repo
|
||||
if len(test.Env) > 0 {
|
||||
cmd.Env = append(os.Environ(), test.Env...)
|
||||
}
|
||||
cmd.WaitDelay = 5 * time.Minute // bound the wait if `go test` hangs
|
||||
out, err := cmd.CombinedOutput()
|
||||
return err == nil, string(out)
|
||||
}
|
||||
|
||||
// splitShellFields splits on whitespace, honoring single/double-quoted spans and stripping the quotes.
|
||||
func splitShellFields(s string) []string {
|
||||
var out []string
|
||||
var cur strings.Builder
|
||||
inSingle, inDouble := false, false
|
||||
for i := 0; i < len(s); i++ {
|
||||
c := s[i]
|
||||
switch {
|
||||
case inSingle:
|
||||
if c == '\'' {
|
||||
inSingle = false
|
||||
} else {
|
||||
cur.WriteByte(c)
|
||||
}
|
||||
case inDouble:
|
||||
if c == '"' {
|
||||
inDouble = false
|
||||
} else {
|
||||
cur.WriteByte(c)
|
||||
}
|
||||
case c == '\'':
|
||||
inSingle = true
|
||||
case c == '"':
|
||||
inDouble = true
|
||||
case c == ' ' || c == '\t' || c == '\n':
|
||||
if cur.Len() > 0 {
|
||||
out = append(out, cur.String())
|
||||
cur.Reset()
|
||||
}
|
||||
default:
|
||||
cur.WriteByte(c)
|
||||
}
|
||||
}
|
||||
if cur.Len() > 0 {
|
||||
out = append(out, cur.String())
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// changedGoFiles lists .go files changed by base...HEAD, excluding *_test.go
|
||||
// when includeTests is false (we want the source under test).
|
||||
func changedGoFiles(repo, base string, includeTests bool) []string {
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestRunTestsAllowsStaticEnvAssignment(t *testing.T) {
|
||||
repo := t.TempDir()
|
||||
writeE2EFile(t, filepath.Join(repo, "go.mod"), "module example.com/e2ebenchtest\n\ngo 1.23\n")
|
||||
writeE2EFile(t, filepath.Join(repo, "env_test.go"), `package e2ebenchtest
|
||||
|
||||
import (
|
||||
"os"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestEnv(t *testing.T) {
|
||||
if got := os.Getenv("REASONIX_E2E_ENV"); got != "ok" {
|
||||
t.Fatalf("REASONIX_E2E_ENV = %q", got)
|
||||
}
|
||||
}
|
||||
`)
|
||||
|
||||
ok, out := runTests(repo, "GOWORK=off REASONIX_E2E_ENV=ok go test", []string{"./..."})
|
||||
if !ok {
|
||||
t.Fatalf("runTests failed:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunTestsRejectsDynamicEnvAssignment(t *testing.T) {
|
||||
ok, out := runTests(t.TempDir(), "REASONIX_E2E_ENV=$(echo ok) go test", []string{"./..."})
|
||||
if ok {
|
||||
t.Fatal("runTests accepted dynamic env assignment")
|
||||
}
|
||||
if !strings.Contains(out, "invalid test command: shell expansion") {
|
||||
t.Fatalf("output = %q, want shell expansion rejection", out)
|
||||
}
|
||||
}
|
||||
|
||||
func writeE2EFile(t *testing.T, path, content string) {
|
||||
t.Helper()
|
||||
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
|
||||
t.Fatalf("write %s: %v", path, err)
|
||||
}
|
||||
}
|
||||
+27
-4
@@ -62,6 +62,24 @@ In a plain browser the native bindings are absent, so `bridge.ts` falls back to
|
||||
exact same event contract — so layout, streaming, markdown, tool cards, and the
|
||||
diff seam can all be built without rebuilding Go.
|
||||
|
||||
## Test
|
||||
|
||||
The desktop package is a nested Go module, so parent `go test ./...` does not run
|
||||
it. Use the full lane before merging desktop changes, and the short lane for fast
|
||||
local feedback:
|
||||
|
||||
```sh
|
||||
make desktop-test # cd desktop && go test .
|
||||
make desktop-test-short # skips slow desktop integration/e2e checks
|
||||
```
|
||||
|
||||
To find the next bottleneck, rank individual test cases from the JSON stream:
|
||||
|
||||
```sh
|
||||
make desktop-test-times
|
||||
# or: cd desktop && go test -count=1 -json . | python3 ../scripts/desktop-test-times.py
|
||||
```
|
||||
|
||||
### Frontend UI review checklist
|
||||
|
||||
For anchored menus, dropdowns, tooltips, and other portaled UI, review both the
|
||||
@@ -101,7 +119,9 @@ Desktop releases ride their own tag namespace, `desktop-v<semver>` (plain `v*`
|
||||
tags are the CLI release). Pushing one triggers `.github/workflows/release-desktop.yml`,
|
||||
which builds on a native runner per platform (Wails can't cross-compile a
|
||||
CGO/WebKit binary), packages each artifact, signs it with minisign, generates a
|
||||
`latest.json` manifest, publishes a GitHub release, and mirrors everything to R2.
|
||||
`latest.json` manifest, publishes a GitHub release, mirrors everything to R2,
|
||||
and attaches the current desktop manifest to the matching CLI release for old
|
||||
clients that still ask GitHub's repository-wide `latest` release for it.
|
||||
The Linux artifact links against WebKitGTK 4.1 (`-tags webkit2_41`), so it needs
|
||||
`libwebkit2gtk-4.1-0` at runtime — present by default on Ubuntu 22.04+, Fedora 40+.
|
||||
|
||||
@@ -109,9 +129,12 @@ The Linux artifact links against WebKitGTK 4.1 (`-tags webkit2_41`), so it needs
|
||||
git tag desktop-v1.1.0 && git push origin desktop-v1.1.0
|
||||
```
|
||||
|
||||
The app checks `latest.json` on startup (R2 first, GitHub as fallback) and shows
|
||||
an update banner when a newer version is published; **Settings → Software update**
|
||||
has a manual check. Self-update behavior by platform:
|
||||
The app checks `latest.json` on startup (R2 first, then the
|
||||
`crash.reasonix.io` desktop release gateway) and shows an update banner when a
|
||||
newer version is published; **Settings → Software update** has a manual check.
|
||||
The gateway resolves only the desktop `desktop-v*` release line and never uses
|
||||
GitHub's repository-wide `/releases/latest` shortcut, because plain `v*` tags are
|
||||
the CLI release line. Self-update behavior by platform:
|
||||
|
||||
- **Linux / Windows** — download, verify the minisign signature, then update in
|
||||
place: Linux replaces the binary and relaunches; Windows runs the per-user NSIS
|
||||
|
||||
+1050
-372
File diff suppressed because it is too large.
Load diff
@@ -2,6 +2,7 @@ package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
@@ -173,6 +174,229 @@ func TestAutosaveFailureRetriesAndRecoversOnNextTurnDone(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDesktopSnapshotConflictRecoveryUpdatesTabAndProjectTree(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
|
||||
root := globalTabWorkspaceRoot()
|
||||
dir := desktopSessionDir(root)
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
t.Fatalf("mkdir sessions: %v", err)
|
||||
}
|
||||
originalPath := filepath.Join(dir, "session.jsonl")
|
||||
originalTopic := "topic_original"
|
||||
if err := setTopicTitle("", originalTopic, "Original"); err != nil {
|
||||
t.Fatalf("set original topic title: %v", err)
|
||||
}
|
||||
current := agent.NewSession("sys")
|
||||
current.Add(provider.Message{Role: provider.RoleUser, Content: "first"})
|
||||
current.Add(provider.Message{Role: provider.RoleAssistant, Content: "one"})
|
||||
current.Add(provider.Message{Role: provider.RoleUser, Content: "disk second"})
|
||||
if err := current.Save(originalPath); err != nil {
|
||||
t.Fatalf("Save current: %v", err)
|
||||
}
|
||||
if err := agent.SaveBranchMeta(originalPath, agent.BranchMeta{
|
||||
Scope: "global",
|
||||
TopicID: originalTopic,
|
||||
TopicTitle: "Original",
|
||||
Preview: "first",
|
||||
Turns: 2,
|
||||
SchemaVersion: agent.BranchMetaCountsVersion,
|
||||
}); err != nil {
|
||||
t.Fatalf("SaveBranchMeta original: %v", err)
|
||||
}
|
||||
|
||||
staleSess := agent.NewSession("sys")
|
||||
staleSess.Add(provider.Message{Role: provider.RoleUser, Content: "first"})
|
||||
staleSess.Add(provider.Message{Role: provider.RoleAssistant, Content: "one"})
|
||||
staleSess.Add(provider.Message{Role: provider.RoleUser, Content: "local second"})
|
||||
staleExec := agent.New(stubProvider{}, tool.NewRegistry(), staleSess, agent.Options{}, event.Discard)
|
||||
app := &App{
|
||||
tabs: map[string]*WorkspaceTab{},
|
||||
detachedSessions: map[string]*WorkspaceTab{},
|
||||
activeTabID: "recovery_tab",
|
||||
}
|
||||
tab := &WorkspaceTab{
|
||||
ID: "recovery_tab",
|
||||
Scope: "global",
|
||||
WorkspaceRoot: root,
|
||||
TopicID: originalTopic,
|
||||
TopicTitle: "Original",
|
||||
SessionPath: originalPath,
|
||||
Ready: true,
|
||||
model: "test-model",
|
||||
disabledMCP: map[string]ServerView{},
|
||||
}
|
||||
tab.sink = &tabEventSink{tabID: tab.ID, app: app}
|
||||
tab.Ctrl = control.New(control.Options{
|
||||
Executor: staleExec,
|
||||
SessionDir: dir,
|
||||
SessionPath: originalPath,
|
||||
Label: "test",
|
||||
Sink: tab.sink,
|
||||
SessionRecoveryMeta: app.tabSessionRecoveryMeta(tab),
|
||||
OnSessionRecovered: app.handleTabSessionRecovered(tab),
|
||||
})
|
||||
app.tabs[tab.ID] = tab
|
||||
|
||||
if err := tab.Ctrl.Snapshot(); err != nil {
|
||||
t.Fatalf("Snapshot: %v", err)
|
||||
}
|
||||
recoveryPath := tab.Ctrl.SessionPath()
|
||||
if recoveryPath == "" || recoveryPath == originalPath {
|
||||
t.Fatalf("recovery path = %q, want distinct path", recoveryPath)
|
||||
}
|
||||
if tab.SessionPath != recoveryPath {
|
||||
t.Fatalf("tab session path = %q, want recovery path %q", tab.SessionPath, recoveryPath)
|
||||
}
|
||||
if tab.TopicID != originalTopic {
|
||||
t.Fatalf("tab topic ID = %q, want original topic %q", tab.TopicID, originalTopic)
|
||||
}
|
||||
meta, ok, err := agent.LoadBranchMeta(recoveryPath)
|
||||
if err != nil || !ok {
|
||||
t.Fatalf("LoadBranchMeta recovery ok=%v err=%v", ok, err)
|
||||
}
|
||||
if !meta.Recovered || meta.TopicID != tab.TopicID || meta.TopicTitle != tab.TopicTitle {
|
||||
t.Fatalf("recovery meta = %+v, tab topic=%q/%q", meta, tab.TopicID, tab.TopicTitle)
|
||||
}
|
||||
tabMeta := app.tabMeta(tab, true)
|
||||
if !tabMeta.Recovered || tabMeta.RecoveryDigest != meta.RecoveryDigest || tabMeta.RecoveryParentID != string(meta.ParentID) {
|
||||
t.Fatalf("tab recovery meta = %+v, want digest %q parent %q", tabMeta, meta.RecoveryDigest, meta.ParentID)
|
||||
}
|
||||
nodes := app.ListProjectTree()
|
||||
foundOriginal := false
|
||||
var walk func([]ProjectNode)
|
||||
walk = func(list []ProjectNode) {
|
||||
for _, node := range list {
|
||||
if node.Recovered {
|
||||
t.Fatalf("project tree should hide recovery metadata, got node %+v", node)
|
||||
}
|
||||
if node.TopicID == originalTopic {
|
||||
foundOriginal = true
|
||||
}
|
||||
walk(node.Children)
|
||||
}
|
||||
}
|
||||
walk(nodes)
|
||||
if !foundOriginal {
|
||||
t.Fatalf("project tree did not include original topic %q: %#v", originalTopic, nodes)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDesktopSnapshotConflictRecoveryRequiresRecoveryLease(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
|
||||
root := globalTabWorkspaceRoot()
|
||||
dir := desktopSessionDir(root)
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
t.Fatalf("mkdir sessions: %v", err)
|
||||
}
|
||||
originalPath := filepath.Join(dir, "session.jsonl")
|
||||
current := agent.NewSession("sys")
|
||||
current.Add(provider.Message{Role: provider.RoleUser, Content: "first"})
|
||||
current.Add(provider.Message{Role: provider.RoleAssistant, Content: "one"})
|
||||
current.Add(provider.Message{Role: provider.RoleUser, Content: "disk second"})
|
||||
if err := current.Save(originalPath); err != nil {
|
||||
t.Fatalf("Save current: %v", err)
|
||||
}
|
||||
|
||||
staleSess := agent.NewSession("sys")
|
||||
staleSess.Add(provider.Message{Role: provider.RoleUser, Content: "first"})
|
||||
staleSess.Add(provider.Message{Role: provider.RoleAssistant, Content: "one"})
|
||||
staleSess.Add(provider.Message{Role: provider.RoleUser, Content: "local second"})
|
||||
recovery, err := staleSess.SaveRecoveryBranch(agent.RecoveryBranchOptions{
|
||||
OriginalPath: originalPath,
|
||||
BranchMeta: agent.BranchMeta{
|
||||
Name: agent.RecoveryBranchDefaultName,
|
||||
Scope: "global",
|
||||
TopicID: "topic_recovery",
|
||||
TopicTitle: "Recovery",
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("SaveRecoveryBranch: %v", err)
|
||||
}
|
||||
lease, err := agent.TryAcquireSessionLease(recovery.Path)
|
||||
if err != nil {
|
||||
t.Fatalf("TryAcquireSessionLease recovery: %v", err)
|
||||
}
|
||||
defer lease.Release()
|
||||
|
||||
staleExec := agent.New(stubProvider{}, tool.NewRegistry(), staleSess, agent.Options{}, event.Discard)
|
||||
runtimeEvents := make(chan runtimeEventEnvelope, 4)
|
||||
app := &App{
|
||||
ctx: context.Background(),
|
||||
tabs: map[string]*WorkspaceTab{},
|
||||
detachedSessions: map[string]*WorkspaceTab{},
|
||||
activeTabID: "recovery_tab",
|
||||
}
|
||||
app.runtimeEvents.emit = func(ctx context.Context, name string, payload ...interface{}) {
|
||||
runtimeEvents <- runtimeEventEnvelope{
|
||||
ctx: ctx,
|
||||
name: name,
|
||||
payload: append([]interface{}(nil), payload...),
|
||||
}
|
||||
}
|
||||
tab := &WorkspaceTab{
|
||||
ID: "recovery_tab",
|
||||
Scope: "global",
|
||||
WorkspaceRoot: root,
|
||||
TopicID: "topic_original",
|
||||
TopicTitle: "Original",
|
||||
SessionPath: originalPath,
|
||||
Ready: true,
|
||||
model: "test-model",
|
||||
disabledMCP: map[string]ServerView{},
|
||||
}
|
||||
tab.sink = &tabEventSink{tabID: tab.ID, app: app}
|
||||
tab.Ctrl = control.New(control.Options{
|
||||
Executor: staleExec,
|
||||
SessionDir: dir,
|
||||
SessionPath: originalPath,
|
||||
Label: "test",
|
||||
Sink: tab.sink,
|
||||
SessionRecoveryMeta: app.tabSessionRecoveryMeta(tab),
|
||||
OnSessionRecovered: app.handleTabSessionRecovered(tab),
|
||||
})
|
||||
app.tabs[tab.ID] = tab
|
||||
|
||||
err = tab.Ctrl.Snapshot()
|
||||
if !errors.Is(err, agent.ErrSessionLeaseHeld) {
|
||||
t.Fatalf("Snapshot err = %v, want ErrSessionLeaseHeld", err)
|
||||
}
|
||||
if got := tab.Ctrl.SessionPath(); got != originalPath {
|
||||
t.Fatalf("controller session path = %q, want original %q", got, originalPath)
|
||||
}
|
||||
if tab.SessionPath != originalPath {
|
||||
t.Fatalf("tab session path = %q, want original %q", tab.SessionPath, originalPath)
|
||||
}
|
||||
if tab.TopicID != "topic_original" {
|
||||
t.Fatalf("tab topic ID = %q, want original topic", tab.TopicID)
|
||||
}
|
||||
|
||||
deadline := time.After(time.Second)
|
||||
for {
|
||||
select {
|
||||
case emitted := <-runtimeEvents:
|
||||
if emitted.name != "session:recovery-failed" {
|
||||
continue
|
||||
}
|
||||
if len(emitted.payload) != 1 {
|
||||
t.Fatalf("session:recovery-failed payload count = %d, want 1", len(emitted.payload))
|
||||
}
|
||||
failed, ok := emitted.payload[0].(sessionRecoveryFailedEvent)
|
||||
if !ok {
|
||||
t.Fatalf("session:recovery-failed payload type = %T, want sessionRecoveryFailedEvent", emitted.payload[0])
|
||||
}
|
||||
if failed.Reason != "lease_held" {
|
||||
t.Fatalf("session:recovery-failed reason = %q, want lease_held", failed.Reason)
|
||||
}
|
||||
return
|
||||
case <-deadline:
|
||||
t.Fatal("session:recovery-failed event was not emitted")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetActiveTabBlocksWhenCurrentSessionCannotPersist(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "blocked.jsonl")
|
||||
if err := os.Mkdir(path, 0o755); err != nil {
|
||||
|
||||
@@ -3,6 +3,8 @@ package main
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -166,6 +168,60 @@ func TestEnsureBlankTabReusesIndexedTopicWithEmptyStub(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureBlankTabStoresCreatedAt(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
|
||||
app := NewApp()
|
||||
before := time.Now().UnixMilli()
|
||||
meta, err := app.EnsureBlankTab("global", "")
|
||||
after := time.Now().UnixMilli()
|
||||
if err != nil {
|
||||
t.Fatalf("EnsureBlankTab: %v", err)
|
||||
}
|
||||
|
||||
createdAt := loadTopicCreatedAt("", meta.TopicID)
|
||||
if createdAt < before || createdAt > after {
|
||||
t.Fatalf("createdAt = %d, want between %d and %d", createdAt, before, after)
|
||||
}
|
||||
|
||||
nodes := app.ListProjectTree()
|
||||
if len(nodes) != 1 || nodes[0].Kind != "global_folder" || len(nodes[0].Children) != 1 {
|
||||
t.Fatalf("project tree = %#v, want Global with one topic", nodes)
|
||||
}
|
||||
if got := nodes[0].Children[0].CreatedAt; got != createdAt {
|
||||
t.Fatalf("project tree createdAt = %d, want %d", got, createdAt)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureBlankTabRepairsMissingCreatedAtForReusedTopic(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
|
||||
const topicID = "topic_20260704-104018_deadbeef"
|
||||
if err := setTopicTitleWithSource("", topicID, defaultTopicTitle, topicTitleSourceAuto); err != nil {
|
||||
t.Fatalf("set topic title: %v", err)
|
||||
}
|
||||
if err := prependTopicInProjectsFile("", topicID, false); err != nil {
|
||||
t.Fatalf("prepend topic: %v", err)
|
||||
}
|
||||
if got := loadTopicCreatedAt("", topicID); got != 0 {
|
||||
t.Fatalf("createdAt before reuse = %d, want 0", got)
|
||||
}
|
||||
|
||||
app := NewApp()
|
||||
meta, err := app.EnsureBlankTab("global", "")
|
||||
if err != nil {
|
||||
t.Fatalf("EnsureBlankTab: %v", err)
|
||||
}
|
||||
if meta.TopicID != topicID {
|
||||
t.Fatalf("EnsureBlankTab topic = %q, want reused topic %q", meta.TopicID, topicID)
|
||||
}
|
||||
|
||||
expected := time.Date(2026, 7, 4, 10, 40, 18, 0, time.UTC).UnixMilli()
|
||||
if got := loadTopicCreatedAt("", topicID); got != expected {
|
||||
t.Fatalf("repaired createdAt = %d, want %d", got, expected)
|
||||
}
|
||||
}
|
||||
|
||||
// EnsureBlankTab reuses an already-open project-scoped blank tab.
|
||||
|
||||
func TestEnsureBlankTabCreatesOneBlankPerProject(t *testing.T) {
|
||||
@@ -189,6 +245,221 @@ func TestEnsureBlankTabCreatesOneBlankPerProject(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureBlankTabStartsProjectRuntimeWithCurrentWorkspacePrompt(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
|
||||
projectA := robustTempDir(t)
|
||||
projectB := robustTempDir(t)
|
||||
if err := addProject(projectA, "Project A"); err != nil {
|
||||
t.Fatalf("add project A: %v", err)
|
||||
}
|
||||
if err := addProject(projectB, "Project B"); err != nil {
|
||||
t.Fatalf("add project B: %v", err)
|
||||
}
|
||||
|
||||
app := NewApp()
|
||||
first, err := app.EnsureBlankTab("project", projectA)
|
||||
if err != nil {
|
||||
t.Fatalf("EnsureBlankTab(project A): %v", err)
|
||||
}
|
||||
tabA := waitForTabReady(t, app, first.ID)
|
||||
if got := normalizeProjectRoot(tabA.Ctrl.WorkspaceRoot()); got != normalizeProjectRoot(projectA) {
|
||||
t.Fatalf("project A controller workspace root = %q, want %q", got, normalizeProjectRoot(projectA))
|
||||
}
|
||||
|
||||
second, err := app.EnsureBlankTab("project", projectB)
|
||||
if err != nil {
|
||||
t.Fatalf("EnsureBlankTab(project B): %v", err)
|
||||
}
|
||||
if second.ID == first.ID {
|
||||
t.Fatalf("EnsureBlankTab reused project A tab %q for project B", second.ID)
|
||||
}
|
||||
tabB := waitForTabReady(t, app, second.ID)
|
||||
|
||||
if got := normalizeProjectRoot(tabB.WorkspaceRoot); got != normalizeProjectRoot(projectB) {
|
||||
t.Fatalf("project B tab workspace root = %q, want %q", got, normalizeProjectRoot(projectB))
|
||||
}
|
||||
if got := normalizeProjectRoot(tabB.Ctrl.WorkspaceRoot()); got != normalizeProjectRoot(projectB) {
|
||||
t.Fatalf("project B controller workspace root = %q, want %q", got, normalizeProjectRoot(projectB))
|
||||
}
|
||||
if !sameDesktopPath(tabB.Ctrl.SessionDir(), desktopSessionDir(projectB)) {
|
||||
t.Fatalf("project B controller session dir = %q, want %q", tabB.Ctrl.SessionDir(), desktopSessionDir(projectB))
|
||||
}
|
||||
if !sameDesktopPath(filepath.Dir(tabB.Ctrl.SessionPath()), desktopSessionDir(projectB)) {
|
||||
t.Fatalf("project B controller session path = %q, want under %q", tabB.Ctrl.SessionPath(), desktopSessionDir(projectB))
|
||||
}
|
||||
sys := systemPromptFrom(tabB.Ctrl.History())
|
||||
if !strings.Contains(sys, "Current workspace: "+strconv.Quote(projectB)) {
|
||||
t.Fatalf("project B system prompt missing current workspace %q:\n%s", projectB, sys)
|
||||
}
|
||||
if strings.Contains(sys, "Current workspace: "+strconv.Quote(projectA)) {
|
||||
t.Fatalf("project B system prompt retained project A workspace %q:\n%s", projectA, sys)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBlankTabSessionPathRejectsOtherProjectWorkspace(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
|
||||
projectA := robustTempDir(t)
|
||||
projectB := robustTempDir(t)
|
||||
pathA, err := createEmptySessionFile(desktopSessionDir(projectA), "test-model")
|
||||
if err != nil {
|
||||
t.Fatalf("create project A empty session: %v", err)
|
||||
}
|
||||
tab := &WorkspaceTab{
|
||||
ID: "blank-project-b",
|
||||
Scope: "project",
|
||||
WorkspaceRoot: projectB,
|
||||
SessionPath: pathA,
|
||||
}
|
||||
|
||||
if blankTabSessionPathHasNoContent(tab) {
|
||||
t.Fatalf("blank tab treated session %q from project A as reusable for project B %q", pathA, projectB)
|
||||
}
|
||||
}
|
||||
|
||||
func TestForkKeepsProjectWorkspacePrompt(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
|
||||
projectA := robustTempDir(t)
|
||||
projectB := robustTempDir(t)
|
||||
if err := addProject(projectA, "Project A"); err != nil {
|
||||
t.Fatalf("add project A: %v", err)
|
||||
}
|
||||
if err := addProject(projectB, "Project B"); err != nil {
|
||||
t.Fatalf("add project B: %v", err)
|
||||
}
|
||||
|
||||
app := NewApp()
|
||||
first, err := app.EnsureBlankTab("project", projectA)
|
||||
if err != nil {
|
||||
t.Fatalf("EnsureBlankTab(project A): %v", err)
|
||||
}
|
||||
waitForTabReady(t, app, first.ID)
|
||||
|
||||
second, err := app.EnsureBlankTab("project", projectB)
|
||||
if err != nil {
|
||||
t.Fatalf("EnsureBlankTab(project B): %v", err)
|
||||
}
|
||||
tabB := waitForTabReady(t, app, second.ID)
|
||||
ctrl := installStubControllerWithCurrentPrompt(t, app, tabB)
|
||||
turn := submitStubTurnAndWaitForCheckpoint(t, ctrl, "project B turn")
|
||||
|
||||
forked, err := app.Fork(turn)
|
||||
if err != nil {
|
||||
t.Fatalf("Fork: %v", err)
|
||||
}
|
||||
if forked.ID == "" || forked.ID == second.ID {
|
||||
t.Fatalf("forked tab ID = %q, want a fresh tab distinct from %q", forked.ID, second.ID)
|
||||
}
|
||||
forkTab := waitForTabReady(t, app, forked.ID)
|
||||
if got := normalizeProjectRoot(forkTab.WorkspaceRoot); got != normalizeProjectRoot(projectB) {
|
||||
t.Fatalf("fork tab workspace root = %q, want %q", got, normalizeProjectRoot(projectB))
|
||||
}
|
||||
if got := normalizeProjectRoot(forkTab.Ctrl.WorkspaceRoot()); got != normalizeProjectRoot(projectB) {
|
||||
t.Fatalf("fork controller workspace root = %q, want %q", got, normalizeProjectRoot(projectB))
|
||||
}
|
||||
sys := systemPromptFrom(forkTab.Ctrl.History())
|
||||
if !strings.Contains(sys, "Current workspace: "+strconv.Quote(projectB)) {
|
||||
t.Fatalf("fork system prompt missing project B workspace %q:\n%s", projectB, sys)
|
||||
}
|
||||
if strings.Contains(sys, "Current workspace: "+strconv.Quote(projectA)) {
|
||||
t.Fatalf("fork system prompt retained project A workspace %q:\n%s", projectA, sys)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRewindKeepsProjectWorkspacePrompt(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
|
||||
projectA := robustTempDir(t)
|
||||
projectB := robustTempDir(t)
|
||||
if err := addProject(projectA, "Project A"); err != nil {
|
||||
t.Fatalf("add project A: %v", err)
|
||||
}
|
||||
if err := addProject(projectB, "Project B"); err != nil {
|
||||
t.Fatalf("add project B: %v", err)
|
||||
}
|
||||
|
||||
app := NewApp()
|
||||
first, err := app.EnsureBlankTab("project", projectA)
|
||||
if err != nil {
|
||||
t.Fatalf("EnsureBlankTab(project A): %v", err)
|
||||
}
|
||||
waitForTabReady(t, app, first.ID)
|
||||
|
||||
second, err := app.EnsureBlankTab("project", projectB)
|
||||
if err != nil {
|
||||
t.Fatalf("EnsureBlankTab(project B): %v", err)
|
||||
}
|
||||
tabB := waitForTabReady(t, app, second.ID)
|
||||
ctrl := installStubControllerWithCurrentPrompt(t, app, tabB)
|
||||
turn := submitStubTurnAndWaitForCheckpoint(t, ctrl, "project B turn")
|
||||
|
||||
if err := app.Rewind(turn, "conversation"); err != nil {
|
||||
t.Fatalf("Rewind: %v", err)
|
||||
}
|
||||
if got := normalizeProjectRoot(tabB.Ctrl.WorkspaceRoot()); got != normalizeProjectRoot(projectB) {
|
||||
t.Fatalf("rewound controller workspace root = %q, want %q", got, normalizeProjectRoot(projectB))
|
||||
}
|
||||
sys := systemPromptFrom(tabB.Ctrl.History())
|
||||
if !strings.Contains(sys, "Current workspace: "+strconv.Quote(projectB)) {
|
||||
t.Fatalf("rewound system prompt missing project B workspace %q:\n%s", projectB, sys)
|
||||
}
|
||||
if strings.Contains(sys, "Current workspace: "+strconv.Quote(projectA)) {
|
||||
t.Fatalf("rewound system prompt retained project A workspace %q:\n%s", projectA, sys)
|
||||
}
|
||||
}
|
||||
|
||||
func installStubControllerWithCurrentPrompt(t *testing.T, app *App, tab *WorkspaceTab) *control.Controller {
|
||||
t.Helper()
|
||||
if tab == nil || tab.Ctrl == nil {
|
||||
t.Fatal("tab controller is required")
|
||||
}
|
||||
sys := systemPromptFrom(tab.Ctrl.History())
|
||||
if strings.TrimSpace(sys) == "" {
|
||||
t.Fatal("tab controller did not expose a system prompt")
|
||||
}
|
||||
sessionDir := tab.Ctrl.SessionDir()
|
||||
sessionPath := tab.Ctrl.SessionPath()
|
||||
workspaceRoot := tab.Ctrl.WorkspaceRoot()
|
||||
label := tab.Ctrl.Label()
|
||||
tab.Ctrl.Close()
|
||||
|
||||
sess := agent.NewSession(sys)
|
||||
ag := agent.New(stubProvider{}, tool.NewRegistry(), sess, agent.Options{}, event.Discard)
|
||||
ctrl := control.New(control.Options{
|
||||
Runner: ag,
|
||||
Executor: ag,
|
||||
SessionDir: sessionDir,
|
||||
SessionPath: sessionPath,
|
||||
WorkspaceRoot: workspaceRoot,
|
||||
Label: label,
|
||||
SystemPrompt: sys,
|
||||
Sink: event.Discard,
|
||||
})
|
||||
tab.Ctrl = ctrl
|
||||
app.bindControllerDisplayRecorder(ctrl)
|
||||
return ctrl
|
||||
}
|
||||
|
||||
func submitStubTurnAndWaitForCheckpoint(t *testing.T, ctrl control.SessionAPI, input string) int {
|
||||
t.Helper()
|
||||
ctrl.SubmitUserTurn(input, input)
|
||||
waitNotRunning(t, ctrl)
|
||||
|
||||
deadline := time.Now().Add(time.Second)
|
||||
for {
|
||||
checkpoints := ctrl.Checkpoints()
|
||||
if len(checkpoints) > 0 {
|
||||
return checkpoints[len(checkpoints)-1].Turn
|
||||
}
|
||||
if time.Now().After(deadline) {
|
||||
t.Fatal("controller did not record a checkpoint")
|
||||
}
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureBlankTabResetsReusableAutoTopicTitle(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
|
||||
|
||||
+1759
-3
File diff suppressed because it is too large.
Load diff
@@ -15,6 +15,7 @@ import (
|
||||
"reasonix/internal/bot"
|
||||
"reasonix/internal/bot/feishu"
|
||||
"reasonix/internal/bot/weixin"
|
||||
"reasonix/internal/botruntime"
|
||||
"reasonix/internal/config"
|
||||
)
|
||||
|
||||
@@ -48,6 +49,7 @@ type BotConnectionView struct {
|
||||
Model string `json:"model"`
|
||||
ToolApprovalMode string `json:"toolApprovalMode"`
|
||||
WorkspaceRoot string `json:"workspaceRoot"`
|
||||
Access BotAccessView `json:"access"`
|
||||
Credential BotConnectionCredentialView `json:"credential"`
|
||||
SessionMappings []BotConnectionSessionMappingView `json:"sessionMappings"`
|
||||
LastError string `json:"lastError"`
|
||||
@@ -136,12 +138,19 @@ func (a *App) StartBotConnectionInstall(provider, domain string) (BotInstallStar
|
||||
|
||||
func (a *App) PollBotConnectionInstall(installID string) (BotInstallPollResult, error) {
|
||||
installID = strings.TrimSpace(installID)
|
||||
// Copy the session under a.mu: overlapping polls of the same install can
|
||||
// race the locked PollDomain upgrade below with unlocked field reads.
|
||||
a.mu.RLock()
|
||||
session := a.botInstalls[installID]
|
||||
sessionPtr := a.botInstalls[installID]
|
||||
var sessionCopy botInstallSession
|
||||
if sessionPtr != nil {
|
||||
sessionCopy = *sessionPtr
|
||||
}
|
||||
a.mu.RUnlock()
|
||||
if session == nil {
|
||||
if sessionPtr == nil {
|
||||
return BotInstallPollResult{Error: "install session not found"}, nil
|
||||
}
|
||||
session := &sessionCopy
|
||||
if time.Now().After(session.ExpireAt) {
|
||||
a.deleteBotInstall(installID)
|
||||
return BotInstallPollResult{Status: "expired", Error: "install session expired"}, nil
|
||||
@@ -164,6 +173,7 @@ func (a *App) PollBotConnectionInstall(installID string) (BotInstallPollResult,
|
||||
Label: "微信",
|
||||
Enabled: true,
|
||||
Status: "connected",
|
||||
Access: botInstallAccess(result.UserID),
|
||||
Credential: config.BotConnectionCredential{AccountID: result.AccountID, TokenEnv: "WEIXIN_BOT_TOKEN"},
|
||||
}, func(c *config.Config) {
|
||||
c.Bot.Enabled = true
|
||||
@@ -518,6 +528,7 @@ func (a *App) pollFeishuConnectionInstall(installID string, session *botInstallS
|
||||
Label: label,
|
||||
Enabled: true,
|
||||
Status: "connected",
|
||||
Access: botInstallAccess(userID),
|
||||
Credential: config.BotConnectionCredential{AppID: appID, AppSecretEnv: secretEnv},
|
||||
}, func(c *config.Config) {
|
||||
c.Bot.Enabled = true
|
||||
@@ -545,6 +556,9 @@ func (a *App) upsertBotConnection(conn config.BotConnectionConfig, updateLegacy
|
||||
if conn.Status == "" {
|
||||
conn.Status = "connected"
|
||||
}
|
||||
if normalizeBotConnectionToolApprovalMode(conn.ToolApprovalMode) == "" {
|
||||
conn.ToolApprovalMode = "ask"
|
||||
}
|
||||
if conn.ID == "" {
|
||||
conn.ID = connectionID(conn.Provider, conn.Domain)
|
||||
}
|
||||
@@ -556,6 +570,9 @@ func (a *App) upsertBotConnection(conn config.BotConnectionConfig, updateLegacy
|
||||
for i, existing := range c.Bot.Connections {
|
||||
if existing.ID == conn.ID {
|
||||
conn.CreatedAt = firstNonEmptyBot(existing.CreatedAt, conn.CreatedAt)
|
||||
if !botruntime.BotAccessActive(conn.Access) && botruntime.BotAccessActive(existing.Access) {
|
||||
conn.Access = existing.Access
|
||||
}
|
||||
c.Bot.Connections[i] = conn
|
||||
replaced = true
|
||||
break
|
||||
@@ -697,6 +714,7 @@ func botConnectionView(conn config.BotConnectionConfig) BotConnectionView {
|
||||
return BotConnectionView{
|
||||
ID: conn.ID, Provider: conn.Provider, Domain: conn.Domain, Label: conn.Label, Enabled: conn.Enabled, Status: conn.Status,
|
||||
Model: conn.Model, ToolApprovalMode: normalizeBotConnectionToolApprovalMode(conn.ToolApprovalMode), WorkspaceRoot: conn.WorkspaceRoot,
|
||||
Access: botAccessViewFromConfig(conn.Access),
|
||||
Credential: BotConnectionCredentialView{
|
||||
AppID: conn.Credential.AppID, AppSecretEnv: conn.Credential.AppSecretEnv, AccountID: conn.Credential.AccountID, TokenEnv: conn.Credential.TokenEnv,
|
||||
SecretSet: botCredentialSecretSet(conn),
|
||||
@@ -763,8 +781,9 @@ func botConnectionConfig(view BotConnectionView) config.BotConnectionConfig {
|
||||
Enabled: view.Enabled,
|
||||
Status: strings.TrimSpace(view.Status),
|
||||
Model: strings.TrimSpace(view.Model),
|
||||
ToolApprovalMode: normalizeBotConnectionToolApprovalMode(view.ToolApprovalMode),
|
||||
ToolApprovalMode: firstNonEmptyBot(normalizeBotConnectionToolApprovalMode(view.ToolApprovalMode), "ask"),
|
||||
WorkspaceRoot: strings.TrimSpace(view.WorkspaceRoot),
|
||||
Access: botAccessConfigFromView(view.Access),
|
||||
Credential: config.BotConnectionCredential{
|
||||
AppID: strings.TrimSpace(view.Credential.AppID),
|
||||
AppSecretEnv: strings.TrimSpace(view.Credential.AppSecretEnv),
|
||||
@@ -873,6 +892,15 @@ func connectionID(provider, domain string) string {
|
||||
return strings.Trim(strings.ToLower(provider+"-"+domain), "-")
|
||||
}
|
||||
|
||||
func botInstallAccess(userID string) config.BotAccessConfig {
|
||||
userID = strings.TrimSpace(userID)
|
||||
access := config.BotAccessConfig{Enabled: true, PairingEnabled: true}
|
||||
if userID != "" {
|
||||
access.Users = []string{userID}
|
||||
}
|
||||
return access
|
||||
}
|
||||
|
||||
func randomInstallID() string {
|
||||
var b [12]byte
|
||||
if _, err := rand.Read(b[:]); err != nil {
|
||||
|
||||
+108
-27
@@ -23,16 +23,45 @@ type BotRuntimeStatusView struct {
|
||||
}
|
||||
|
||||
type desktopBotRuntime struct {
|
||||
mu sync.Mutex
|
||||
cancel context.CancelFunc
|
||||
gw *bot.BotGateway
|
||||
status BotRuntimeStatusView
|
||||
// lifecycleMu serializes start/stop transitions so two apply/stop calls
|
||||
// can't race a gateway into existence. The slow work (gw.Stop teardown,
|
||||
// gw.Start dials) runs while holding it but NOT r.mu, so status/send reads
|
||||
// never block on a restart.
|
||||
lifecycleMu sync.Mutex
|
||||
mu sync.Mutex
|
||||
cancel context.CancelFunc
|
||||
gw *bot.BotGateway
|
||||
status BotRuntimeStatusView
|
||||
}
|
||||
|
||||
func newDesktopBotRuntime() *desktopBotRuntime {
|
||||
return &desktopBotRuntime{status: BotRuntimeStatusView{Status: "stopped", Message: "bot runtime is not started"}}
|
||||
}
|
||||
|
||||
func desktopBotChannelsWithLegacyQQ(qq config.QQBotConfig, channels map[bot.Platform]bot.ChannelConfig, connectionChannels map[string]bot.ChannelConfig) (map[bot.Platform]bot.ChannelConfig, map[string]bot.ChannelConfig) {
|
||||
channel := bot.ChannelConfig{
|
||||
Model: strings.TrimSpace(qq.Model),
|
||||
ToolApprovalMode: normalizeBotConnectionToolApprovalMode(qq.ToolApprovalMode),
|
||||
WorkspaceRoot: strings.TrimSpace(qq.WorkspaceRoot),
|
||||
}
|
||||
if channel.Model == "" && channel.ToolApprovalMode == "" && channel.WorkspaceRoot == "" {
|
||||
return channels, connectionChannels
|
||||
}
|
||||
if channels == nil {
|
||||
channels = make(map[bot.Platform]bot.ChannelConfig)
|
||||
}
|
||||
if _, ok := channels[bot.PlatformQQ]; !ok {
|
||||
channels[bot.PlatformQQ] = channel
|
||||
}
|
||||
if connectionChannels == nil {
|
||||
connectionChannels = make(map[string]bot.ChannelConfig)
|
||||
}
|
||||
if _, ok := connectionChannels[string(bot.PlatformQQ)]; !ok {
|
||||
connectionChannels[string(bot.PlatformQQ)] = channel
|
||||
}
|
||||
return channels, connectionChannels
|
||||
}
|
||||
|
||||
func (a *App) refreshBotRuntimeAsync() {
|
||||
if a.ctx == nil {
|
||||
return
|
||||
@@ -41,8 +70,11 @@ func (a *App) refreshBotRuntimeAsync() {
|
||||
}
|
||||
|
||||
func (a *App) refreshBotRuntime() {
|
||||
// NewApp always pre-fills botRuntime; a nil here means a test-constructed
|
||||
// App with no bot runtime, which must not lazily create one from a
|
||||
// background goroutine (that would race a concurrent refresh).
|
||||
if a.botRuntime == nil {
|
||||
a.botRuntime = newDesktopBotRuntime()
|
||||
return
|
||||
}
|
||||
cfg, err := a.loadDesktopBotConfig()
|
||||
if err != nil {
|
||||
@@ -53,7 +85,11 @@ func (a *App) refreshBotRuntime() {
|
||||
}
|
||||
|
||||
func (a *App) loadDesktopBotConfig() (*config.Config, error) {
|
||||
cfg, _, err := a.loadDesktopUserConfigForEdit()
|
||||
// Read-only load feeding the bot runtime and connection diagnostics. It
|
||||
// must load credentials: the runtime resolves app secrets and control
|
||||
// tokens from the process env (AppSecretEnv, Control.TokenEnv), which the
|
||||
// credential-free view load would leave unset on a fresh process.
|
||||
cfg, _, err := a.loadDesktopUserConfigForViewWithCredentials()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -81,24 +117,44 @@ func (r *desktopBotRuntime) apply(parent context.Context, cfg *config.Config, wo
|
||||
parent = context.Background()
|
||||
}
|
||||
plan := desktopBotRuntimePlan(cfg)
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
r.stopLocked()
|
||||
r.lifecycleMu.Lock()
|
||||
defer r.lifecycleMu.Unlock()
|
||||
r.stopCurrent()
|
||||
if !plan.Start {
|
||||
r.status = BotRuntimeStatusView{Status: plan.Status, Message: plan.Message}
|
||||
r.setStatus(BotRuntimeStatusView{Status: plan.Status, Message: plan.Message})
|
||||
return nil
|
||||
}
|
||||
|
||||
logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelInfo}))
|
||||
ctx, cancel := context.WithCancel(parent)
|
||||
modelName := botruntime.ModelName(cfg, "")
|
||||
channels := botruntime.ChannelConfigs(cfg.Bot.Connections, true, true)
|
||||
connectionChannels := botruntime.ConnectionChannelConfigs(cfg.Bot.Connections, true, true)
|
||||
channels, connectionChannels = desktopBotChannelsWithLegacyQQ(cfg.Bot.QQ, channels, connectionChannels)
|
||||
gwCfg := bot.GatewayConfig{
|
||||
Model: modelName,
|
||||
ToolApprovalMode: cfg.Bot.ToolApprovalMode,
|
||||
MaxSteps: cfg.Bot.MaxSteps,
|
||||
QueueMode: cfg.Bot.QueueMode,
|
||||
QueueCap: cfg.Bot.QueueCap,
|
||||
QueueDrop: cfg.Bot.QueueDrop,
|
||||
PairingEnabled: cfg.Bot.Pairing.Enabled,
|
||||
PairingTTL: time.Duration(cfg.Bot.Pairing.RequestTTLMinutes) * time.Minute,
|
||||
PairingMaxPending: cfg.Bot.Pairing.MaxPendingPerPlatform,
|
||||
IgnoreSelfMessages: cfg.Bot.IgnoreSelfMessages,
|
||||
SelfUserIDs: map[bot.Platform][]string{
|
||||
bot.PlatformQQ: cfg.Bot.SelfUserIDs.QQ,
|
||||
bot.PlatformFeishu: cfg.Bot.SelfUserIDs.Feishu,
|
||||
bot.PlatformWeixin: cfg.Bot.SelfUserIDs.Weixin,
|
||||
},
|
||||
ControlEnabled: cfg.Bot.Control.Enabled,
|
||||
ControlAddr: cfg.Bot.Control.Addr,
|
||||
ControlToken: os.Getenv(strings.TrimSpace(cfg.Bot.Control.TokenEnv)),
|
||||
WorkspaceRoot: workspaceRoot,
|
||||
Channels: botruntime.ChannelConfigs(cfg.Bot.Connections, true, true),
|
||||
ConnectionChannels: botruntime.ConnectionChannelConfigs(cfg.Bot.Connections, true, true),
|
||||
Channels: channels,
|
||||
ConnectionChannels: connectionChannels,
|
||||
Routes: botruntime.RouteConfigs(cfg.Bot.Routes, true, true),
|
||||
ConnectionAccess: botruntime.ConnectionAccessConfigs(cfg),
|
||||
Enabled: plan.Enabled,
|
||||
Allowlist: bot.AllowlistConfig{
|
||||
Enabled: cfg.Bot.Allowlist.Enabled,
|
||||
@@ -108,6 +164,16 @@ func (r *desktopBotRuntime) apply(parent context.Context, cfg *config.Config, wo
|
||||
bot.PlatformFeishu: cfg.Bot.Allowlist.FeishuUsers,
|
||||
bot.PlatformWeixin: cfg.Bot.Allowlist.WeixinUsers,
|
||||
},
|
||||
Approvers: map[bot.Platform][]string{
|
||||
bot.PlatformQQ: cfg.Bot.Allowlist.QQApprovers,
|
||||
bot.PlatformFeishu: cfg.Bot.Allowlist.FeishuApprovers,
|
||||
bot.PlatformWeixin: cfg.Bot.Allowlist.WeixinApprovers,
|
||||
},
|
||||
Admins: map[bot.Platform][]string{
|
||||
bot.PlatformQQ: cfg.Bot.Allowlist.QQAdmins,
|
||||
bot.PlatformFeishu: cfg.Bot.Allowlist.FeishuAdmins,
|
||||
bot.PlatformWeixin: cfg.Bot.Allowlist.WeixinAdmins,
|
||||
},
|
||||
Groups: map[bot.Platform][]string{
|
||||
bot.PlatformQQ: cfg.Bot.Allowlist.QQGroups,
|
||||
bot.PlatformFeishu: cfg.Bot.Allowlist.FeishuGroups,
|
||||
@@ -122,14 +188,14 @@ func (r *desktopBotRuntime) apply(parent context.Context, cfg *config.Config, wo
|
||||
bindings := botruntime.AdapterBindings(cfg, plan.Enabled, nil, logger)
|
||||
if len(bindings) == 0 {
|
||||
cancel()
|
||||
r.status = BotRuntimeStatusView{Status: "stopped", Message: "no bot adapters configured"}
|
||||
r.setStatus(BotRuntimeStatusView{Status: "stopped", Message: "no bot adapters configured"})
|
||||
return nil
|
||||
}
|
||||
gw := bot.NewGatewayWithAdapterBindings(gwCfg, bindings, logger)
|
||||
if err := gw.Start(ctx); err != nil {
|
||||
cancel()
|
||||
gw.Stop()
|
||||
r.status = BotRuntimeStatusView{Status: "error", Message: err.Error(), Connections: gw.AdapterCount()}
|
||||
r.setStatus(BotRuntimeStatusView{Status: "error", Message: err.Error(), Connections: gw.AdapterCount()})
|
||||
return err
|
||||
}
|
||||
runningConnections := gw.AdapterCount()
|
||||
@@ -140,6 +206,7 @@ func (r *desktopBotRuntime) apply(parent context.Context, cfg *config.Config, wo
|
||||
status = "degraded"
|
||||
message = fmt.Sprintf("%d bot connection(s) running; %d failed to start: %s", runningConnections, len(startErrors), summarizeBotRuntimeErrors(startErrors))
|
||||
}
|
||||
r.mu.Lock()
|
||||
r.cancel = cancel
|
||||
r.gw = gw
|
||||
r.status = BotRuntimeStatusView{
|
||||
@@ -149,6 +216,7 @@ func (r *desktopBotRuntime) apply(parent context.Context, cfg *config.Config, wo
|
||||
Connections: runningConnections,
|
||||
StartedAt: time.Now().UTC().Format(time.RFC3339),
|
||||
}
|
||||
r.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -206,8 +274,8 @@ func desktopBotRuntimePlan(cfg *config.Config) botRuntimePlan {
|
||||
if !cfg.Bot.Enabled {
|
||||
return botRuntimePlan{Status: "stopped", Message: "bot is disabled"}
|
||||
}
|
||||
if !cfg.Bot.Allowlist.AllowAll && (!cfg.Bot.Allowlist.Enabled || botruntime.AllowlistUserCount(cfg.Bot.Allowlist) == 0) {
|
||||
return botRuntimePlan{Status: "blocked", Message: "bot requires an allowlist or allow_all=true"}
|
||||
if !botruntime.BotConfigHasAccessControl(cfg.Bot) {
|
||||
return botRuntimePlan{Status: "blocked", Message: "bot requires an allowlist, pairing, per-bot access, or allow_all=true"}
|
||||
}
|
||||
enabled, unknown := botruntime.EnabledPlatforms(cfg, nil)
|
||||
if len(unknown) > 0 {
|
||||
@@ -220,23 +288,36 @@ func desktopBotRuntimePlan(cfg *config.Config) botRuntimePlan {
|
||||
}
|
||||
|
||||
func (r *desktopBotRuntime) stop(status, message string) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
r.stopLocked()
|
||||
r.status = BotRuntimeStatusView{Status: status, Message: message}
|
||||
r.lifecycleMu.Lock()
|
||||
defer r.lifecycleMu.Unlock()
|
||||
r.stopCurrent()
|
||||
r.setStatus(BotRuntimeStatusView{Status: status, Message: message})
|
||||
}
|
||||
|
||||
func (r *desktopBotRuntime) stopLocked() {
|
||||
if r.cancel != nil {
|
||||
r.cancel()
|
||||
r.cancel = nil
|
||||
// stopCurrent detaches the running gateway under r.mu, then tears it down
|
||||
// off-lock: gw.Stop() closes every session controller (up to the jobs teardown
|
||||
// grace each) and must not stall status/send readers. Callers hold lifecycleMu.
|
||||
func (r *desktopBotRuntime) stopCurrent() {
|
||||
r.mu.Lock()
|
||||
cancel := r.cancel
|
||||
gw := r.gw
|
||||
r.cancel = nil
|
||||
r.gw = nil
|
||||
r.mu.Unlock()
|
||||
if cancel != nil {
|
||||
cancel()
|
||||
}
|
||||
if r.gw != nil {
|
||||
r.gw.Stop()
|
||||
r.gw = nil
|
||||
if gw != nil {
|
||||
gw.Stop()
|
||||
}
|
||||
}
|
||||
|
||||
func (r *desktopBotRuntime) setStatus(status BotRuntimeStatusView) {
|
||||
r.mu.Lock()
|
||||
r.status = status
|
||||
r.mu.Unlock()
|
||||
}
|
||||
|
||||
func (r *desktopBotRuntime) snapshot() BotRuntimeStatusView {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
|
||||
@@ -39,6 +39,7 @@ func TestDesktopBotRuntimePlanBlocksWithoutAllowlist(t *testing.T) {
|
||||
cfg := config.Default()
|
||||
cfg.Bot.Enabled = true
|
||||
cfg.Bot.Allowlist.Enabled = true
|
||||
cfg.Bot.Pairing.Enabled = false
|
||||
cfg.Bot.Connections = []config.BotConnectionConfig{
|
||||
{ID: "feishu-lark", Provider: "feishu", Domain: "lark", Enabled: true},
|
||||
}
|
||||
@@ -49,6 +50,44 @@ func TestDesktopBotRuntimePlanBlocksWithoutAllowlist(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDesktopBotRuntimePlanStartsWithPairing(t *testing.T) {
|
||||
cfg := config.Default()
|
||||
cfg.Bot.Enabled = true
|
||||
cfg.Bot.Allowlist.Enabled = true
|
||||
cfg.Bot.Pairing.Enabled = true
|
||||
cfg.Bot.Connections = []config.BotConnectionConfig{
|
||||
{ID: "feishu-lark", Provider: "feishu", Domain: "lark", Enabled: true},
|
||||
}
|
||||
|
||||
plan := desktopBotRuntimePlan(cfg)
|
||||
if !plan.Start {
|
||||
t.Fatalf("plan = %+v, want start with pairing enabled", plan)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDesktopBotChannelsWithLegacyQQConfig(t *testing.T) {
|
||||
channels, connectionChannels := desktopBotChannelsWithLegacyQQ(config.QQBotConfig{
|
||||
Model: "qq-model",
|
||||
ToolApprovalMode: "auto",
|
||||
WorkspaceRoot: "/tmp/qq-project",
|
||||
}, nil, nil)
|
||||
|
||||
channel, ok := channels[bot.PlatformQQ]
|
||||
if !ok {
|
||||
t.Fatalf("platform QQ channel missing: %+v", channels)
|
||||
}
|
||||
if channel.Model != "qq-model" || channel.ToolApprovalMode != "auto" || channel.WorkspaceRoot != "/tmp/qq-project" {
|
||||
t.Fatalf("platform channel = %+v, want QQ-specific runtime fields", channel)
|
||||
}
|
||||
connectionChannel, ok := connectionChannels["qq"]
|
||||
if !ok {
|
||||
t.Fatalf("connection QQ channel missing: %+v", connectionChannels)
|
||||
}
|
||||
if connectionChannel.Model != "qq-model" || connectionChannel.ToolApprovalMode != "auto" || connectionChannel.WorkspaceRoot != "/tmp/qq-project" {
|
||||
t.Fatalf("connection channel = %+v, want QQ-specific runtime fields", connectionChannel)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDesktopBotRuntimePlanStopsWhenBotDisabled(t *testing.T) {
|
||||
cfg := config.Default()
|
||||
cfg.Bot.Enabled = false
|
||||
@@ -263,7 +302,8 @@ status = "connected"
|
||||
t.Fatalf("chdir project: %v", err)
|
||||
}
|
||||
|
||||
got, err := NewApp().loadDesktopBotConfig()
|
||||
app := NewApp()
|
||||
got, err := app.loadDesktopBotConfig()
|
||||
if err != nil {
|
||||
t.Fatalf("load desktop bot config: %v", err)
|
||||
}
|
||||
@@ -271,6 +311,17 @@ status = "connected"
|
||||
t.Fatalf("desktop bot config = %+v, want migrated legacy Lark connection", got.Bot)
|
||||
}
|
||||
|
||||
// The bot-runtime load is a pure read: the merge above stays in memory and
|
||||
// the user config file is not rewritten.
|
||||
preWrite := config.LoadForEdit(config.UserConfigPath())
|
||||
if preWrite.Bot.Enabled || len(preWrite.Bot.Connections) != 0 {
|
||||
t.Fatalf("read path persisted bot config = %+v, want disk untouched until a locked write", preWrite.Bot)
|
||||
}
|
||||
|
||||
// The first locked write path performs the on-disk migration.
|
||||
if err := app.applyConfigOnly(func(*config.Config) error { return nil }); err != nil {
|
||||
t.Fatalf("applyConfigOnly: %v", err)
|
||||
}
|
||||
persisted := config.LoadForEdit(config.UserConfigPath())
|
||||
if !persisted.Bot.Enabled || len(persisted.Bot.Connections) != 1 || persisted.Bot.Connections[0].ID != "feishu-lark" {
|
||||
t.Fatalf("persisted bot config = %+v, want migrated legacy Lark connection", persisted.Bot)
|
||||
@@ -312,7 +363,8 @@ status = "connected"
|
||||
t.Fatalf("chdir project: %v", err)
|
||||
}
|
||||
|
||||
got, err := NewApp().loadDesktopBotConfig()
|
||||
app := NewApp()
|
||||
got, err := app.loadDesktopBotConfig()
|
||||
if err != nil {
|
||||
t.Fatalf("load desktop bot config: %v", err)
|
||||
}
|
||||
@@ -320,13 +372,21 @@ status = "connected"
|
||||
t.Fatalf("desktop bot config = %+v, want migrated legacy Lark connection", got.Bot)
|
||||
}
|
||||
|
||||
// The bot-runtime load is a pure read: it serves the legacy config from
|
||||
// memory and must not create the user config file.
|
||||
if _, err := os.Stat(config.UserConfigPath()); !os.IsNotExist(err) {
|
||||
t.Fatalf("read path must not create the user config, stat err = %v", err)
|
||||
}
|
||||
|
||||
// The first locked write path creates the user config with the migrated
|
||||
// bot settings (adopting the legacy config, ConfigVersion-bumped).
|
||||
if err := app.applyConfigOnly(func(*config.Config) error { return nil }); err != nil {
|
||||
t.Fatalf("applyConfigOnly: %v", err)
|
||||
}
|
||||
persisted := config.LoadForEdit(config.UserConfigPath())
|
||||
if !persisted.Bot.Enabled || len(persisted.Bot.Connections) != 1 || persisted.Bot.Connections[0].ID != "feishu-lark" {
|
||||
t.Fatalf("persisted bot config = %+v, want migrated legacy Lark connection", persisted.Bot)
|
||||
}
|
||||
if persisted.DesktopTheme() == "dark" {
|
||||
t.Fatal("legacy project desktop theme should not be persisted during bot-only migration")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDesktopSettingsBotMigrationPersistsOnlyBotBeforeFirstEdit(t *testing.T) {
|
||||
|
||||
@@ -42,9 +42,9 @@ func TestBoundArrayPayloadsAreNonNilBeforeStartup(t *testing.T) {
|
||||
if got := app.HooksSettings("global"); got.Hooks == nil || got.Events == nil {
|
||||
t.Fatalf("HooksSettings(global) arrays = hooks:%v events:%v, want non-nil", got.Hooks, got.Events)
|
||||
}
|
||||
if got := app.Settings(); got.Providers == nil || got.OfficialProviders == nil || got.ProviderKinds == nil ||
|
||||
if got := app.Settings(); got.Providers == nil || got.OfficialProviders == nil || got.ProviderPresets == nil || got.ProviderKinds == nil ||
|
||||
got.Permissions.Allow == nil || got.Permissions.Ask == nil || got.Permissions.Deny == nil ||
|
||||
got.Sandbox.AllowWrite == nil ||
|
||||
got.Sandbox.AllowWrite == nil || got.Sandbox.EffectiveWriteRoots == nil ||
|
||||
got.Bot.Allowlist.QQUsers == nil || got.Bot.Allowlist.FeishuUsers == nil || got.Bot.Allowlist.WeixinUsers == nil ||
|
||||
got.Bot.Allowlist.QQGroups == nil || got.Bot.Allowlist.FeishuGroups == nil || got.Bot.Allowlist.WeixinGroups == nil {
|
||||
t.Fatalf("Settings() contains nil array fields: %+v", got)
|
||||
|
||||
@@ -36,6 +36,7 @@ Unicode true
|
||||
####
|
||||
!include "wails_tools.nsh"
|
||||
!include "FileFunc.nsh"
|
||||
!include "LogicLib.nsh"
|
||||
|
||||
# The version information for this two must consist of 4 parts
|
||||
VIProductVersion "${INFO_PRODUCTVERSION}.0"
|
||||
@@ -76,6 +77,8 @@ ManifestDPIAware true
|
||||
Name "${INFO_PRODUCTNAME}"
|
||||
OutFile "..\..\bin\${INFO_PROJECTNAME}-${ARCH}-installer.exe" # Name of the installer's file.
|
||||
!define REASONIX_DEFAULT_INSTALLDIR "$LOCALAPPDATA\Programs\${INFO_PRODUCTNAME}"
|
||||
!define REASONIX_UPDATE_HELPER "reasonix-update-helper.exe"
|
||||
!define REASONIX_UNLOCK_RETRIES 60
|
||||
InstallDirRegKey HKCU "${UNINST_KEY}" "InstallLocation" # Reuse the previous install path on update; .onInit falls back to the default on first install.
|
||||
InstallDir "${REASONIX_DEFAULT_INSTALLDIR}" # Per-user install location (no admin rights required).
|
||||
ShowInstDetails show # This will always show the installation details.
|
||||
@@ -130,14 +133,54 @@ fallback:
|
||||
done:
|
||||
FunctionEnd
|
||||
|
||||
Function reasonix.waitForExecutableUnlock
|
||||
IfFileExists "$INSTDIR\${PRODUCT_EXECUTABLE}" 0 done
|
||||
StrCpy $0 0
|
||||
|
||||
retry:
|
||||
ClearErrors
|
||||
FileOpen $1 "$INSTDIR\${PRODUCT_EXECUTABLE}" a
|
||||
IfErrors locked
|
||||
FileClose $1
|
||||
Goto done
|
||||
|
||||
locked:
|
||||
IntOp $0 $0 + 1
|
||||
IntCmp $0 ${REASONIX_UNLOCK_RETRIES} failed 0 0
|
||||
Sleep 1000
|
||||
Goto retry
|
||||
|
||||
failed:
|
||||
IfSilent silent interactive
|
||||
|
||||
interactive:
|
||||
MessageBox MB_RETRYCANCEL|MB_ICONEXCLAMATION "Reasonix is still running. Close Reasonix, then click Retry to continue the installation." IDRETRY retry IDCANCEL abort
|
||||
Goto retry
|
||||
|
||||
silent:
|
||||
SetErrorLevel 1618
|
||||
|
||||
abort:
|
||||
Abort "Reasonix is still running. Close Reasonix and run the installer again."
|
||||
|
||||
done:
|
||||
FunctionEnd
|
||||
|
||||
Section
|
||||
!insertmacro wails.setShellContext
|
||||
|
||||
!insertmacro wails.webview2runtime
|
||||
|
||||
Call reasonix.waitForExecutableUnlock
|
||||
|
||||
SetOutPath $INSTDIR
|
||||
|
||||
!insertmacro wails.files
|
||||
!if /FileExists "${REASONIX_UPDATE_HELPER}"
|
||||
File "/oname=${REASONIX_UPDATE_HELPER}" "${REASONIX_UPDATE_HELPER}"
|
||||
!else
|
||||
!warning "${REASONIX_UPDATE_HELPER} was not found; Windows auto-update will fall back to installer-side waiting only."
|
||||
!endif
|
||||
|
||||
CreateShortcut "$SMPROGRAMS\${INFO_PRODUCTNAME}.lnk" "$INSTDIR\${PRODUCT_EXECUTABLE}"
|
||||
CreateShortCut "$DESKTOP\${INFO_PRODUCTNAME}.lnk" "$INSTDIR\${PRODUCT_EXECUTABLE}"
|
||||
@@ -155,6 +198,7 @@ Section "uninstall"
|
||||
|
||||
; Precision uninstall: delete main application files
|
||||
Delete "$INSTDIR\${PRODUCT_EXECUTABLE}"
|
||||
Delete "$INSTDIR\${REASONIX_UPDATE_HELPER}"
|
||||
|
||||
Delete "$SMPROGRAMS\${INFO_PRODUCTNAME}.lnk"
|
||||
Delete "$DESKTOP\${INFO_PRODUCTNAME}.lnk"
|
||||
|
||||
@@ -166,12 +166,12 @@ func signFiles(files []string) error {
|
||||
// release tag used in download URLs (e.g. "desktop-v1.1.0").
|
||||
func genManifest(dir, version, tag string) error {
|
||||
repo := os.Getenv("GITHUB_REPOSITORY")
|
||||
if repo == "" {
|
||||
repo = "esengine/reasonix"
|
||||
if repo == "" || repo == "esengine/reasonix" {
|
||||
repo = "esengine/DeepSeek-Reasonix"
|
||||
}
|
||||
m := update.Manifest{
|
||||
Version: version,
|
||||
DownloadPage: fmt.Sprintf("https://github.com/%s/releases/latest", repo),
|
||||
DownloadPage: "https://reasonix.io/#start",
|
||||
Platforms: map[string]update.Asset{},
|
||||
}
|
||||
entries, err := os.ReadDir(dir)
|
||||
|
||||
@@ -85,6 +85,9 @@ func TestGenManifest(t *testing.T) {
|
||||
if m.Version != "v1.2.0" {
|
||||
t.Fatalf("version = %q, want v1.2.0", m.Version)
|
||||
}
|
||||
if m.DownloadPage != "https://reasonix.io/#start" {
|
||||
t.Fatalf("download_page = %q, want official install page", m.DownloadPage)
|
||||
}
|
||||
if len(m.Platforms) != 5 {
|
||||
t.Fatalf("want 5 platforms, got %d: %v", len(m.Platforms), m.Platforms)
|
||||
}
|
||||
@@ -92,7 +95,7 @@ func TestGenManifest(t *testing.T) {
|
||||
if !ok {
|
||||
t.Fatal("windows-amd64 missing")
|
||||
}
|
||||
wantURL := "https://github.com/esengine/reasonix/releases/download/desktop-v1.2.0/Reasonix-windows-amd64-installer.exe"
|
||||
wantURL := "https://github.com/esengine/DeepSeek-Reasonix/releases/download/desktop-v1.2.0/Reasonix-windows-amd64-installer.exe"
|
||||
if win.URL != wantURL {
|
||||
t.Fatalf("windows url = %q, want %q", win.URL, wantURL)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
package main
|
||||
|
||||
import "fmt"
|
||||
|
||||
func installerCommandLine(installer, dir string) string {
|
||||
line := fmt.Sprintf(`"%s" /S`, installer)
|
||||
if dir != "" {
|
||||
line += " /D=" + dir
|
||||
}
|
||||
return line
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestInstallerCommandLineIsSilentAndLeavesDFlagLast(t *testing.T) {
|
||||
got := installerCommandLine(`C:\Temp\Reasonix Installer.exe`, `D:\Tools\Reasonix App`)
|
||||
want := `"C:\Temp\Reasonix Installer.exe" /S /D=D:\Tools\Reasonix App`
|
||||
if got != want {
|
||||
t.Fatalf("installerCommandLine = %q, want %q", got, want)
|
||||
}
|
||||
if !strings.HasSuffix(got, `/D=D:\Tools\Reasonix App`) {
|
||||
t.Fatalf("/D= must be the final unquoted NSIS token, got %q", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
//go:build !windows
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
)
|
||||
|
||||
func main() {
|
||||
fmt.Fprintln(os.Stderr, "reasonix-update-helper is only used by Windows desktop builds")
|
||||
os.Exit(2)
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
//go:build windows
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"golang.org/x/sys/windows"
|
||||
)
|
||||
|
||||
const parentExitTimeout = 2 * time.Minute
|
||||
|
||||
func main() {
|
||||
os.Exit(run(os.Args[1:]))
|
||||
}
|
||||
|
||||
func run(args []string) int {
|
||||
var parentPID uint
|
||||
var installer, installDir, relaunch string
|
||||
fs := flag.NewFlagSet("reasonix-update-helper", flag.ContinueOnError)
|
||||
fs.SetOutput(os.Stderr)
|
||||
fs.UintVar(&parentPID, "parent-pid", 0, "Reasonix process id to wait for before installing")
|
||||
fs.StringVar(&installer, "installer", "", "verified NSIS installer path")
|
||||
fs.StringVar(&installDir, "install-dir", "", "Reasonix installation directory")
|
||||
fs.StringVar(&relaunch, "relaunch", "", "Reasonix executable to start after the installer succeeds")
|
||||
if err := fs.Parse(args); err != nil {
|
||||
return 2
|
||||
}
|
||||
logger := newLogger()
|
||||
if installer == "" {
|
||||
logger.Print("missing --installer")
|
||||
return 2
|
||||
}
|
||||
if parentPID != 0 {
|
||||
if err := waitForProcessExit(uint32(parentPID), parentExitTimeout); err != nil {
|
||||
logger.Printf("wait for parent process %d: %v", parentPID, err)
|
||||
return 1
|
||||
}
|
||||
}
|
||||
if err := runInstaller(installer, installDir); err != nil {
|
||||
logger.Printf("run installer: %v", err)
|
||||
return 1
|
||||
}
|
||||
if relaunch != "" {
|
||||
if err := startRelaunch(relaunch, installDir); err != nil {
|
||||
logger.Printf("relaunch: %v", err)
|
||||
return 1
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func newLogger() *log.Logger {
|
||||
dir, err := os.UserCacheDir()
|
||||
if err == nil {
|
||||
dir = filepath.Join(dir, "Reasonix", "updates")
|
||||
if err := os.MkdirAll(dir, 0o700); err == nil {
|
||||
if f, err := os.OpenFile(filepath.Join(dir, "update-helper.log"), os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o600); err == nil {
|
||||
return log.New(f, "", log.LstdFlags)
|
||||
}
|
||||
}
|
||||
}
|
||||
return log.New(os.Stderr, "", log.LstdFlags)
|
||||
}
|
||||
|
||||
func waitForProcessExit(pid uint32, timeout time.Duration) error {
|
||||
h, err := windows.OpenProcess(windows.SYNCHRONIZE, false, pid)
|
||||
if err != nil {
|
||||
if err == windows.ERROR_INVALID_PARAMETER {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
defer windows.CloseHandle(h)
|
||||
waitMS := uint32(timeout / time.Millisecond)
|
||||
result, err := windows.WaitForSingleObject(h, waitMS)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
switch result {
|
||||
case windows.WAIT_OBJECT_0:
|
||||
return nil
|
||||
case uint32(windows.WAIT_TIMEOUT):
|
||||
return fmt.Errorf("timed out after %s", timeout)
|
||||
default:
|
||||
return fmt.Errorf("unexpected wait result %d", result)
|
||||
}
|
||||
}
|
||||
|
||||
func runInstaller(installer, installDir string) error {
|
||||
cmd := exec.Command(installer)
|
||||
cmd.SysProcAttr = &syscall.SysProcAttr{CmdLine: installerCommandLine(installer, installDir), HideWindow: true}
|
||||
return cmd.Run()
|
||||
}
|
||||
|
||||
func startRelaunch(relaunch, installDir string) error {
|
||||
cmd := exec.Command(relaunch)
|
||||
cmd.Dir = installDir
|
||||
cmd.SysProcAttr = &syscall.SysProcAttr{HideWindow: true}
|
||||
return cmd.Start()
|
||||
}
|
||||
@@ -46,15 +46,32 @@ func writePendingCrash(site string, r any, stack []byte) {
|
||||
report.Stack = sanitizeCrashText(stackText, maxCrashStackBytes)
|
||||
report.TopFrame = topFrameFromStack(report.Stack)
|
||||
report.Message = msg
|
||||
_ = writePendingReport(report, true)
|
||||
}
|
||||
|
||||
func writePendingReport(report crashReport, overwrite bool) bool {
|
||||
body, err := json.Marshal(report)
|
||||
if err != nil {
|
||||
return
|
||||
return false
|
||||
}
|
||||
path := pendingCrashPath()
|
||||
if os.MkdirAll(filepath.Dir(path), 0o755) != nil {
|
||||
return
|
||||
return false
|
||||
}
|
||||
_ = os.WriteFile(path, body, 0o644)
|
||||
if overwrite {
|
||||
return os.WriteFile(path, body, 0o644) == nil
|
||||
}
|
||||
f, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o644)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
defer f.Close()
|
||||
n, err := f.Write(body)
|
||||
if err != nil || n != len(body) {
|
||||
_ = os.Remove(path)
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func (a *App) goSafe(site string, fn func()) {
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
)
|
||||
@@ -67,6 +68,66 @@ func TestWritePendingCrashCaps(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestWritePendingReportCanAvoidOverwritingExistingCrash(t *testing.T) {
|
||||
t.Cleanup(func() { os.Remove(pendingCrashPath()) })
|
||||
writePendingCrash("panic", "boom", []byte("stack"))
|
||||
before, ok := readPending(t)
|
||||
if !ok {
|
||||
t.Fatal("expected initial pending crash")
|
||||
}
|
||||
|
||||
hang := baseCrashReport("performance")
|
||||
hang.Source = "native.watchdog"
|
||||
hang.Label = "mac.main_thread.hang"
|
||||
hang.Message = "hang"
|
||||
if writePendingReport(hang, false) {
|
||||
t.Fatal("writePendingReport overwrite=false should not replace existing report")
|
||||
}
|
||||
after, ok := readPending(t)
|
||||
if !ok {
|
||||
t.Fatal("expected pending crash after skipped write")
|
||||
}
|
||||
if after.Label != before.Label || after.Message != before.Message {
|
||||
t.Fatalf("pending crash was overwritten: before=%+v after=%+v", before, after)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWritePendingReportNonOverwriteAllowsOneConcurrentWriter(t *testing.T) {
|
||||
t.Cleanup(func() { os.Remove(pendingCrashPath()) })
|
||||
const writers = 32
|
||||
start := make(chan struct{})
|
||||
var ready sync.WaitGroup
|
||||
var done sync.WaitGroup
|
||||
var successes atomic.Int32
|
||||
|
||||
for i := 0; i < writers; i++ {
|
||||
ready.Add(1)
|
||||
done.Add(1)
|
||||
go func() {
|
||||
defer done.Done()
|
||||
report := baseCrashReport("performance")
|
||||
report.Source = "native.watchdog"
|
||||
report.Label = "mac.main_thread.hang"
|
||||
report.Message = strings.Repeat("hang", 1024)
|
||||
ready.Done()
|
||||
<-start
|
||||
if writePendingReport(report, false) {
|
||||
successes.Add(1)
|
||||
}
|
||||
}()
|
||||
}
|
||||
ready.Wait()
|
||||
close(start)
|
||||
done.Wait()
|
||||
|
||||
if got := successes.Load(); got != 1 {
|
||||
t.Fatalf("successful non-overwrite writers = %d, want 1", got)
|
||||
}
|
||||
if _, ok := readPending(t); !ok {
|
||||
t.Fatal("expected one pending report")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWritePendingCrashScrubsSensitiveText(t *testing.T) {
|
||||
t.Cleanup(func() { os.Remove(pendingCrashPath()) })
|
||||
apiKey := "sk-proj-" + "abcdefghijklmnopqrstuvwxyz1234567890"
|
||||
|
||||
@@ -0,0 +1,237 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"reasonix/internal/agent"
|
||||
)
|
||||
|
||||
// deferredRebuildRetryInterval is how often the retry loop probes a held
|
||||
// session lease. Package-level so tests can shorten it.
|
||||
var deferredRebuildRetryInterval = 2 * time.Second
|
||||
|
||||
// deferredRebuildState tracks tabs whose settings were saved to disk but whose
|
||||
// runtime could not refresh because the session lease was held by another
|
||||
// Reasonix process. A single background loop probes the lease and replays the
|
||||
// rebuild once the other side releases it. The loop only runs after
|
||||
// enableDeferredRebuildRetry (the wails startup hook); tests that never call
|
||||
// it get the pending bookkeeping without a background goroutine.
|
||||
type deferredRebuildState struct {
|
||||
mu sync.Mutex
|
||||
pending map[string]string // tab ID -> setting label for notices
|
||||
enabled bool
|
||||
running bool
|
||||
stopped bool
|
||||
stop chan struct{}
|
||||
}
|
||||
|
||||
// enableDeferredRebuildRetry arms the retry loop; called from startup.
|
||||
func (a *App) enableDeferredRebuildRetry() {
|
||||
d := &a.deferredRebuild
|
||||
d.mu.Lock()
|
||||
defer d.mu.Unlock()
|
||||
d.enabled = true
|
||||
a.startDeferredRebuildLoopLocked()
|
||||
}
|
||||
|
||||
// startDeferredRebuildLoopLocked starts the loop when it is armed, idle, and
|
||||
// has work. Callers must hold d.mu.
|
||||
func (a *App) startDeferredRebuildLoopLocked() {
|
||||
d := &a.deferredRebuild
|
||||
if !d.enabled || d.running || d.stopped || len(d.pending) == 0 {
|
||||
return
|
||||
}
|
||||
d.running = true
|
||||
if d.stop == nil {
|
||||
d.stop = make(chan struct{})
|
||||
}
|
||||
go a.deferredRebuildLoop(d.stop)
|
||||
}
|
||||
|
||||
// scheduleDeferredRebuild records that tabID needs a runtime refresh for
|
||||
// setting and starts the retry loop if it is not running yet. Repeated calls
|
||||
// for the same tab collapse into one retry carrying the latest label.
|
||||
func (a *App) scheduleDeferredRebuild(tabID, setting string) {
|
||||
tabID = strings.TrimSpace(tabID)
|
||||
if tabID == "" {
|
||||
return
|
||||
}
|
||||
d := &a.deferredRebuild
|
||||
d.mu.Lock()
|
||||
defer d.mu.Unlock()
|
||||
if d.stopped {
|
||||
return
|
||||
}
|
||||
if d.pending == nil {
|
||||
d.pending = map[string]string{}
|
||||
}
|
||||
d.pending[tabID] = setting
|
||||
a.startDeferredRebuildLoopLocked()
|
||||
}
|
||||
|
||||
func (a *App) clearDeferredRebuild(tabID string) {
|
||||
d := &a.deferredRebuild
|
||||
d.mu.Lock()
|
||||
delete(d.pending, tabID)
|
||||
d.mu.Unlock()
|
||||
}
|
||||
|
||||
func (a *App) deferredRebuildPending(tabID string) bool {
|
||||
d := &a.deferredRebuild
|
||||
d.mu.Lock()
|
||||
defer d.mu.Unlock()
|
||||
_, ok := d.pending[tabID]
|
||||
return ok
|
||||
}
|
||||
|
||||
// stopDeferredRebuildRetry permanently stops the retry loop; used on shutdown
|
||||
// and by tests.
|
||||
func (a *App) stopDeferredRebuildRetry() {
|
||||
d := &a.deferredRebuild
|
||||
d.mu.Lock()
|
||||
defer d.mu.Unlock()
|
||||
if d.stopped {
|
||||
return
|
||||
}
|
||||
d.stopped = true
|
||||
if d.stop != nil {
|
||||
close(d.stop)
|
||||
}
|
||||
}
|
||||
|
||||
func (a *App) deferredRebuildLoop(stop <-chan struct{}) {
|
||||
ticker := time.NewTicker(deferredRebuildRetryInterval)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-stop:
|
||||
d := &a.deferredRebuild
|
||||
d.mu.Lock()
|
||||
d.running = false
|
||||
d.mu.Unlock()
|
||||
return
|
||||
case <-ticker.C:
|
||||
}
|
||||
if a.deferredRebuildTickDone() {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// deferredRebuildTickDone runs one retry pass and reports true when the loop
|
||||
// should exit because nothing is pending anymore.
|
||||
func (a *App) deferredRebuildTickDone() bool {
|
||||
d := &a.deferredRebuild
|
||||
d.mu.Lock()
|
||||
if d.stopped || len(d.pending) == 0 {
|
||||
d.running = false
|
||||
d.mu.Unlock()
|
||||
return true
|
||||
}
|
||||
pending := make(map[string]string, len(d.pending))
|
||||
for id, setting := range d.pending {
|
||||
pending[id] = setting
|
||||
}
|
||||
d.mu.Unlock()
|
||||
|
||||
for tabID, setting := range pending {
|
||||
a.retryDeferredRebuild(tabID, setting)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (a *App) retryDeferredRebuild(tabID, setting string) {
|
||||
if a.ctx == nil {
|
||||
return
|
||||
}
|
||||
tab := a.tabByID(tabID)
|
||||
if tab == nil || tab.ID != tabID {
|
||||
// The tab is gone; nothing left to refresh.
|
||||
a.clearDeferredRebuild(tabID)
|
||||
return
|
||||
}
|
||||
// Hold the rebuild mutex across probe + rebuild: the probe briefly acquires
|
||||
// the session lease, and a concurrent manual rebuild's ensureSessionLease
|
||||
// would see that probe as "held by another runtime" and spuriously defer.
|
||||
a.runtimeRebuildMu.Lock()
|
||||
defer a.runtimeRebuildMu.Unlock()
|
||||
// rebuildSettingLocked refreshes the active tab only. Wait until the user
|
||||
// is back on this tab so we refresh the runtime the pending setting was
|
||||
// meant for, not whichever tab happens to be focused.
|
||||
if a.activeTab() != tab {
|
||||
return
|
||||
}
|
||||
ctrl := a.controllerForTab(tab)
|
||||
if ctrl == nil {
|
||||
// Mid-(re)build on another path (provider retarget, workspace repair);
|
||||
// racing a second build+swap against it is what this loop must avoid.
|
||||
return
|
||||
}
|
||||
if controllerHasActiveRuntimeWork(ctrl) {
|
||||
return
|
||||
}
|
||||
if !a.deferredRebuildLeaseLooksFree(tab) {
|
||||
return
|
||||
}
|
||||
err := a.rebuildSettingLocked(setting)
|
||||
if err == nil {
|
||||
// rebuildSettingLocked already cleared the pending entry for the tab it
|
||||
// refreshed; just announce it.
|
||||
a.noticeForTab(tabID, fmt.Sprintf("%s applied: session refreshed after the lease was released", setting))
|
||||
return
|
||||
}
|
||||
if errors.Is(err, agent.ErrSessionLeaseHeld) {
|
||||
return // grabbed back before we could rebuild; keep waiting
|
||||
}
|
||||
var busy *rebuildBusyError
|
||||
if errors.As(err, &busy) {
|
||||
return // a turn started meanwhile; retry once it finishes
|
||||
}
|
||||
// Anything else will not resolve by waiting; give up loudly instead of
|
||||
// retrying forever.
|
||||
a.clearDeferredRebuild(tabID)
|
||||
slog.Warn("desktop: deferred settings rebuild failed", "setting", setting, "tab", tabID, "err", err)
|
||||
a.warnForTab(tabID, fmt.Sprintf("%s was saved but the session could not refresh: %s", setting, err.Error()))
|
||||
}
|
||||
|
||||
// deferredRebuildLeaseLooksFree cheaply probes whether the tab's session lease
|
||||
// could be acquired right now, without touching tab.sessionLease (only the
|
||||
// serialized rebuild paths may mutate that). The probe path can lag the
|
||||
// reconciled path the rebuild will use; a stale answer either re-defers on the
|
||||
// next tick or lets the rebuild fail back into the pending set, so a mismatch
|
||||
// only delays the retry.
|
||||
func (a *App) deferredRebuildLeaseLooksFree(tab *WorkspaceTab) bool {
|
||||
a.mu.RLock()
|
||||
ctrl := tab.Ctrl
|
||||
path := strings.TrimSpace(tab.SessionPath)
|
||||
a.mu.RUnlock()
|
||||
if ctrl != nil {
|
||||
if p := strings.TrimSpace(ctrl.SessionPath()); p != "" {
|
||||
path = p
|
||||
}
|
||||
}
|
||||
if path == "" {
|
||||
return true // nothing to probe; let the rebuild decide
|
||||
}
|
||||
lease, err := agent.TryAcquireSessionLease(sessionRuntimeKey(path))
|
||||
if err != nil {
|
||||
var leaseErr *agent.SessionLeaseError
|
||||
if errors.As(err, &leaseErr) && leaseErr.Info != nil && leaseErr.Info.PID == os.Getpid() {
|
||||
if host, _ := os.Hostname(); leaseErr.Info.Hostname == host {
|
||||
// This process (usually this very tab) holds the probed path;
|
||||
// the blocking lease is some other path. Attempt the rebuild
|
||||
// and let its own lease checks decide.
|
||||
return true
|
||||
}
|
||||
}
|
||||
return !errors.Is(err, agent.ErrSessionLeaseHeld)
|
||||
}
|
||||
lease.Release()
|
||||
return true
|
||||
}
|
||||
@@ -10,9 +10,9 @@
|
||||
"check:css": "node scripts/check-css-syntax.mjs src/styles.css && node scripts/check-z-index-tokens.mjs src/styles.css",
|
||||
"test:todo-visibility": "node scripts/test-todo-visibility.mjs",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "node scripts/test-todo-visibility.mjs && tsx src/__tests__/anchored-popover-scroll.test.tsx && tsx src/__tests__/math-golden.test.ts && tsx src/__tests__/mermaid-rendering.test.tsx && tsx src/__tests__/text-size.test.ts && tsx src/__tests__/font-availability.test.ts && tsx src/__tests__/theme-auto-background.test.ts && tsx src/__tests__/typography-overflow-contract.test.ts && tsx src/__tests__/ask-card-layout.test.ts && tsx src/__tests__/approval-modal-file-reference.test.tsx && tsx src/__tests__/provider-model-refresh.test.ts && tsx src/__tests__/composer-profile.test.ts && tsx src/__tests__/composer-history.test.ts && tsx src/__tests__/session-title-contract.test.ts && tsx src/__tests__/composer-keyboard.test.ts && tsx src/__tests__/composer-goal-toggle.test.tsx && tsx src/__tests__/composer-session-draft.test.tsx && tsx src/__tests__/composer-image-capability.test.tsx && tsx src/__tests__/use-controller-meta.test.ts && tsx src/__tests__/use-controller-cancel-reconcile.test.tsx && tsx src/__tests__/new-session-load-race.test.tsx && tsx src/__tests__/tab-switch-hydration.test.tsx && tsx src/__tests__/workspace-layout.test.ts && tsx src/__tests__/workspace-changes-errors.test.tsx && tsx src/__tests__/workspace-split.test.ts && tsx src/__tests__/workspace-preview-css.test.ts && tsx src/__tests__/resize-drag.test.ts && tsx src/__tests__/tool-approval-mode.test.ts && tsx src/__tests__/heartbeat-next-run.test.ts && tsx src/__tests__/keyboard-shortcuts.test.ts && tsx src/__tests__/send-failed.test.ts && tsx src/__tests__/edit-replay.test.ts && tsx src/__tests__/history-tool-status.test.ts && tsx src/__tests__/message-selection-copy.test.ts && tsx src/__tests__/code-block-copy-position.test.tsx && tsx src/__tests__/reasoning-display.test.ts && tsx src/__tests__/attachment-display.test.ts && tsx src/__tests__/crash-reporting.test.ts && tsx src/__tests__/bridge-drag-rejection.test.ts && tsx src/__tests__/startup-settings-contract.test.ts && tsx src/__tests__/bundle-contract.test.ts && tsx src/__tests__/command-palette-css.test.ts && tsx src/__tests__/command-palette-interactions.test.tsx && tsx src/__tests__/context-panel-breakdown.test.ts && tsx src/__tests__/statusbar-workspace.test.tsx && tsx src/__tests__/settings-refresh-snapshot.test.tsx && tsx src/__tests__/capabilities-panel-actions.test.ts && tsx src/__tests__/tool-data-archive.test.ts && tsx src/__tests__/tool-subject.test.ts && tsx src/__tests__/diff-rendering.test.ts && tsx src/__tests__/render-optimization.test.ts && tsx src/__tests__/transcript-grouping.test.ts && tsx src/__tests__/project-tree-runtime.test.ts && tsx src/__tests__/app-chrome-tabs.test.ts && tsx src/__tests__/open-topic-coalescing.test.tsx && tsx src/__tests__/memory-compiler-display.test.ts && tsx src/__tests__/memory-citation-visibility.test.ts",
|
||||
"test": "node scripts/test-todo-visibility.mjs && tsx src/__tests__/anchored-popover-scroll.test.tsx && tsx src/__tests__/math-golden.test.ts && tsx src/__tests__/mermaid-rendering.test.tsx && tsx src/__tests__/text-size.test.ts && tsx src/__tests__/font-availability.test.ts && tsx src/__tests__/theme-auto-background.test.ts && tsx src/__tests__/typography-overflow-contract.test.ts && tsx src/__tests__/ask-card-layout.test.ts && tsx src/__tests__/approval-modal-file-reference.test.tsx && tsx src/__tests__/provider-model-refresh.test.ts && tsx src/__tests__/composer-profile.test.ts && tsx src/__tests__/composer-history.test.ts && tsx src/__tests__/session-title-contract.test.ts && tsx src/__tests__/composer-keyboard.test.ts && tsx src/__tests__/composer-goal-toggle.test.tsx && tsx src/__tests__/composer-session-draft.test.tsx && tsx src/__tests__/composer-image-capability.test.tsx && tsx src/__tests__/use-controller-meta.test.ts && tsx src/__tests__/use-controller-cancel-reconcile.test.tsx && tsx src/__tests__/new-session-load-race.test.tsx && tsx src/__tests__/tab-switch-hydration.test.tsx && tsx src/__tests__/workspace-layout.test.ts && tsx src/__tests__/workspace-changes-errors.test.tsx && tsx src/__tests__/workspace-split.test.ts && tsx src/__tests__/workspace-preview-css.test.ts && tsx src/__tests__/resize-drag.test.ts && tsx src/__tests__/wails-resize-fix.test.tsx && tsx src/__tests__/tool-approval-mode.test.ts && tsx src/__tests__/heartbeat-next-run.test.ts && tsx src/__tests__/keyboard-shortcuts.test.ts && tsx src/__tests__/send-failed.test.ts && tsx src/__tests__/edit-replay.test.ts && tsx src/__tests__/message-pasted-blocks.test.ts && tsx src/__tests__/history-tool-status.test.ts && tsx src/__tests__/message-selection-copy.test.ts && tsx src/__tests__/turn-action-copy.test.ts && tsx src/__tests__/code-block-copy-position.test.tsx && tsx src/__tests__/reasoning-display.test.ts && tsx src/__tests__/attachment-display.test.ts && tsx src/__tests__/crash-reporting.test.ts && tsx src/__tests__/bridge-drag-rejection.test.ts && tsx src/__tests__/startup-settings-contract.test.ts && tsx src/__tests__/bundle-contract.test.ts && tsx src/__tests__/command-palette-css.test.ts && tsx src/__tests__/command-palette-interactions.test.tsx && tsx src/__tests__/context-panel-breakdown.test.ts && tsx src/__tests__/statusbar-workspace.test.tsx && tsx src/__tests__/settings-refresh-snapshot.test.tsx && tsx src/__tests__/capabilities-panel-actions.test.ts && tsx src/__tests__/tool-data-archive.test.ts && tsx src/__tests__/tool-subject.test.ts && tsx src/__tests__/diff-rendering.test.ts && tsx src/__tests__/render-optimization.test.ts && tsx src/__tests__/transcript-grouping.test.ts && tsx src/__tests__/project-tree-runtime.test.ts && tsx src/__tests__/app-chrome-tabs.test.ts && tsx src/__tests__/open-topic-coalescing.test.tsx && tsx src/__tests__/memory-compiler-display.test.ts && tsx src/__tests__/memory-citation-visibility.test.ts",
|
||||
"test:typecheck": "tsc --noEmit -p tsconfig.test.json",
|
||||
"test:all": "tsc --noEmit -p tsconfig.test.json && node scripts/test-todo-visibility.mjs && tsx src/__tests__/anchored-popover-scroll.test.tsx && tsx src/__tests__/math-golden.test.ts && tsx src/__tests__/mermaid-rendering.test.tsx && tsx src/__tests__/text-size.test.ts && tsx src/__tests__/font-availability.test.ts && tsx src/__tests__/theme-auto-background.test.ts && tsx src/__tests__/typography-overflow-contract.test.ts && tsx src/__tests__/ask-card-layout.test.ts && tsx src/__tests__/approval-modal-file-reference.test.tsx && tsx src/__tests__/provider-model-refresh.test.ts && tsx src/__tests__/composer-profile.test.ts && tsx src/__tests__/composer-history.test.ts && tsx src/__tests__/session-title-contract.test.ts && tsx src/__tests__/composer-keyboard.test.ts && tsx src/__tests__/composer-goal-toggle.test.tsx && tsx src/__tests__/composer-session-draft.test.tsx && tsx src/__tests__/composer-image-capability.test.tsx && tsx src/__tests__/use-controller-meta.test.ts && tsx src/__tests__/use-controller-cancel-reconcile.test.tsx && tsx src/__tests__/new-session-load-race.test.tsx && tsx src/__tests__/tab-switch-hydration.test.tsx && tsx src/__tests__/workspace-layout.test.ts && tsx src/__tests__/workspace-changes-errors.test.tsx && tsx src/__tests__/workspace-split.test.ts && tsx src/__tests__/workspace-preview-css.test.ts && tsx src/__tests__/resize-drag.test.ts && tsx src/__tests__/tool-approval-mode.test.ts && tsx src/__tests__/heartbeat-next-run.test.ts && tsx src/__tests__/keyboard-shortcuts.test.ts && tsx src/__tests__/send-failed.test.ts && tsx src/__tests__/edit-replay.test.ts && tsx src/__tests__/history-tool-status.test.ts && tsx src/__tests__/message-selection-copy.test.ts && tsx src/__tests__/code-block-copy-position.test.tsx && tsx src/__tests__/reasoning-display.test.ts && tsx src/__tests__/attachment-display.test.ts && tsx src/__tests__/crash-reporting.test.ts && tsx src/__tests__/bridge-drag-rejection.test.ts && tsx src/__tests__/startup-settings-contract.test.ts && tsx src/__tests__/bundle-contract.test.ts && tsx src/__tests__/command-palette-css.test.ts && tsx src/__tests__/command-palette-interactions.test.tsx && tsx src/__tests__/context-panel-breakdown.test.ts && tsx src/__tests__/statusbar-workspace.test.tsx && tsx src/__tests__/settings-refresh-snapshot.test.tsx && tsx src/__tests__/capabilities-panel-actions.test.ts && tsx src/__tests__/tool-data-archive.test.ts && tsx src/__tests__/tool-subject.test.ts && tsx src/__tests__/diff-rendering.test.ts && tsx src/__tests__/render-optimization.test.ts && tsx src/__tests__/transcript-grouping.test.ts && tsx src/__tests__/project-tree-runtime.test.ts && tsx src/__tests__/app-chrome-tabs.test.ts && tsx src/__tests__/open-topic-coalescing.test.tsx"
|
||||
"test:all": "tsc --noEmit -p tsconfig.test.json && node scripts/test-todo-visibility.mjs && tsx src/__tests__/anchored-popover-scroll.test.tsx && tsx src/__tests__/math-golden.test.ts && tsx src/__tests__/mermaid-rendering.test.tsx && tsx src/__tests__/text-size.test.ts && tsx src/__tests__/font-availability.test.ts && tsx src/__tests__/theme-auto-background.test.ts && tsx src/__tests__/typography-overflow-contract.test.ts && tsx src/__tests__/ask-card-layout.test.ts && tsx src/__tests__/approval-modal-file-reference.test.tsx && tsx src/__tests__/provider-model-refresh.test.ts && tsx src/__tests__/composer-profile.test.ts && tsx src/__tests__/composer-history.test.ts && tsx src/__tests__/session-title-contract.test.ts && tsx src/__tests__/composer-keyboard.test.ts && tsx src/__tests__/composer-goal-toggle.test.tsx && tsx src/__tests__/composer-session-draft.test.tsx && tsx src/__tests__/composer-image-capability.test.tsx && tsx src/__tests__/use-controller-meta.test.ts && tsx src/__tests__/use-controller-cancel-reconcile.test.tsx && tsx src/__tests__/new-session-load-race.test.tsx && tsx src/__tests__/tab-switch-hydration.test.tsx && tsx src/__tests__/workspace-layout.test.ts && tsx src/__tests__/workspace-changes-errors.test.tsx && tsx src/__tests__/workspace-split.test.ts && tsx src/__tests__/workspace-preview-css.test.ts && tsx src/__tests__/resize-drag.test.ts && tsx src/__tests__/wails-resize-fix.test.tsx && tsx src/__tests__/tool-approval-mode.test.ts && tsx src/__tests__/heartbeat-next-run.test.ts && tsx src/__tests__/keyboard-shortcuts.test.ts && tsx src/__tests__/send-failed.test.ts && tsx src/__tests__/edit-replay.test.ts && tsx src/__tests__/message-pasted-blocks.test.ts && tsx src/__tests__/history-tool-status.test.ts && tsx src/__tests__/message-selection-copy.test.ts && tsx src/__tests__/turn-action-copy.test.ts && tsx src/__tests__/code-block-copy-position.test.tsx && tsx src/__tests__/reasoning-display.test.ts && tsx src/__tests__/attachment-display.test.ts && tsx src/__tests__/crash-reporting.test.ts && tsx src/__tests__/bridge-drag-rejection.test.ts && tsx src/__tests__/startup-settings-contract.test.ts && tsx src/__tests__/bundle-contract.test.ts && tsx src/__tests__/command-palette-css.test.ts && tsx src/__tests__/command-palette-interactions.test.tsx && tsx src/__tests__/context-panel-breakdown.test.ts && tsx src/__tests__/statusbar-workspace.test.tsx && tsx src/__tests__/settings-refresh-snapshot.test.tsx && tsx src/__tests__/capabilities-panel-actions.test.ts && tsx src/__tests__/tool-data-archive.test.ts && tsx src/__tests__/tool-subject.test.ts && tsx src/__tests__/diff-rendering.test.ts && tsx src/__tests__/render-optimization.test.ts && tsx src/__tests__/transcript-grouping.test.ts && tsx src/__tests__/project-tree-runtime.test.ts && tsx src/__tests__/app-chrome-tabs.test.ts && tsx src/__tests__/open-topic-coalescing.test.tsx"
|
||||
},
|
||||
"dependencies": {
|
||||
"@gsap/react": "^2.1.2",
|
||||
|
||||
@@ -35,10 +35,11 @@ import {
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { useToast } from "./lib/toast";
|
||||
import { useWailsResizeFix } from "./lib/useWailsResizeFix";
|
||||
import { asArray } from "./lib/array";
|
||||
import { clearLegacyLangPref, normalizeLangPref, readLegacyLangPref, useI18n, useT, type Translator } from "./lib/i18n";
|
||||
import { useController, type Item, type LiveStream } from "./lib/useController";
|
||||
import { app, onEvent, onProjectTreeChanged } from "./lib/bridge";
|
||||
import { app, onEvent, onProjectTreeChanged, onSessionRecovered, onSessionRecoveryFailed } from "./lib/bridge";
|
||||
import { generativeMusic, isGenerativeMusicEnabled } from "./lib/generative-music";
|
||||
import { playSuccessChime } from "./lib/sound";
|
||||
import { Transcript } from "./components/Transcript";
|
||||
@@ -1030,6 +1031,9 @@ export default function App() {
|
||||
const rightDockMode = useLayoutStore((s) => s.rightDockMode);
|
||||
const setRightDockMode = useLayoutStore((s) => s.setRightDockMode);
|
||||
const [dockRefreshKey, setDockRefreshKey] = useState(0);
|
||||
const [fileRefRefreshKey, setFileRefRefreshKey] = useState(0);
|
||||
const refreshComposerFileRefs = useCallback(() => setFileRefRefreshKey((value) => value + 1), []);
|
||||
const composerFileRefRefreshKey = `${dockRefreshKey}:${fileRefRefreshKey}`;
|
||||
const [projectRevision, setProjectRevision] = useState(0);
|
||||
const [activeTopicTurns, setActiveTopicTurns] = useState<number | undefined>(undefined);
|
||||
const [composerInsertRequest, setComposerInsertRequest] = useState<ComposerInsertRequest | null>(null);
|
||||
@@ -1038,6 +1042,7 @@ export default function App() {
|
||||
const transientOverlayDismissSignal = useOverlayStore((s) => s.transientOverlayDismissSignal);
|
||||
const setTransientOverlayDismissSignal = useOverlayStore((s) => s.setTransientOverlayDismissSignal);
|
||||
const [desktopPlatform, setDesktopPlatform] = useState<DesktopPlatform>(detectBrowserPlatform);
|
||||
useWailsResizeFix(desktopPlatform === "windows");
|
||||
const [statusBarStyle, setStatusBarStyle] = useState<"icon" | "text">("text");
|
||||
const [statusBarItems, setStatusBarItems] = useState<StatusBarItemId[]>(() => [...DEFAULT_STATUS_BAR_ITEMS]);
|
||||
const [renamingTopicId, setRenamingTopicId] = useState<string | null>(null);
|
||||
@@ -2588,6 +2593,37 @@ export default function App() {
|
||||
enqueueNavigation({ kind: "blank", scope, workspaceRoot: scope === "project" ? workspaceRoot : "" }),
|
||||
[enqueueNavigation]);
|
||||
|
||||
useEffect(() => {
|
||||
return onSessionRecovered((event) => {
|
||||
const scope = event.scope === "project" ? "project" : "global";
|
||||
const workspaceRoot = scope === "project" ? event.workspaceRoot || "" : "";
|
||||
setProjectRevision((value) => value + 1);
|
||||
void refreshTabMetas();
|
||||
showToast(t("recovery.toast", { title: event.topicTitle || t("recovery.branch") }), "warn", event.topicId
|
||||
? {
|
||||
actionLabel: t("recovery.open"),
|
||||
durationMs: 9000,
|
||||
onAction: () => {
|
||||
void enqueueNavigation({
|
||||
kind: "topic",
|
||||
scope,
|
||||
workspaceRoot,
|
||||
topicId: event.topicId || "",
|
||||
sessionPath: event.recoveryPath || "",
|
||||
});
|
||||
},
|
||||
}
|
||||
: { durationMs: 9000 });
|
||||
});
|
||||
}, [enqueueNavigation, refreshTabMetas, showToast, t]);
|
||||
|
||||
useEffect(() => {
|
||||
return onSessionRecoveryFailed((event) => {
|
||||
const key = event.reason === "lease_held" ? "recovery.failedLease" : "recovery.failedUnavailable";
|
||||
showToast(t(key), "error", { durationMs: 9000 });
|
||||
});
|
||||
}, [showToast, t]);
|
||||
|
||||
const handleNewTab = useCallback(async () => {
|
||||
closeTransientOverlays();
|
||||
setSidebarImDetailConnectionId("");
|
||||
@@ -2848,6 +2884,13 @@ export default function App() {
|
||||
: [topicbarWorkspacePath || topicbarWorkspaceLabel, topicbarImSourceLabel].filter(Boolean).join(" · ");
|
||||
const topicbarCanRename = !sidebarImDetailConnection && Boolean(activeTab?.topicId);
|
||||
const topicbarTitleEditSize = Math.min(56, Math.max(4, topicTitleDraft.length || topicbarTitle.length || 1));
|
||||
const recoveryBannerTitle = activeTab?.recovered
|
||||
? [
|
||||
activeTab.recoveryReason ? t("recovery.reason", { reason: activeTab.recoveryReason }) : "",
|
||||
activeTab.recoveryDigest ? t("recovery.digest", { digest: activeTab.recoveryDigest.slice(0, 12) }) : "",
|
||||
activeTab.recoveryParentId ? t("recovery.parent", { parent: activeTab.recoveryParentId }) : "",
|
||||
].filter(Boolean).join(" · ")
|
||||
: "";
|
||||
const sidebarWorkbench = desktopLayoutStyle === "workbench";
|
||||
const windowsFramelessChrome = desktopPlatform === "windows";
|
||||
const handleWindowsTitlebarDoubleClick = useCallback((event: ReactMouseEvent<HTMLDivElement>) => {
|
||||
@@ -3399,6 +3442,13 @@ export default function App() {
|
||||
<div className="banner banner--error">{t("topbar.startupError", { msg: state.meta.startupErr })}</div>
|
||||
)}
|
||||
|
||||
{activeTab?.recovered && !sidebarImDetailConnection && (
|
||||
<div className="banner banner--recovery" title={recoveryBannerTitle || undefined}>
|
||||
<span className="banner__badge">{t("recovery.branch")}</span>
|
||||
<span>{t("recovery.banner")}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<UpdateBanner enabled={startupUpdateChecksEnabled === true} />
|
||||
|
||||
<main className="main">
|
||||
@@ -3538,6 +3588,7 @@ export default function App() {
|
||||
retry={state.retry}
|
||||
transientDismissSignal={transientOverlayDismissSignal}
|
||||
sessionKey={composerSessionKey}
|
||||
fileRefRefreshKey={composerFileRefRefreshKey}
|
||||
guidanceConsumedKey={latestGuidanceConsumed?.key}
|
||||
guidanceConsumedText={latestGuidanceConsumed?.text}
|
||||
guidanceQueuePreviewItems={guidanceQueueMockItems}
|
||||
@@ -3656,6 +3707,7 @@ export default function App() {
|
||||
onPreviewModeChange={handleWorkspacePreviewModeChange}
|
||||
onAddToChat={addWorkspaceTextToComposer}
|
||||
onRequestPanelWidth={ensureWorkspacePanelWidth}
|
||||
onFileTreeRefresh={refreshComposerFileRefs}
|
||||
refreshKey={dockRefreshKey}
|
||||
initialViewMode={rightDockMode === "changed" ? "changed" : "files"}
|
||||
showViewTabs={false}
|
||||
|
||||
@@ -69,6 +69,12 @@ ok(
|
||||
"AppChrome markup includes classic tab strip containers",
|
||||
);
|
||||
|
||||
ok(
|
||||
/const titlebarDragRail = darwinChrome \|\| platform === "windows";/.test(appChromeSource) &&
|
||||
/\{titlebarDragRail && <span className="app-chrome__drag-rail"/.test(appChromeSource),
|
||||
"AppChrome exposes the classic drag rail on macOS and Windows",
|
||||
);
|
||||
|
||||
ok(
|
||||
/const WORKSPACE_PANEL_DEFAULT_OPEN = false;/.test(layoutStoreSource) &&
|
||||
/workspacePanelOpen:\s*WORKSPACE_PANEL_DEFAULT_OPEN/.test(layoutStoreSource),
|
||||
@@ -276,6 +282,13 @@ for (const selector of [
|
||||
);
|
||||
}
|
||||
|
||||
ok(
|
||||
finalDeclaration(".app--windows-frameless:not(.app--workbench):not(.app--creation) .app-chrome--native-tabs .app-chrome__drag-rail", "--wails-draggable") === "drag" &&
|
||||
finalDeclaration(".app--windows-frameless:not(.app--workbench):not(.app--creation) .app-chrome--native-tabs .app-chrome__drag-rail", "right")?.includes("--windows-window-controls-safe") &&
|
||||
finalDeclaration(".app--windows .app-chrome--native-tabs .tabbar", "--wails-draggable") === "no-drag",
|
||||
"Windows classic chrome keeps a draggable rail while tabs remain clickable",
|
||||
);
|
||||
|
||||
for (const selector of [
|
||||
".layout--workbench-chrome-hidden",
|
||||
":root[data-theme-style] .layout--workbench-chrome-hidden",
|
||||
|
||||
@@ -4,6 +4,7 @@ import { JSDOM } from "jsdom";
|
||||
import React from "react";
|
||||
import { act } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import gsap from "gsap";
|
||||
import { ApprovalModal } from "../components/ApprovalModal";
|
||||
import { LocaleProvider } from "../lib/i18n";
|
||||
import type { AppBindings } from "../lib/bridge";
|
||||
@@ -12,6 +13,17 @@ import type { WireApproval } from "../lib/types";
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
|
||||
type GsapToOptions = { onComplete?: () => void };
|
||||
const gsapForTests = (typeof gsap.to === "function" ? gsap : (gsap as unknown as { default?: typeof gsap }).default) as unknown as {
|
||||
to?: (target: unknown, vars: GsapToOptions) => unknown;
|
||||
};
|
||||
if (typeof gsapForTests.to === "function") {
|
||||
gsapForTests.to = (_target: unknown, vars: GsapToOptions) => {
|
||||
vars.onComplete?.();
|
||||
return {};
|
||||
};
|
||||
}
|
||||
|
||||
function ok(value: boolean, label: string) {
|
||||
if (value) {
|
||||
process.stdout.write(` PASS ${label}\n`);
|
||||
@@ -225,5 +237,48 @@ console.log("\napproval modal file references");
|
||||
dom.window.close();
|
||||
}
|
||||
|
||||
{
|
||||
const dom = installDom();
|
||||
mockApp({
|
||||
ListDir: async () => [],
|
||||
SearchFileRefs: async () => [],
|
||||
});
|
||||
const answers: Array<[boolean, boolean, boolean]> = [];
|
||||
const { root } = await renderApproval({
|
||||
approval: {
|
||||
id: "memory-approval",
|
||||
tool: "remember",
|
||||
subject: "Save/update memory \"prefers-vitest\": Preferred test framework",
|
||||
},
|
||||
onAnswer: (allow, session, persist) => answers.push([allow, session, persist]),
|
||||
});
|
||||
|
||||
const text = document.body.textContent ?? "";
|
||||
ok(text.includes("Allow once"), "fresh-human approval shows allow once");
|
||||
ok(text.includes("Deny"), "fresh-human approval shows deny");
|
||||
ok(!text.includes("Allow for session"), "fresh-human approval hides session grant");
|
||||
ok(!text.includes("Always allow"), "fresh-human approval hides persistent grant");
|
||||
eq(
|
||||
Array.from(document.querySelectorAll(".prompt-shelf__actions button")).map((button) => button.textContent).join("|"),
|
||||
"1Allow once|4Deny",
|
||||
"fresh-human approval keeps allow/deny shortcut keys",
|
||||
);
|
||||
|
||||
const allowButton = Array.from(document.querySelectorAll("button")).find((button) => button.textContent?.includes("Allow once")) as HTMLButtonElement | undefined;
|
||||
if (!allowButton) throw new Error("allow once button did not render");
|
||||
|
||||
await act(async () => {
|
||||
allowButton.click();
|
||||
await flushTimers();
|
||||
});
|
||||
|
||||
eq(JSON.stringify(answers), JSON.stringify([[true, false, false]]), "fresh-human approval allows only once");
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
dom.window.close();
|
||||
}
|
||||
|
||||
console.log(`\n${passed} passed, ${failed} failed, ${passed + failed} total`);
|
||||
if (failed > 0) process.exit(1);
|
||||
@@ -43,7 +43,7 @@ ok(
|
||||
"App loads secondary drawers on demand",
|
||||
);
|
||||
ok(
|
||||
!/import\s+\{[^}]*\b(?:MCPServersSettingsPage|SkillsSettingsPage)\b[^}]*\}\s+from\s+["']\.\/CapabilitiesPanel["']/.test(settingsSource) &&
|
||||
!/import\s+\{[^}]*\b(?:MCPServersSettingsPage|SkillsSettingsPage|PluginsSettingsPage)\b[^}]*\}\s+from\s+["']\.\/CapabilitiesPanel["']/.test(settingsSource) &&
|
||||
!/import\s+\{[^}]*\bMemorySettingsPage\b[^}]*\}\s+from\s+["']\.\/MemoryPanel["']/.test(settingsSource),
|
||||
"SettingsPanel keeps secondary settings pages out of the first settings chunk",
|
||||
);
|
||||
|
||||
@@ -2,11 +2,11 @@ import { JSDOM } from "jsdom";
|
||||
import React from "react";
|
||||
import { act } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { MCPServersSettingsPage } from "../components/CapabilitiesPanel";
|
||||
import { MCPServersSettingsPage, PluginsSettingsPage } from "../components/CapabilitiesPanel";
|
||||
import type { AppBindings } from "../lib/bridge";
|
||||
import { LocaleProvider } from "../lib/i18n";
|
||||
import { mcpServerLifecycleActions, mcpServerRetryableFromAvailableList } from "../lib/mcpServerLifecycle";
|
||||
import type { Meta, ServerView, TabMeta } from "../lib/types";
|
||||
import type { Meta, PluginInstallOptions, PluginView, ServerView, TabMeta } from "../lib/types";
|
||||
|
||||
function ok(value: unknown, message: string) {
|
||||
if (!value) throw new Error(message);
|
||||
@@ -80,6 +80,7 @@ function installDom() {
|
||||
globalThis.Node = dom.window.Node;
|
||||
globalThis.HTMLElement = dom.window.HTMLElement;
|
||||
globalThis.HTMLButtonElement = dom.window.HTMLButtonElement;
|
||||
globalThis.HTMLInputElement = dom.window.HTMLInputElement;
|
||||
globalThis.Event = dom.window.Event;
|
||||
globalThis.KeyboardEvent = dom.window.KeyboardEvent;
|
||||
globalThis.MouseEvent = dom.window.MouseEvent;
|
||||
@@ -106,6 +107,16 @@ function findButton(label: string): HTMLButtonElement | undefined {
|
||||
return Array.from(document.querySelectorAll("button")).find((button) => button.textContent?.trim() === label) as HTMLButtonElement | undefined;
|
||||
}
|
||||
|
||||
function setInputValue(input: HTMLInputElement, value: string) {
|
||||
const win = input.ownerDocument.defaultView;
|
||||
const setter = Object.getOwnPropertyDescriptor((win?.HTMLInputElement ?? HTMLInputElement).prototype, "value")?.set;
|
||||
setter?.call(input, value);
|
||||
const eventCtor = win?.Event ?? Event;
|
||||
const inputEventCtor = win?.InputEvent ?? eventCtor;
|
||||
input.dispatchEvent(new inputEventCtor("input", { bubbles: true, data: value, inputType: "insertText" } as InputEventInit));
|
||||
input.dispatchEvent(new eventCtor("change", { bubbles: true }));
|
||||
}
|
||||
|
||||
console.log("capabilities panel MCP actions");
|
||||
|
||||
{
|
||||
@@ -233,3 +244,221 @@ console.log("capabilities panel MCP actions");
|
||||
});
|
||||
dom.window.close();
|
||||
}
|
||||
|
||||
console.log("capabilities panel plugin actions");
|
||||
|
||||
{
|
||||
const dom = installDom();
|
||||
const rootEl = document.getElementById("root");
|
||||
if (!rootEl) throw new Error("missing root");
|
||||
const root = createRoot(rootEl);
|
||||
const meta: Meta = { label: "test", ready: true, eventChannel: "plugin-channel", cwd: "/tmp/reasonix-test", workspaceRoot: "/tmp/reasonix-test" };
|
||||
const tabs: TabMeta[] = [{
|
||||
id: "tab-plugin",
|
||||
scope: "project",
|
||||
workspaceRoot: "/tmp/reasonix-test",
|
||||
workspaceName: "reasonix-test",
|
||||
topicId: "topic-plugin",
|
||||
topicTitle: "Plugins",
|
||||
label: "Plugins",
|
||||
ready: true,
|
||||
running: false,
|
||||
mode: "normal",
|
||||
toolApprovalMode: "auto",
|
||||
active: true,
|
||||
cwd: "/tmp/reasonix-test",
|
||||
}];
|
||||
let planCalls = 0;
|
||||
let installCalls = 0;
|
||||
let toggleCalls = 0;
|
||||
let updateCalls = 0;
|
||||
let doctorCalls = 0;
|
||||
let removeCalls = 0;
|
||||
let pickFolderCalls = 0;
|
||||
const plannedSources: string[] = [];
|
||||
const installedSources: string[] = [];
|
||||
let plugins: PluginView[] = [{
|
||||
name: "superpowers",
|
||||
version: "0.1.0",
|
||||
description: "Shared agent skills and hooks.",
|
||||
source: "git:github.com/obra/superpowers",
|
||||
root: "~/.reasonix/plugins/superpowers",
|
||||
manifestKind: "reasonix",
|
||||
enabled: true,
|
||||
skills: 2,
|
||||
hooks: 1,
|
||||
mcpServers: 0,
|
||||
}];
|
||||
window.go = {
|
||||
main: {
|
||||
App: {
|
||||
Meta: async () => meta,
|
||||
ListTabs: async () => tabs,
|
||||
Plugins: async () => plugins.map((plugin) => ({ ...plugin, warnings: [...(plugin.warnings ?? [])] })),
|
||||
PlanPluginInstall: async (source: string, options: PluginInstallOptions) => {
|
||||
planCalls += 1;
|
||||
plannedSources.push(source);
|
||||
ok(options.dryRun === true, "plugin preview asks for dry-run planning");
|
||||
return JSON.stringify({
|
||||
ok: true,
|
||||
status: "planned",
|
||||
name: "superpowers",
|
||||
actions: [{ kind: "plugin", action: "install_plugin_package", name: "superpowers", source, status: "planned" }],
|
||||
});
|
||||
},
|
||||
InstallPlugin: async (source: string, _options: PluginInstallOptions) => {
|
||||
installCalls += 1;
|
||||
installedSources.push(source);
|
||||
const next: PluginView = {
|
||||
name: "superpowers",
|
||||
version: "0.1.1",
|
||||
description: "Shared agent skills and hooks.",
|
||||
source,
|
||||
root: "~/.reasonix/plugins/superpowers",
|
||||
manifestKind: "reasonix",
|
||||
enabled: true,
|
||||
skills: 3,
|
||||
hooks: 1,
|
||||
mcpServers: 1,
|
||||
skillDetails: [{ name: "plan", description: "Plan work before implementation.", invocation: "/plan", runAs: "inline" }],
|
||||
hookDetails: [{ event: "SessionStart", contextFile: "CLAUDE.md", description: "Load startup context." }],
|
||||
mcpServerDetails: [{ name: "context", transport: "stdio", command: "node server.js" }],
|
||||
};
|
||||
plugins = plugins.filter((plugin) => plugin.name !== next.name).concat(next);
|
||||
return JSON.stringify({ ok: true, status: "done", actions: [{ action: "install_plugin_package", name: next.name, status: "done" }] });
|
||||
},
|
||||
SetPluginEnabled: async (name: string, enabled: boolean) => {
|
||||
toggleCalls += 1;
|
||||
plugins = plugins.map((plugin) => plugin.name === name ? { ...plugin, enabled } : plugin);
|
||||
},
|
||||
UpdatePlugin: async (name: string) => {
|
||||
updateCalls += 1;
|
||||
plugins = plugins.map((plugin) => plugin.name === name ? { ...plugin, version: "0.1.2" } : plugin);
|
||||
return JSON.stringify({ ok: true, status: "done", name });
|
||||
},
|
||||
PluginDoctor: async (name: string) => {
|
||||
doctorCalls += 1;
|
||||
return { ...(plugins.find((plugin) => plugin.name === name) ?? plugins[0]), warnings: ["manifest exports no MCP auth metadata"] };
|
||||
},
|
||||
RemovePlugin: async (name: string) => {
|
||||
removeCalls += 1;
|
||||
plugins = plugins.filter((plugin) => plugin.name !== name);
|
||||
},
|
||||
PickPluginFolder: async () => {
|
||||
pickFolderCalls += 1;
|
||||
return "/tmp/superpowers-plugin";
|
||||
},
|
||||
} as Partial<AppBindings> as AppBindings,
|
||||
},
|
||||
};
|
||||
|
||||
await act(async () => {
|
||||
root.render(React.createElement(LocaleProvider, null, React.createElement(PluginsSettingsPage)));
|
||||
await flush();
|
||||
});
|
||||
await waitFor("superpowers plugin row", () => Boolean(document.querySelector(".cap-row__name")?.textContent?.includes("superpowers")));
|
||||
ok(Boolean(document.querySelector(".cap-plugin-form-grid .cap-plugin-fields--local")), "local plugin install mode uses the shared form grid");
|
||||
const localOptionTexts = Array.from(document.querySelectorAll(".cap-plugin-installer__options > .cap-plugin-option-block"))
|
||||
.map((option) => option.textContent ?? "");
|
||||
ok(localOptionTexts[0]?.includes("Overwrite same-name plugin"), "local install mode shows overwrite before link mode");
|
||||
ok(localOptionTexts[1]?.includes("Developer mode: link source folder"), "local install mode shows link mode after overwrite");
|
||||
|
||||
const chooseFolder = findButton("Choose plugin folder");
|
||||
if (!chooseFolder) throw new Error("missing plugin folder picker button");
|
||||
await act(async () => {
|
||||
chooseFolder.click();
|
||||
await flush();
|
||||
});
|
||||
await waitFor("picked plugin folder source", () => document.body.textContent?.includes("/tmp/superpowers-plugin") ?? false);
|
||||
ok(pickFolderCalls === 1, "clicking Choose folder invokes the plugin folder picker once");
|
||||
|
||||
const gitMode = findButton("Git repository");
|
||||
if (!gitMode) throw new Error("missing Git repository install mode");
|
||||
await act(async () => {
|
||||
gitMode.click();
|
||||
await flush();
|
||||
});
|
||||
ok(Boolean(document.querySelector(".cap-plugin-form-grid .cap-plugin-fields--git")), "Git plugin install mode uses the shared form grid");
|
||||
const sourceInput = document.querySelector<HTMLInputElement>('input[aria-label="Git repository URL"]');
|
||||
if (!sourceInput) throw new Error("missing plugin git source input");
|
||||
await act(async () => {
|
||||
setInputValue(sourceInput, "git:github.com/obra/superpowers");
|
||||
await flush();
|
||||
});
|
||||
await waitFor("plugin preview enabled", () => findButton("Preview")?.disabled === false);
|
||||
|
||||
const preview = findButton("Preview");
|
||||
if (!preview) throw new Error("missing plugin preview button");
|
||||
await act(async () => {
|
||||
preview.click();
|
||||
await flush();
|
||||
});
|
||||
await waitFor("plugin install plan", () => document.body.textContent?.includes("install_plugin_package") ?? false);
|
||||
ok(planCalls === 1, "clicking Preview invokes plugin install planning once");
|
||||
ok(plannedSources[0] === "git:github.com/obra/superpowers", "plugin preview receives the entered Git source");
|
||||
|
||||
const install = findButton("Install plugin");
|
||||
if (!install) throw new Error("missing plugin install button");
|
||||
await act(async () => {
|
||||
install.click();
|
||||
await flush();
|
||||
});
|
||||
await waitFor("plugin install result", () => installCalls === 1 && plugins[0]?.version === "0.1.1");
|
||||
ok(installedSources[0] === "git:github.com/obra/superpowers", "plugin install receives the entered Git source");
|
||||
|
||||
const disclosure = document.querySelector<HTMLButtonElement>(".cap-plugin-entry .cap-disclosure");
|
||||
if (!disclosure) throw new Error("missing plugin disclosure");
|
||||
await act(async () => {
|
||||
disclosure.click();
|
||||
await flush();
|
||||
});
|
||||
await waitFor("plugin update action", () => Boolean(findButton("Update")));
|
||||
ok(document.body.textContent?.includes("How to use") ?? false, "expanded plugin details explain how to use the plugin");
|
||||
ok(document.body.textContent?.includes("/plan") ?? false, "expanded plugin details list exported skill invocations");
|
||||
ok(document.body.textContent?.includes("SessionStart") ?? false, "expanded plugin details list exported hooks");
|
||||
ok(document.body.textContent?.includes("context") ?? false, "expanded plugin details list exported MCP servers");
|
||||
|
||||
const update = findButton("Update");
|
||||
if (!update) throw new Error("missing plugin update button");
|
||||
await act(async () => {
|
||||
update.click();
|
||||
await flush();
|
||||
});
|
||||
await waitFor("plugin update call", () => updateCalls === 1 && plugins[0]?.version === "0.1.2");
|
||||
|
||||
const doctor = findButton("Doctor");
|
||||
if (!doctor) throw new Error("missing plugin doctor button");
|
||||
await act(async () => {
|
||||
doctor.click();
|
||||
await flush();
|
||||
});
|
||||
await waitFor("plugin diagnostic warning", () => document.body.textContent?.includes("manifest exports no MCP auth metadata") ?? false);
|
||||
ok(doctorCalls === 1, "clicking Doctor invokes plugin diagnostics once");
|
||||
|
||||
const toggle = document.querySelector<HTMLInputElement>(".cap-plugin-entry .cap-switch input");
|
||||
if (!toggle) throw new Error("missing plugin enable toggle");
|
||||
await act(async () => {
|
||||
toggle.click();
|
||||
await flush();
|
||||
});
|
||||
await waitFor("plugin disabled", () => toggleCalls === 1 && plugins[0]?.enabled === false);
|
||||
|
||||
const remove = findButton("Remove plugin");
|
||||
if (!remove) throw new Error("missing plugin remove button");
|
||||
await act(async () => {
|
||||
remove.click();
|
||||
await flush();
|
||||
});
|
||||
const confirmRemove = findButton("Confirm remove");
|
||||
if (!confirmRemove) throw new Error("missing plugin confirm remove button");
|
||||
await act(async () => {
|
||||
confirmRemove.click();
|
||||
await flush();
|
||||
});
|
||||
await waitFor("plugin removed", () => removeCalls === 1 && plugins.length === 0);
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
dom.window.close();
|
||||
}
|
||||
@@ -8,7 +8,7 @@ import { Composer, composerPickFileEntry } from "../components/Composer";
|
||||
import { LocaleProvider } from "../lib/i18n";
|
||||
import { ToastProvider } from "../lib/toast";
|
||||
import type { AppBindings } from "../lib/bridge";
|
||||
import type { CollaborationMode, ToolApprovalMode, TokenMode } from "../lib/types";
|
||||
import type { CollaborationMode, DirEntry, ToolApprovalMode, TokenMode } from "../lib/types";
|
||||
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
@@ -200,6 +200,16 @@ async function waitFor(label: string, predicate: () => boolean) {
|
||||
throw new Error(`timed out waiting for ${label}`);
|
||||
}
|
||||
|
||||
type RenderedComposer = Awaited<ReturnType<typeof renderComposer>>;
|
||||
|
||||
function fileEntry(name: string): DirEntry {
|
||||
return { name, isDir: false };
|
||||
}
|
||||
|
||||
async function replaceComposerDraft(rerender: RenderedComposer["rerender"], id: number, text: string) {
|
||||
await rerender({ insertRequest: { id, text, mode: "replace" } });
|
||||
}
|
||||
|
||||
console.log("\ncomposer goal toggle");
|
||||
|
||||
{
|
||||
@@ -739,5 +749,156 @@ console.log("\ncomposer goal toggle");
|
||||
dom.window.close();
|
||||
}
|
||||
|
||||
{
|
||||
const dom = installDom();
|
||||
let listDirCalls = 0;
|
||||
mockApp({
|
||||
ListDir: async () => {
|
||||
listDirCalls += 1;
|
||||
return listDirCalls === 1 ? [fileEntry("cached-dir.txt")] : [fileEntry("fresh-dir.txt")];
|
||||
},
|
||||
SearchFileRefs: async () => [],
|
||||
});
|
||||
const { root, rerender } = await renderComposer();
|
||||
|
||||
await replaceComposerDraft(rerender, 101, "@");
|
||||
await waitFor("initial @ directory load", () => listDirCalls === 1);
|
||||
|
||||
await replaceComposerDraft(rerender, 102, "");
|
||||
await replaceComposerDraft(rerender, 103, "@");
|
||||
await waitFor("@ directory revalidation call", () => listDirCalls === 2);
|
||||
|
||||
eq(listDirCalls, 2, "@ directory cache hit still revalidates ListDir");
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
dom.window.close();
|
||||
}
|
||||
|
||||
{
|
||||
const dom = installDom();
|
||||
let listDirCalls = 0;
|
||||
mockApp({
|
||||
ListDir: async () => {
|
||||
listDirCalls += 1;
|
||||
return listDirCalls === 1 ? [fileEntry("manual-refresh-stale.txt")] : [fileEntry("manual-refresh-fresh.txt")];
|
||||
},
|
||||
SearchFileRefs: async () => [],
|
||||
});
|
||||
const { root, rerender } = await renderComposer({ fileRefRefreshKey: "0" });
|
||||
|
||||
await replaceComposerDraft(rerender, 201, "@");
|
||||
await waitFor("initial @ directory load before refresh key", () => listDirCalls === 1);
|
||||
|
||||
await rerender({ fileRefRefreshKey: "1" });
|
||||
await waitFor("@ directory reload after refresh key", () => listDirCalls === 2);
|
||||
|
||||
eq(listDirCalls, 2, "fileRefRefreshKey refreshes @ directory cache while the menu is open");
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
dom.window.close();
|
||||
}
|
||||
|
||||
{
|
||||
const dom = installDom();
|
||||
const realDateNow = Date.now;
|
||||
let now = 1000;
|
||||
let searchCalls = 0;
|
||||
Date.now = () => now;
|
||||
mockApp({
|
||||
ListDir: async () => [],
|
||||
SearchFileRefs: async () => {
|
||||
searchCalls += 1;
|
||||
return searchCalls === 1 ? [fileEntry("alpha-old.ts")] : [fileEntry("alpha-new.ts")];
|
||||
},
|
||||
});
|
||||
const { root, rerender } = await renderComposer();
|
||||
|
||||
try {
|
||||
await replaceComposerDraft(rerender, 301, "@alpha");
|
||||
await waitFor("initial @ search request", () => searchCalls === 1);
|
||||
eq(searchCalls, 1, "@ search fetches the first query");
|
||||
|
||||
await replaceComposerDraft(rerender, 302, "");
|
||||
now = 2000;
|
||||
await replaceComposerDraft(rerender, 303, "@alpha");
|
||||
await act(async () => {
|
||||
await flushTimers();
|
||||
});
|
||||
eq(searchCalls, 1, "@ search cache is reused inside the TTL");
|
||||
|
||||
await replaceComposerDraft(rerender, 304, "");
|
||||
now = 7001;
|
||||
await replaceComposerDraft(rerender, 305, "@alpha");
|
||||
await waitFor("expired @ search cache refresh", () => searchCalls === 2);
|
||||
eq(searchCalls, 2, "@ search cache revalidates after the TTL");
|
||||
} finally {
|
||||
Date.now = realDateNow;
|
||||
}
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
dom.window.close();
|
||||
}
|
||||
|
||||
{
|
||||
const dom = installDom();
|
||||
let staleListDirResolve: ((entries: DirEntry[]) => void) | undefined;
|
||||
let thirdListDirResolve: ((entries: DirEntry[]) => void) | undefined;
|
||||
let listDirCalls = 0;
|
||||
mockApp({
|
||||
ListDir: async () => {
|
||||
listDirCalls += 1;
|
||||
if (listDirCalls === 1) {
|
||||
return new Promise<DirEntry[]>((resolve) => {
|
||||
staleListDirResolve = resolve;
|
||||
});
|
||||
}
|
||||
if (listDirCalls === 2) return [fileEntry("cache-live.txt")];
|
||||
return new Promise<DirEntry[]>((resolve) => {
|
||||
thirdListDirResolve = resolve;
|
||||
});
|
||||
},
|
||||
SearchFileRefs: async () => [],
|
||||
});
|
||||
const { root, rerender } = await renderComposer({ fileRefRefreshKey: "0" });
|
||||
|
||||
const textarea = document.querySelector("textarea") as HTMLTextAreaElement | null;
|
||||
if (!textarea) throw new Error("composer textarea did not render");
|
||||
|
||||
await replaceComposerDraft(rerender, 401, "@cache");
|
||||
await waitFor("initial stale @ directory request", () => listDirCalls === 1);
|
||||
|
||||
await rerender({ fileRefRefreshKey: "1" });
|
||||
await waitFor("fresh @ directory request after refresh key", () => listDirCalls === 2);
|
||||
await act(async () => {
|
||||
await flushTimers();
|
||||
});
|
||||
|
||||
staleListDirResolve?.([fileEntry("cache-stale.txt")]);
|
||||
await act(async () => {
|
||||
await flushTimers();
|
||||
});
|
||||
|
||||
await replaceComposerDraft(rerender, 402, "");
|
||||
await replaceComposerDraft(rerender, 403, "@cache");
|
||||
await waitFor("second fresh @ directory request", () => listDirCalls === 3);
|
||||
await act(async () => {
|
||||
textarea.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true, cancelable: true }));
|
||||
await flushTimers();
|
||||
});
|
||||
eq(textarea.value, "@cache-live.txt ", "stale @ directory request cannot repopulate cache after refresh");
|
||||
thirdListDirResolve?.([fileEntry("cache-later.txt")]);
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
dom.window.close();
|
||||
}
|
||||
|
||||
console.log(`\n${passed} passed, ${failed} failed, ${passed + failed} total`);
|
||||
if (failed > 0) process.exit(1);
|
||||
@@ -5,6 +5,7 @@ import React from "react";
|
||||
import { act } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { Composer } from "../components/Composer";
|
||||
import { UserMessage } from "../components/Message";
|
||||
import { LocaleProvider } from "../lib/i18n";
|
||||
import { ToastProvider } from "../lib/toast";
|
||||
import type { CollaborationMode, TokenMode, ToolApprovalMode } from "../lib/types";
|
||||
@@ -137,7 +138,9 @@ async function renderComposer(props: Partial<Parameters<typeof Composer>[0]> = {
|
||||
root.render(
|
||||
<LocaleProvider>
|
||||
<ToastProvider>
|
||||
<Composer {...currentProps} />
|
||||
<div className="chat-pane">
|
||||
<Composer {...currentProps} />
|
||||
</div>
|
||||
</ToastProvider>
|
||||
</LocaleProvider>,
|
||||
);
|
||||
@@ -183,6 +186,29 @@ function imagePasteEvent(file: File): Event {
|
||||
return event;
|
||||
}
|
||||
|
||||
function imageViewerOpen(): boolean {
|
||||
return Boolean(document.querySelector(".image-viewer-backdrop .image-viewer__image"));
|
||||
}
|
||||
|
||||
function renderUserMessage(text: string, props: Partial<Parameters<typeof UserMessage>[0]> = {}) {
|
||||
const rootEl = document.getElementById("root");
|
||||
if (!rootEl) throw new Error("missing root");
|
||||
const root = createRoot(rootEl);
|
||||
const paint = async () => {
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<LocaleProvider>
|
||||
<div className="chat-pane">
|
||||
<UserMessage text={text} {...props} />
|
||||
</div>
|
||||
</LocaleProvider>,
|
||||
);
|
||||
await flushTimers();
|
||||
});
|
||||
};
|
||||
return { root, paint };
|
||||
}
|
||||
|
||||
console.log("\ncomposer image capability");
|
||||
|
||||
{
|
||||
@@ -255,5 +281,107 @@ console.log("\ncomposer image capability");
|
||||
dom.window.close();
|
||||
}
|
||||
|
||||
{
|
||||
const dom = installDom();
|
||||
installBridgeApp({
|
||||
SavePastedImage: async () => ".reasonix/attachments/mock.png",
|
||||
AttachmentDataURL: async () => "data:image/png;base64,iVBORw0KGgo=",
|
||||
});
|
||||
const { root } = await renderComposer({ imageInputEnabled: true });
|
||||
const file = new File(["img"], "photo.png", { type: "image/png", lastModified: 1 });
|
||||
|
||||
await act(async () => {
|
||||
textarea().dispatchEvent(imagePasteEvent(file));
|
||||
await flushTimers();
|
||||
await flushTimers();
|
||||
});
|
||||
await waitFor(() => Boolean(document.querySelector(".composer-context__thumb img")));
|
||||
const thumb = document.querySelector(".composer-context__thumb") as HTMLElement | null;
|
||||
if (!thumb) throw new Error("missing composer image thumbnail");
|
||||
await act(async () => {
|
||||
thumb.click();
|
||||
await flushTimers();
|
||||
});
|
||||
await waitFor(imageViewerOpen);
|
||||
ok(imageViewerOpen(), "composer image thumbnail opens the image viewer");
|
||||
|
||||
await act(async () => {
|
||||
document.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true }));
|
||||
await flushTimers();
|
||||
});
|
||||
await waitFor(() => !document.querySelector(".image-viewer-backdrop"));
|
||||
ok(!document.querySelector(".image-viewer-backdrop"), "composer image viewer closes on Escape");
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
dom.window.close();
|
||||
}
|
||||
|
||||
{
|
||||
const dom = installDom();
|
||||
installBridgeApp({
|
||||
AttachmentDataURL: async () => "data:image/png;base64,iVBORw0KGgo=",
|
||||
});
|
||||
const { root, paint } = renderUserMessage("check @[photo.png](.reasonix/attachments/mock.png)");
|
||||
await paint();
|
||||
await waitFor(() => Boolean(document.querySelector(".msg-attachment--image img")));
|
||||
const thumb = document.querySelector(".msg-attachment--image") as HTMLElement | null;
|
||||
if (!thumb) throw new Error("missing message image thumbnail");
|
||||
await act(async () => {
|
||||
thumb.click();
|
||||
await flushTimers();
|
||||
});
|
||||
await waitFor(() => Boolean(document.querySelector(".chat-pane > .image-viewer-backdrop")));
|
||||
ok(Boolean(document.querySelector(".chat-pane > .image-viewer-backdrop")), "sent message image preview portals into the chat pane");
|
||||
|
||||
const close = document.querySelector(".image-viewer__close") as HTMLButtonElement | null;
|
||||
if (!close) throw new Error("missing image viewer close button");
|
||||
await act(async () => {
|
||||
close.click();
|
||||
await flushTimers();
|
||||
});
|
||||
await waitFor(() => !document.querySelector(".image-viewer-backdrop"));
|
||||
ok(!document.querySelector(".image-viewer-backdrop"), "sent message image viewer closes from the close button");
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
dom.window.close();
|
||||
}
|
||||
|
||||
{
|
||||
const dom = installDom();
|
||||
installBridgeApp({
|
||||
AttachmentDataURL: async () => "data:image/png;base64,iVBORw0KGgo=",
|
||||
});
|
||||
const { root, paint } = renderUserMessage("check @[photo.png](.reasonix/attachments/mock.png)", {
|
||||
turn: 1,
|
||||
onEdit: () => true,
|
||||
});
|
||||
await paint();
|
||||
await waitFor(() => Boolean(document.querySelector(".msg-attachment--image img")));
|
||||
const edit = document.querySelector("button.msg-meta__btn:not(.msg-meta__copy)") as HTMLButtonElement | null;
|
||||
if (!edit) throw new Error("missing message edit button");
|
||||
await act(async () => {
|
||||
edit.click();
|
||||
await flushTimers();
|
||||
});
|
||||
await waitFor(() => Boolean(document.querySelector(".msg-edit .composer-context__thumb img")));
|
||||
const thumb = document.querySelector(".msg-edit .composer-context__thumb") as HTMLElement | null;
|
||||
if (!thumb) throw new Error("missing edit image thumbnail");
|
||||
await act(async () => {
|
||||
thumb.click();
|
||||
await flushTimers();
|
||||
});
|
||||
await waitFor(imageViewerOpen);
|
||||
ok(imageViewerOpen(), "edit message image thumbnail opens the image viewer");
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
dom.window.close();
|
||||
}
|
||||
|
||||
console.log(`\n${passed} passed, ${failed} failed, ${passed + failed} total`);
|
||||
if (failed > 0) process.exit(1);
|
||||
@@ -1,6 +1,6 @@
|
||||
// Run: tsx src/__tests__/context-panel-breakdown.test.ts
|
||||
|
||||
import { cacheHitTone, contextBreakdown, contextCostDisplay, contextSourceRows, contextWindowStatus, formatCacheHitRate, formatMetricTokens } from "../components/ContextPanel";
|
||||
import { cacheHitTone, contextBreakdown, contextCostDisplay, contextSourceRows, contextUsageRefreshKey, contextWindowStatus, formatCacheHitRate, formatMetricTokens } from "../components/ContextPanel";
|
||||
import { currencySymbol, formatMoney, formatMoneyLocalized } from "../lib/money";
|
||||
|
||||
let passed = 0;
|
||||
@@ -130,6 +130,28 @@ eq(cacheHitTone(6000, 4000), "notice", "mid cache hit rate uses notice tone");
|
||||
eq(cacheHitTone(5999, 4001), "warn", "low cache hit rate uses warning tone");
|
||||
eq(cacheHitTone(0, 0), undefined, "missing cache data stays uncolored");
|
||||
|
||||
console.log("\ncontext panel usage refresh key");
|
||||
|
||||
eq(contextUsageRefreshKey(undefined), "", "missing usage does not request a streaming refresh");
|
||||
ok(
|
||||
contextUsageRefreshKey({
|
||||
totalTokens: 10,
|
||||
promptTokens: 10,
|
||||
completionTokens: 0,
|
||||
reasoningTokens: 0,
|
||||
sessionCacheHitTokens: 0,
|
||||
sessionCacheMissTokens: 0,
|
||||
}) !== contextUsageRefreshKey({
|
||||
totalTokens: 11,
|
||||
promptTokens: 10,
|
||||
completionTokens: 1,
|
||||
reasoningTokens: 0,
|
||||
sessionCacheHitTokens: 0,
|
||||
sessionCacheMissTokens: 0,
|
||||
}),
|
||||
"general token changes refresh even when cache counters stay unchanged",
|
||||
);
|
||||
|
||||
console.log("\ncontext panel source rows");
|
||||
|
||||
const sourceRows = contextSourceRows({
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
// Run: tsx src/__tests__/message-pasted-blocks.test.ts
|
||||
|
||||
import { parsePastedBlocks } from "../components/Message";
|
||||
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
|
||||
function eq(a: unknown, b: unknown, label: string) {
|
||||
if (JSON.stringify(a) === JSON.stringify(b)) {
|
||||
process.stdout.write(` PASS ${label}\n`);
|
||||
passed += 1;
|
||||
} else {
|
||||
process.stdout.write(` FAIL ${label}: expected ${JSON.stringify(b)}, got ${JSON.stringify(a)}\n`);
|
||||
failed += 1;
|
||||
}
|
||||
}
|
||||
|
||||
function wrapped(label: string, content: string): string {
|
||||
return `${label}\n\n--- Begin ${label} ---\n${content}\n--- End ${label} ---`;
|
||||
}
|
||||
|
||||
console.log("\nmessage pasted blocks");
|
||||
|
||||
for (const [label, name] of [
|
||||
["[已粘贴文本 #2 · 31 行]", "Simplified Chinese"],
|
||||
["[已貼上文字 #2 · 31 行]", "Traditional Chinese"],
|
||||
["[Pasted text #2 · 31 lines]", "English"],
|
||||
] as const) {
|
||||
eq(
|
||||
parsePastedBlocks(`before\n${label}\nafter`, wrapped(label, "line 1\nline 2")),
|
||||
[{ label, content: "line 1\nline 2\n" }],
|
||||
`${name} pasted text labels are parsed from submit text`,
|
||||
);
|
||||
}
|
||||
|
||||
eq(parsePastedBlocks("[unknown paste #1]", "--- Begin [unknown paste #1] ---\nnope\n--- End [unknown paste #1] ---"), [], "unknown labels are ignored");
|
||||
|
||||
console.log(`\n${passed} passed, ${failed} failed`);
|
||||
if (failed > 0) process.exit(1);
|
||||
@@ -74,7 +74,7 @@ function tabMeta(overrides: Partial<TabMeta> = {}): TabMeta {
|
||||
};
|
||||
}
|
||||
|
||||
function meta(): Meta {
|
||||
function meta(overrides: Partial<Meta> = {}): Meta {
|
||||
return {
|
||||
label: "model",
|
||||
ready: true,
|
||||
@@ -91,6 +91,7 @@ function meta(): Meta {
|
||||
tokenMode: "full",
|
||||
goal: "",
|
||||
goalStatus: "stopped",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -211,10 +212,14 @@ await act(async () => {
|
||||
});
|
||||
|
||||
const guardedStartupTabs = deferred<TabMeta[]>();
|
||||
let ensureBlankSurfaceCalls = 0;
|
||||
const staleProjectA = "/repo/project-a";
|
||||
const targetProjectB = "/repo/project-b";
|
||||
const ensureBlankSurfaceCalls: Array<{ scope: string; workspaceRoot: string }> = [];
|
||||
window.go.main.App = {
|
||||
ListTabs: async () => guardedStartupTabs.promise,
|
||||
MetaForTab: async () => meta(),
|
||||
MetaForTab: async (tabID: string) => tabID === "tab-new"
|
||||
? meta({ cwd: targetProjectB, workspaceRoot: targetProjectB, workspaceName: "project-b", workspacePath: targetProjectB })
|
||||
: meta({ cwd: staleProjectA, workspaceRoot: staleProjectA, workspaceName: "project-a", workspacePath: staleProjectA }),
|
||||
ContextUsageForTab: async () => context,
|
||||
EffortForTab: async () => effort,
|
||||
BalanceForTab: async () => balance,
|
||||
@@ -224,9 +229,17 @@ window.go.main.App = {
|
||||
HistoryPageForTab: async () => ({ messages: [], startTurn: 0, endTurn: 0, totalTurns: 0, hasOlder: false }),
|
||||
HistoryCheckpointTurnsForTab: async () => [],
|
||||
ReplayPendingPrompts: async () => {},
|
||||
EnsureBlankSurface: async () => {
|
||||
ensureBlankSurfaceCalls += 1;
|
||||
return tabMeta({ id: "tab-new", topicId: "topic-new", topicTitle: "New session" });
|
||||
EnsureBlankSurface: async (scope: string, workspaceRoot: string) => {
|
||||
ensureBlankSurfaceCalls.push({ scope, workspaceRoot });
|
||||
return tabMeta({
|
||||
id: "tab-new",
|
||||
topicId: "topic-new",
|
||||
topicTitle: "New session",
|
||||
workspaceRoot: targetProjectB,
|
||||
workspaceName: "project-b",
|
||||
workspacePath: targetProjectB,
|
||||
cwd: targetProjectB,
|
||||
});
|
||||
},
|
||||
} as Partial<AppBindings> as AppBindings;
|
||||
|
||||
@@ -239,20 +252,31 @@ await act(async () => {
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await controller?.ensureBlankSurface("project", "/repo");
|
||||
await controller?.ensureBlankSurface("project", targetProjectB);
|
||||
await flushPromises();
|
||||
});
|
||||
|
||||
eq(ensureBlankSurfaceCalls, 1, "EnsureBlankSurface is called once");
|
||||
eq(ensureBlankSurfaceCalls.length, 1, "EnsureBlankSurface is called once");
|
||||
eq(ensureBlankSurfaceCalls[0]?.workspaceRoot, targetProjectB, "EnsureBlankSurface keeps the requested project root");
|
||||
eq(controller?.activeTabId, "tab-new", "blank surface becomes active before startup sync resolves");
|
||||
eq(controller?.state.meta?.workspaceRoot, targetProjectB, "blank surface exposes the new project root");
|
||||
|
||||
await act(async () => {
|
||||
guardedStartupTabs.resolve([tabMeta({ id: "tab-old", topicId: "topic-old", topicTitle: "Old session" })]);
|
||||
guardedStartupTabs.resolve([tabMeta({
|
||||
id: "tab-old",
|
||||
topicId: "topic-old",
|
||||
topicTitle: "Old session",
|
||||
workspaceRoot: staleProjectA,
|
||||
workspaceName: "project-a",
|
||||
workspacePath: staleProjectA,
|
||||
cwd: staleProjectA,
|
||||
})]);
|
||||
await guardedStartupTabs.promise;
|
||||
await flushPromises();
|
||||
});
|
||||
|
||||
eq(controller?.activeTabId, "tab-new", "guarded startup sync cannot restore an older active tab");
|
||||
eq(controller?.state.meta?.workspaceRoot, targetProjectB, "guarded startup sync cannot restore the old project root");
|
||||
|
||||
await act(async () => {
|
||||
guardRoot.unmount();
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
projectTreeReadActivityKey,
|
||||
projectTreeTopicHasUnreadActivity,
|
||||
projectTreeShouldRenderTopicActions,
|
||||
projectTreeTopicMetaLine,
|
||||
} from "../components/ProjectTree";
|
||||
import type { ProjectNode } from "../lib/types";
|
||||
|
||||
@@ -27,6 +28,12 @@ function eq(a: unknown, b: unknown, label: string) {
|
||||
|
||||
console.log("\nproject tree runtime sessions");
|
||||
|
||||
const testT = (key: string, vars?: Record<string, string | number>) => {
|
||||
if (key === "history.turnOne") return `${vars?.n ?? 1} turn`;
|
||||
if (key === "history.turnOther") return `${vars?.n ?? 0} turns`;
|
||||
return key;
|
||||
};
|
||||
|
||||
const tree: ProjectNode[] = [
|
||||
{
|
||||
key: "global_folder",
|
||||
@@ -107,6 +114,29 @@ eq(
|
||||
"regular project topic still opens by topic",
|
||||
);
|
||||
|
||||
eq(
|
||||
projectTreeTopicMetaLine({
|
||||
key: "global_topic_missing_time",
|
||||
kind: "global_topic",
|
||||
label: "Old empty topic",
|
||||
topicId: "missing-time",
|
||||
}, testT),
|
||||
"projectTree.previously",
|
||||
"topic with no turns and no timestamps renders previous-time fallback meta",
|
||||
);
|
||||
|
||||
eq(
|
||||
projectTreeTopicMetaLine({
|
||||
key: "global_topic_recent",
|
||||
kind: "global_topic",
|
||||
label: "Recent blank topic",
|
||||
topicId: "recent",
|
||||
createdAt: Date.now(),
|
||||
}, testT),
|
||||
"projectTree.justNow",
|
||||
"topic with a real recent timestamp still renders just-now meta",
|
||||
);
|
||||
|
||||
const completedTopic: ProjectNode = {
|
||||
key: "topic_complete",
|
||||
kind: "topic",
|
||||
|
||||
@@ -47,14 +47,14 @@ eq(
|
||||
);
|
||||
|
||||
eq(
|
||||
mergedFetchedProviderModels(["mimo-v2.5-pro"], ["mimo-v2-flash", "mimo-v2-omni", "mimo-v2.5-pro"], { preserveCurated: true }),
|
||||
mergedFetchedProviderModels(["mimo-v2.5-pro"], ["mimo-v2.5", "mimo-v2.5-pro"], { preserveCurated: true }),
|
||||
["mimo-v2.5-pro"],
|
||||
"manual access refresh preserves selected MiMo model instead of importing provider catalog",
|
||||
);
|
||||
|
||||
eq(
|
||||
providerModelCandidates(["mimo-v2.5-pro"], ["mimo-v2-flash", "mimo-v2-omni", "mimo-v2.5-pro"]),
|
||||
["mimo-v2.5-pro", "mimo-v2-flash", "mimo-v2-omni"],
|
||||
providerModelCandidates(["mimo-v2.5-pro"], ["mimo-v2.5", "mimo-v2.5-pro"]),
|
||||
["mimo-v2.5-pro", "mimo-v2.5"],
|
||||
"manual access refresh can show provider catalog as unsaved candidates",
|
||||
);
|
||||
|
||||
|
||||
@@ -6,6 +6,8 @@ import { act } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import {
|
||||
SettingsPanel,
|
||||
formatProviderExtraBody,
|
||||
parseProviderExtraBody,
|
||||
providerBaseURLFromChatURL,
|
||||
providerChatURLPreview,
|
||||
providerEditorEffectiveKind,
|
||||
@@ -58,18 +60,52 @@ function baseSettings(displayMode: "standard" | "compact" = "standard"): Setting
|
||||
autoPlan: "off",
|
||||
providers: [],
|
||||
officialProviders: [],
|
||||
providerPresets: [],
|
||||
permissions: { mode: "ask", allow: [], ask: [], deny: [] },
|
||||
sandbox: { bash: "enforce", network: false, workspaceRoot: "", allowWrite: [], shell: "auto" },
|
||||
sandbox: { bash: "enforce", network: false, workspaceRoot: "", allowWrite: [], effectiveWorkspaceRoot: "/work", effectiveWriteRoots: ["/work"], shell: "auto" },
|
||||
network: { proxyMode: "auto", proxyUrl: "", noProxy: "", proxy: { type: "socks5", server: "", port: 0, username: "", password: "" } },
|
||||
agent: { temperature: 0, maxSteps: 0, plannerMaxSteps: 0, systemPrompt: "", coldResumePrune: true, reasoningLanguage: "auto" },
|
||||
agent: { temperature: 0, maxSteps: 0, plannerMaxSteps: 0, maxSubagentDepth: 2, systemPrompt: "", coldResumePrune: true, reasoningLanguage: "auto" },
|
||||
bot: {
|
||||
enabled: false,
|
||||
model: "",
|
||||
toolApprovalMode: "",
|
||||
maxSteps: 0,
|
||||
debounceMs: 0,
|
||||
allowlist: { enabled: false, allowAll: false, qqUsers: [], feishuUsers: [], weixinUsers: [], qqGroups: [], feishuGroups: [], weixinGroups: [] },
|
||||
qq: { enabled: false, appId: "", appSecretEnv: "", secretSet: false, sandbox: false },
|
||||
toolApprovalMode: "",
|
||||
maxSteps: 0,
|
||||
debounceMs: 0,
|
||||
queueMode: "steer",
|
||||
queueCap: 20,
|
||||
queueDrop: "summarize",
|
||||
ignoreSelfMessages: true,
|
||||
selfUserIds: { qq: [], feishu: [], weixin: [] },
|
||||
control: { enabled: false, addr: "127.0.0.1:37913", tokenEnv: "REASONIX_BOT_CONTROL_TOKEN" },
|
||||
pairing: { enabled: true, requestTtlMinutes: 60, maxPendingPerPlatform: 3 },
|
||||
routes: [],
|
||||
allowlist: {
|
||||
enabled: false,
|
||||
allowAll: false,
|
||||
qqUsers: [],
|
||||
feishuUsers: [],
|
||||
weixinUsers: [],
|
||||
qqApprovers: [],
|
||||
feishuApprovers: [],
|
||||
weixinApprovers: [],
|
||||
qqAdmins: [],
|
||||
feishuAdmins: [],
|
||||
weixinAdmins: [],
|
||||
qqGroups: [],
|
||||
feishuGroups: [],
|
||||
weixinGroups: [],
|
||||
},
|
||||
qq: {
|
||||
enabled: false,
|
||||
appId: "",
|
||||
appSecretEnv: "",
|
||||
secretSet: false,
|
||||
sandbox: false,
|
||||
model: "",
|
||||
toolApprovalMode: "ask",
|
||||
workspaceRoot: "",
|
||||
access: { enabled: true, allowAll: false, pairingEnabled: true, users: [], groups: [], approvers: [], admins: [] },
|
||||
},
|
||||
feishu: { enabled: false, domain: "feishu", appId: "", appSecretEnv: "", secretSet: false, verificationToken: "", mode: "webhook", webhookPort: 0, requireMention: false },
|
||||
weixin: { enabled: false, accountId: "", tokenEnv: "", tokenSet: false, apiBase: "" },
|
||||
connections: [],
|
||||
@@ -96,11 +132,20 @@ function baseSettings(displayMode: "standard" | "compact" = "standard"): Setting
|
||||
|
||||
console.log("\nsettings refresh snapshot");
|
||||
|
||||
eq(providerEditorEffectiveKind(true, "anthropic", ["anthropic", "openai"]), "openai", "new custom providers ignore sorted providerKinds and default to OpenAI");
|
||||
eq(providerEditorEffectiveKind(true, "anthropic", ["anthropic", "openai"]), "anthropic", "new custom providers keep the selected Anthropic-compatible kind");
|
||||
eq(providerEditorEffectiveKind(false, "anthropic", ["anthropic", "openai"]), "anthropic", "existing providers preserve their stored kind");
|
||||
eq(providerChatURLPreview("https://proxy.example.com/v1", "", false), "https://proxy.example.com/v1/chat/completions", "base URL mode previews chat completions URL");
|
||||
eq(providerChatURLPreview("", "https://proxy.example.com/custom/chat", true), "https://proxy.example.com/custom/chat", "full URL mode previews configured URL");
|
||||
eq(providerBaseURLFromChatURL("https://proxy.example.com/v1/chat/completions"), "https://proxy.example.com/v1", "chat URL derives base URL for model discovery");
|
||||
eq(formatProviderExtraBody({ top_p: 0.7, enable_thinking: true }), "{\n \"enable_thinking\": true,\n \"top_p\": 0.7\n}", "extra body editor formats stable JSON");
|
||||
eq(JSON.stringify(parseProviderExtraBody('{ "enable_thinking": true, "top_p": 0.7 }')), "{\"enable_thinking\":true,\"top_p\":0.7}", "extra body editor parses JSON object");
|
||||
let extraBodyRejected = false;
|
||||
try {
|
||||
parseProviderExtraBody("[true]");
|
||||
} catch {
|
||||
extraBodyRejected = true;
|
||||
}
|
||||
ok(extraBodyRejected, "extra body editor rejects non-object JSON");
|
||||
|
||||
const dom = new JSDOM("<!doctype html><html><body><div id=\"root\"></div></body></html>", {
|
||||
pretendToBeVisual: true,
|
||||
@@ -120,6 +165,7 @@ globalThis.localStorage = dom.window.localStorage;
|
||||
globalThis.requestAnimationFrame = dom.window.requestAnimationFrame.bind(dom.window);
|
||||
globalThis.cancelAnimationFrame = dom.window.cancelAnimationFrame.bind(dom.window);
|
||||
window.scrollTo = () => {};
|
||||
localStorage.clear();
|
||||
|
||||
const settingsSnapshots = [baseSettings("standard"), baseSettings("compact")];
|
||||
let settingsCalls = 0;
|
||||
@@ -146,6 +192,7 @@ await act(async () => {
|
||||
<LocaleProvider>
|
||||
<SettingsPanel
|
||||
initialTab="general"
|
||||
desktopPlatform="linux"
|
||||
onClose={() => {}}
|
||||
onChanged={(settings?: SettingsView) => {
|
||||
onChangedSettings = settings;
|
||||
@@ -193,6 +240,7 @@ await act(async () => {
|
||||
<LocaleProvider>
|
||||
<SettingsPanel
|
||||
initialTab="general"
|
||||
desktopPlatform="linux"
|
||||
onClose={() => {}}
|
||||
onChanged={() => {}}
|
||||
/>
|
||||
@@ -220,6 +268,137 @@ ok(document.body.textContent?.includes("Settings could not be loaded.") === fals
|
||||
await act(async () => {
|
||||
retryRoot.unmount();
|
||||
});
|
||||
|
||||
const zoomRootEl = document.createElement("div");
|
||||
document.body.appendChild(zoomRootEl);
|
||||
const zoomRoot = createRoot(zoomRootEl);
|
||||
let persistedZoom = 0.5;
|
||||
const savedZoomFactors: number[] = [];
|
||||
window.go = {
|
||||
main: {
|
||||
App: {
|
||||
Settings: async () => baseSettings("standard"),
|
||||
GetDesktopZoomFactor: async () => persistedZoom,
|
||||
SetDesktopZoomFactor: async (factor: number) => {
|
||||
persistedZoom = factor;
|
||||
savedZoomFactors.push(factor);
|
||||
},
|
||||
} as Partial<AppBindings> as AppBindings,
|
||||
},
|
||||
};
|
||||
|
||||
localStorage.setItem("reasonix-zoom-restart", "1");
|
||||
await act(async () => {
|
||||
zoomRoot.render(
|
||||
<LocaleProvider>
|
||||
<SettingsPanel
|
||||
initialTab="appearance"
|
||||
desktopPlatform="windows"
|
||||
onClose={() => {}}
|
||||
onChanged={() => {}}
|
||||
/>
|
||||
</LocaleProvider>,
|
||||
);
|
||||
await flushPromises();
|
||||
});
|
||||
await waitFor("persisted display zoom sync", () => document.querySelector(".zoom-slider__value")?.textContent?.trim() === "50%");
|
||||
|
||||
const resetZoomButton = document.querySelector("button[aria-label='Reset display zoom to 100%']") as HTMLButtonElement | null;
|
||||
if (!resetZoomButton) throw new Error("display zoom reset button did not render");
|
||||
await act(async () => {
|
||||
resetZoomButton.click();
|
||||
await flushPromises();
|
||||
});
|
||||
await waitFor("display zoom reset", () => document.querySelector(".zoom-slider__value")?.textContent?.trim() === "100%");
|
||||
|
||||
eq(savedZoomFactors.at(-1), 1, "display zoom reset writes the default zoom factor");
|
||||
eq(localStorage.getItem("reasonix-zoom-restart"), "1", "display zoom reset updates the local restart zoom cache");
|
||||
|
||||
await act(async () => {
|
||||
zoomRoot.unmount();
|
||||
});
|
||||
|
||||
// Bots tab: direct four-channel bot manager.
|
||||
const botsRootEl = document.createElement("div");
|
||||
document.body.appendChild(botsRootEl);
|
||||
const botsRoot = createRoot(botsRootEl);
|
||||
const botsSettings = baseSettings("standard");
|
||||
botsSettings.bot.connections = [
|
||||
{
|
||||
id: "conn-feishu-1",
|
||||
provider: "feishu",
|
||||
domain: "feishu",
|
||||
label: "kun",
|
||||
enabled: true,
|
||||
status: "connected",
|
||||
model: "",
|
||||
toolApprovalMode: "",
|
||||
workspaceRoot: "",
|
||||
credential: { appId: "cli_mock", appSecretEnv: "FEISHU_BOT_APP_SECRET", accountId: "", tokenEnv: "", secretSet: true },
|
||||
sessionMappings: [],
|
||||
lastError: "",
|
||||
createdAt: "",
|
||||
updatedAt: "",
|
||||
access: { enabled: true, allowAll: false, pairingEnabled: true, users: ["ou_mock_user_001"], groups: [], approvers: [], admins: [] },
|
||||
},
|
||||
];
|
||||
window.go = {
|
||||
main: {
|
||||
App: {
|
||||
Settings: async () => botsSettings,
|
||||
} as Partial<AppBindings> as AppBindings,
|
||||
},
|
||||
};
|
||||
|
||||
await act(async () => {
|
||||
botsRoot.render(
|
||||
<LocaleProvider>
|
||||
<SettingsPanel initialTab="bots" desktopPlatform="linux" onClose={() => {}} onChanged={() => {}} />
|
||||
</LocaleProvider>,
|
||||
);
|
||||
await flushPromises();
|
||||
});
|
||||
await waitFor("bot channel manager", () => Boolean(document.querySelector(".bot-channel-manager")));
|
||||
|
||||
ok(!document.querySelector(".bot-overview-grid"), "bots tab does not render the removed entry overview");
|
||||
ok(!document.getElementById("bot-mobile-remote"), "bots tab no longer renders the mobile remote entry card");
|
||||
ok(!document.querySelector(".bot-channel-entry"), "bots tab no longer renders the Bot Channel entry panel");
|
||||
ok(!document.getElementById("bot-step-access"), "bots tab omits the old global access step card");
|
||||
ok(!document.getElementById("bot-step-behavior"), "bots tab omits global default behavior card");
|
||||
eq(document.querySelectorAll(".bot-step-chip").length, 0, "hero no longer shows the old two-step chips");
|
||||
|
||||
eq(document.querySelectorAll(".bot-channel-tabs [role=\"tab\"]").length, 4, "bot manager uses four fixed channel tabs on the left");
|
||||
ok(document.querySelector(".bot-channel-setup-card")?.textContent?.includes("Configure QQ") === true, "unconfigured QQ tab shows key setup on the right");
|
||||
ok(document.body.textContent?.includes("Back to entry") === false, "bot manager does not show a return-to-entry action");
|
||||
|
||||
const feishuTab = Array.from(document.querySelectorAll(".bot-channel-tabs [role=\"tab\"]")).find((button) => button.textContent?.includes("Feishu")) as HTMLButtonElement | undefined;
|
||||
if (!feishuTab) throw new Error("Feishu channel tab did not render");
|
||||
await act(async () => {
|
||||
feishuTab.click();
|
||||
await flushPromises();
|
||||
});
|
||||
await waitFor("selected Feishu detail", () => Boolean(document.querySelector(".bot-channel-manager__detail .bot-detail-card")));
|
||||
|
||||
ok(Boolean(document.querySelector(".bot-channel-manager__detail .bot-detail-card")), "configured channel renders selected bot detail on the right");
|
||||
ok(Boolean(document.querySelector(".bot-channel-manager__detail .bot-detail-section--access")), "selected bot detail owns its access control");
|
||||
ok(document.body.textContent?.includes("Access control") === true, "selected bot detail labels per-bot access control");
|
||||
const selectedBotDetailText = document.querySelector(".bot-channel-manager__detail .bot-detail-card")?.textContent ?? "";
|
||||
const connectionSummaryIndex = selectedBotDetailText.indexOf("Connection summary");
|
||||
const enableBotIndex = selectedBotDetailText.indexOf("Enable bot");
|
||||
const toolApprovalIndex = selectedBotDetailText.indexOf("Tool approval");
|
||||
const modelIndex = selectedBotDetailText.indexOf("Model");
|
||||
const accessControlIndex = selectedBotDetailText.indexOf("Access control");
|
||||
ok(
|
||||
connectionSummaryIndex >= 0 && enableBotIndex > connectionSummaryIndex && toolApprovalIndex > enableBotIndex && modelIndex > toolApprovalIndex && accessControlIndex > modelIndex,
|
||||
"selected bot detail places enable, approval, and model controls between summary and access control",
|
||||
);
|
||||
ok(document.body.textContent?.includes("ou_mock_user_001") === true, "selected bot detail shows its trusted user");
|
||||
ok(document.body.textContent?.includes("Legacy global allowlist") === true, "advanced area keeps the legacy global allowlist");
|
||||
ok(document.querySelector(".bot-simple-advanced")?.textContent?.includes("local control API") === false, "advanced area no longer owns mobile/control API setup");
|
||||
|
||||
await act(async () => {
|
||||
botsRoot.unmount();
|
||||
});
|
||||
dom.window.close();
|
||||
|
||||
console.log(`\n${passed} passed, ${failed} failed, ${passed + failed} total`);
|
||||
|
||||
@@ -130,25 +130,40 @@ const tabA = tabMeta("tab-a", { active: true });
|
||||
const tabB = tabMeta("tab-b");
|
||||
const tabC = tabMeta("tab-c");
|
||||
const tabD = tabMeta("tab-d");
|
||||
const tabE = tabMeta("tab-e");
|
||||
const tabF = tabMeta("tab-f");
|
||||
const tabG = tabMeta("tab-g");
|
||||
const tabH = tabMeta("tab-h");
|
||||
let backendActiveId = "tab-a";
|
||||
const historyB = deferred<HistoryMessage[]>();
|
||||
const historyD = deferred<HistoryMessage[]>();
|
||||
let metaH = deferred<Meta>();
|
||||
let historyH = deferred<HistoryMessage[]>();
|
||||
const contextDGate = deferred<ContextInfo>();
|
||||
const setActiveBGate = deferred<void>();
|
||||
const setActiveEGate = deferred<void>();
|
||||
const setActiveFGate = deferred<void>();
|
||||
const submitTabCGate = deferred<void>();
|
||||
const historyCalls: string[] = [];
|
||||
const cancelCalls: string[] = [];
|
||||
let contextDCalls = 0;
|
||||
let metaHCalls = 0;
|
||||
let holdNextContextForD = false;
|
||||
let holdNextMetaForH = false;
|
||||
let holdNextHistoryForH = false;
|
||||
let setActiveCalls = 0;
|
||||
let newSessionCalls = 0;
|
||||
let failSetActiveFor = "";
|
||||
const runningTabs = new Set<string>();
|
||||
const tabsById = new Map([tabA, tabB, tabC, tabD].map((tab) => [tab.id, tab]));
|
||||
const tabsById = new Map([tabA, tabB, tabC, tabD, tabE, tabF, tabG, tabH].map((tab) => [tab.id, tab]));
|
||||
const eventHandlers: Array<(e: WireEvent) => void> = [];
|
||||
const readyHandlers: Array<(tabId?: string) => void> = [];
|
||||
|
||||
function currentTabs(): TabMeta[] {
|
||||
return [tabA, tabB, tabC, tabD].map((tab) => ({ ...tab, active: tab.id === backendActiveId, running: runningTabs.has(tab.id) }));
|
||||
return Array.from(tabsById.values()).map((tab) => {
|
||||
const running = runningTabs.has(tab.id);
|
||||
return { ...tab, active: tab.id === backendActiveId, running, cancellable: running };
|
||||
});
|
||||
}
|
||||
|
||||
window.runtime = {
|
||||
@@ -163,7 +178,14 @@ window.go = {
|
||||
main: {
|
||||
App: {
|
||||
ListTabs: async () => currentTabs(),
|
||||
MetaForTab: async (tabID: string) => metaFor(tabsById.get(tabID) ?? tabA),
|
||||
MetaForTab: async (tabID: string) => {
|
||||
if (tabID === "tab-h" && holdNextMetaForH) {
|
||||
metaHCalls += 1;
|
||||
holdNextMetaForH = false;
|
||||
return metaH.promise;
|
||||
}
|
||||
return metaFor(tabsById.get(tabID) ?? tabA);
|
||||
},
|
||||
ContextUsageForTab: async (tabID: string) => {
|
||||
if (tabID === "tab-d" && holdNextContextForD) {
|
||||
contextDCalls += 1;
|
||||
@@ -181,6 +203,13 @@ window.go = {
|
||||
historyCalls.push(tabID);
|
||||
if (tabID === "tab-b") return historyB.promise;
|
||||
if (tabID === "tab-d") return historyD.promise;
|
||||
if (tabID === "tab-e") return [userMessage("fork E")];
|
||||
if (tabID === "tab-g") return [userMessage("history G")];
|
||||
if (tabID === "tab-h" && holdNextHistoryForH) {
|
||||
holdNextHistoryForH = false;
|
||||
return historyH.promise;
|
||||
}
|
||||
if (tabID === "tab-h") return [userMessage("history H")];
|
||||
return [userMessage("cached A")];
|
||||
},
|
||||
HistoryPageForTab: async (tabID: string) => {
|
||||
@@ -188,20 +217,38 @@ window.go = {
|
||||
return { messages, startTurn: 0, endTurn: messages.filter((message) => message.role === "user").length, totalTurns: messages.filter((message) => message.role === "user").length, hasOlder: false };
|
||||
},
|
||||
HistoryCheckpointTurnsForTab: async () => [],
|
||||
OpenProjectTab: async () => {
|
||||
backendActiveId = "tab-d";
|
||||
return { ...(tabsById.get("tab-d") ?? tabD), active: true };
|
||||
OpenProjectTab: async (workspaceRoot: string, topicId: string) => {
|
||||
const target = Array.from(tabsById.values()).find((tab) => tab.workspaceRoot === workspaceRoot && tab.topicId === topicId) ?? tabD;
|
||||
backendActiveId = target.id;
|
||||
return { ...target, active: true };
|
||||
},
|
||||
ActivateTopic: async (_scope: string, workspaceRoot: string, topicId: string) => {
|
||||
const target = Array.from(tabsById.values()).find((tab) => tab.workspaceRoot === workspaceRoot && tab.topicId === topicId) ?? tabG;
|
||||
backendActiveId = target.id;
|
||||
return { ...target, active: true };
|
||||
},
|
||||
NewSession: async () => {
|
||||
newSessionCalls += 1;
|
||||
},
|
||||
Fork: async () => {
|
||||
tabsById.set("tab-e", tabE);
|
||||
backendActiveId = "tab-e";
|
||||
runningTabs.add("tab-e");
|
||||
return { ...tabE, active: true, running: true };
|
||||
},
|
||||
ReplayPendingPrompts: async () => {},
|
||||
SetActiveTab: async (tabID: string) => {
|
||||
setActiveCalls += 1;
|
||||
if (tabID === "tab-b") await setActiveBGate.promise;
|
||||
if (tabID === "tab-e") await setActiveEGate.promise;
|
||||
if (tabID === "tab-f") await setActiveFGate.promise;
|
||||
if (tabID === failSetActiveFor) throw new Error("persist failed");
|
||||
backendActiveId = tabID;
|
||||
},
|
||||
CancelTab: async (tabID: string) => {
|
||||
cancelCalls.push(tabID);
|
||||
runningTabs.delete(tabID);
|
||||
},
|
||||
SubmitToTab: async (tabID: string) => {
|
||||
runningTabs.add(tabID);
|
||||
if (tabID === "tab-c") await submitTabCGate.promise;
|
||||
@@ -280,6 +327,10 @@ ok(controller?.state.items.some((item) => item.kind === "user" && item.text ===
|
||||
ok(!(controller?.state.items.some((item) => item.kind === "user" && item.text === "late B") ?? false), "late history stays scoped to its tab state");
|
||||
|
||||
const historyCallsBeforeFallbackSync = historyCalls.length;
|
||||
await act(async () => {
|
||||
for (const handler of eventHandlers) handler({ kind: "approval_request", tabId: "tab-b", approval: { id: "stale-fallback-approval", tool: "bash", subject: "stale fallback approval" } });
|
||||
await flushPromises();
|
||||
});
|
||||
backendActiveId = "tab-b";
|
||||
await act(async () => {
|
||||
await controller?.syncActiveTab(false);
|
||||
@@ -288,12 +339,69 @@ await act(async () => {
|
||||
eq(controller?.activeTabId, "tab-b", "backend fallback sync activates the backend-selected cached tab");
|
||||
ok(controller?.state.items.some((item) => item.kind === "user" && item.text === "late B") ?? false, "backend fallback sync keeps the cached transcript");
|
||||
eq(historyCalls.length, historyCallsBeforeFallbackSync, "backend fallback sync preserves cached history instead of reloading it");
|
||||
eq(controller?.state.approval?.id, undefined, "backend fallback sync reconciles stale approval state");
|
||||
eq(controller?.state.running, false, "backend fallback sync reconciles stale running state");
|
||||
await act(async () => {
|
||||
await controller?.switchTab("tab-a", tabA);
|
||||
await flushPromises();
|
||||
});
|
||||
await waitFor("tab-a restored after fallback sync", () => controller?.activeTabId === "tab-a" && controller.state.items.some((item) => item.kind === "user" && item.text === "cached A"));
|
||||
|
||||
runningTabs.add("tab-e");
|
||||
let switchToE: Promise<TabMeta[] | undefined> | undefined;
|
||||
await act(async () => {
|
||||
switchToE = controller?.switchTab("tab-e", { ...tabE, running: true, cancellable: true });
|
||||
await flushPromises();
|
||||
});
|
||||
eq(controller?.activeTabId, "tab-e", "switching to a backend-running tab updates the active tab immediately");
|
||||
eq(controller?.state.running, true, "backend-running tab restores the stop state before backend activation settles");
|
||||
eq(controller?.state.cancellable, true, "backend-running tab remains cancellable before backend activation settles");
|
||||
await act(async () => {
|
||||
controller?.cancel();
|
||||
await flushPromises();
|
||||
});
|
||||
eq(cancelCalls.join(","), "tab-e", "cancel targets the backend-running tab while activation is pending");
|
||||
await act(async () => {
|
||||
setActiveEGate.resolve();
|
||||
await switchToE;
|
||||
await flushPromises();
|
||||
});
|
||||
eq(controller?.state.running, false, "cancelled backend-running tab reconciles to idle after activation");
|
||||
await act(async () => {
|
||||
await controller?.switchTab("tab-a", tabA);
|
||||
await flushPromises();
|
||||
});
|
||||
await waitFor("tab-a restored after backend-running switch", () => controller?.activeTabId === "tab-a" && controller.state.items.some((item) => item.kind === "user" && item.text === "cached A"));
|
||||
|
||||
let switchToF: Promise<TabMeta[] | undefined> | undefined;
|
||||
await act(async () => {
|
||||
switchToF = controller?.switchTab("tab-f", tabF);
|
||||
await flushPromises();
|
||||
});
|
||||
eq(controller?.activeTabId, "tab-f", "first rapid switch activates the slow target optimistically");
|
||||
let switchToG: Promise<TabMeta[] | undefined> | undefined;
|
||||
await act(async () => {
|
||||
switchToG = controller?.switchTab("tab-g", tabG);
|
||||
await switchToG;
|
||||
await flushPromises();
|
||||
});
|
||||
eq(controller?.activeTabId, "tab-g", "second rapid switch wins immediately");
|
||||
eq(backendActiveId, "tab-g", "second rapid switch activates the backend");
|
||||
await act(async () => {
|
||||
setActiveFGate.resolve();
|
||||
await switchToF;
|
||||
await flushPromises();
|
||||
});
|
||||
eq(controller?.activeTabId, "tab-g", "late completion from the first rapid switch does not replace the visible tab");
|
||||
eq(backendActiveId, "tab-g", "late completion from the first rapid switch reasserts the last-clicked backend tab");
|
||||
ok(!historyCalls.includes("tab-f"), "late completion from the first rapid switch does not hydrate the stale target");
|
||||
|
||||
await act(async () => {
|
||||
await controller?.switchTab("tab-a", tabA);
|
||||
await flushPromises();
|
||||
});
|
||||
await waitFor("tab-a restored after rapid switch", () => controller?.activeTabId === "tab-a" && controller.state.items.some((item) => item.kind === "user" && item.text === "cached A"));
|
||||
|
||||
failSetActiveFor = "tab-b";
|
||||
const historyCallsBeforeFailedSwitch = historyCalls.length;
|
||||
await act(async () => {
|
||||
@@ -382,6 +490,11 @@ ok(controller?.state.items.some((item) => item.kind === "user" && item.text ===
|
||||
eq(controller?.state.hydratePlaceholderItems?.length ?? 0, 0, "topic history clears the hydration placeholder");
|
||||
|
||||
const historyCallsBeforeReopenD = historyCalls.length;
|
||||
await act(async () => {
|
||||
for (const handler of eventHandlers) handler({ kind: "approval_request", tabId: "tab-d", approval: { id: "stale-approval", tool: "bash", subject: "stale approval" } });
|
||||
await controller?.switchTab("tab-a", tabA);
|
||||
await flushPromises();
|
||||
});
|
||||
await act(async () => {
|
||||
await controller?.openProjectTab(tabD.workspaceRoot, tabD.topicId || "");
|
||||
await flushPromises();
|
||||
@@ -389,6 +502,17 @@ await act(async () => {
|
||||
eq(controller?.activeTabId, "tab-d", "reopening an already hydrated topic keeps it active");
|
||||
ok(controller?.state.items.some((item) => item.kind === "user" && item.text === "history D") ?? false, "reopened cached topic keeps its transcript");
|
||||
eq(historyCalls.length, historyCallsBeforeReopenD, "reopening an already hydrated topic skips history hydration");
|
||||
eq(controller?.state.approval?.id, undefined, "reopening a topic reconciles stale approval state");
|
||||
eq(controller?.state.running, false, "reopening a topic reconciles stale running state");
|
||||
|
||||
await act(async () => {
|
||||
await controller?.rewind(0, "fork");
|
||||
await flushPromises();
|
||||
});
|
||||
eq(controller?.activeTabId, "tab-e", "fork activates the forked tab");
|
||||
ok(controller?.state.items.some((item) => item.kind === "user" && item.text === "fork E") ?? false, "fork loads the forked transcript");
|
||||
eq(controller?.state.running, true, "fork reconciles backend running state after reset hydration");
|
||||
runningTabs.delete("tab-e");
|
||||
|
||||
const contextCallsBeforeInactiveD = contextDCalls;
|
||||
await act(async () => {
|
||||
@@ -410,6 +534,70 @@ await act(async () => {
|
||||
});
|
||||
eq(historyCalls.length, historyCallsBeforeReboundD + 1, "rebound topic reloads history when session path changes");
|
||||
|
||||
metaH = deferred<Meta>();
|
||||
holdNextMetaForH = true;
|
||||
const historyCallsBeforeSlowMeta = historyCalls.length;
|
||||
await act(async () => {
|
||||
await controller?.openProjectTab(tabH.workspaceRoot, tabH.topicId || "");
|
||||
await flushPromises();
|
||||
});
|
||||
await waitFor("slow meta tab hydrates history", () =>
|
||||
controller?.activeTabId === "tab-h" &&
|
||||
controller.state.hydrating === false &&
|
||||
(controller.state.items.some((item) => item.kind === "user" && item.text === "history H") ?? false) &&
|
||||
metaHCalls === 1
|
||||
);
|
||||
eq(historyCalls.length, historyCallsBeforeSlowMeta + 1, "slow MetaForTab does not delay the history request");
|
||||
eq(controller?.state.meta?.label, "model-tab-h", "slow MetaForTab leaves optimistic metadata visible while history hydrates");
|
||||
await act(async () => {
|
||||
metaH.resolve({ ...metaFor(tabH), label: "fresh-model-tab-h" });
|
||||
await metaH.promise;
|
||||
await flushPromises();
|
||||
});
|
||||
await waitFor("slow meta refresh applies", () => controller?.state.meta?.label === "fresh-model-tab-h");
|
||||
|
||||
metaH = deferred<Meta>();
|
||||
const metaHCallsBeforeStale = metaHCalls;
|
||||
holdNextMetaForH = true;
|
||||
await act(async () => {
|
||||
await controller?.openProjectTab(tabH.workspaceRoot, tabH.topicId || "");
|
||||
await flushPromises();
|
||||
});
|
||||
await waitFor("slow meta pending before single-surface navigation", () => metaHCalls === metaHCallsBeforeStale + 1);
|
||||
await act(async () => {
|
||||
await controller?.activateTopic("project", tabG.workspaceRoot, tabG.topicId || "");
|
||||
await flushPromises();
|
||||
});
|
||||
await waitFor("single-surface activation replaces visible tab", () =>
|
||||
controller?.activeTabId === "tab-g" &&
|
||||
(controller.state.items.some((item) => item.kind === "user" && item.text === "history G") ?? false)
|
||||
);
|
||||
await act(async () => {
|
||||
metaH.resolve({ ...metaFor(tabH), label: "stale-model-tab-h" });
|
||||
await metaH.promise;
|
||||
await flushPromises();
|
||||
});
|
||||
historyH = deferred<HistoryMessage[]>();
|
||||
holdNextHistoryForH = true;
|
||||
await act(async () => {
|
||||
await controller?.openProjectTab(tabH.workspaceRoot, tabH.topicId || "");
|
||||
await flushPromises();
|
||||
});
|
||||
await waitFor("reopened tab-h treats stale late meta as discarded", () =>
|
||||
controller?.activeTabId === "tab-h" &&
|
||||
controller.state.hydrating === true &&
|
||||
(controller.state.hydratePlaceholderItems?.some((item) => item.kind === "user" && item.text === "history G") ?? false)
|
||||
);
|
||||
await act(async () => {
|
||||
historyH.resolve([userMessage("history H after stale meta")]);
|
||||
await historyH.promise;
|
||||
await flushPromises();
|
||||
});
|
||||
await waitFor("reopened tab-h finishes after stale meta discard", () =>
|
||||
controller?.state.hydrating === false &&
|
||||
(controller.state.items.some((item) => item.kind === "user" && item.text === "history H after stale meta") ?? false)
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
// Run: tsx src/__tests__/turn-action-copy.test.ts
|
||||
|
||||
import { appendTurnActionCopyText } from "../lib/turnActionCopy";
|
||||
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
|
||||
function eq(actual: unknown, expected: unknown, label: string) {
|
||||
if (actual === expected) {
|
||||
process.stdout.write(` PASS ${label}\n`);
|
||||
passed += 1;
|
||||
} else {
|
||||
process.stdout.write(` FAIL ${label}: expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}\n`);
|
||||
failed += 1;
|
||||
}
|
||||
}
|
||||
|
||||
console.log("\nturn action copy");
|
||||
|
||||
eq(
|
||||
appendTurnActionCopyText("", "final answer"),
|
||||
"final answer",
|
||||
"keeps single assistant output unchanged",
|
||||
);
|
||||
|
||||
eq(
|
||||
appendTurnActionCopyText("first assistant block", "second assistant block"),
|
||||
"first assistant block\n\nsecond assistant block",
|
||||
"accumulates multiple assistant outputs in one turn",
|
||||
);
|
||||
|
||||
eq(
|
||||
appendTurnActionCopyText("first assistant block\n", "second assistant block"),
|
||||
"first assistant block\nsecond assistant block",
|
||||
"does not add an extra blank line when the previous block already ends with newline",
|
||||
);
|
||||
|
||||
eq(
|
||||
appendTurnActionCopyText("first assistant block", "\nsecond assistant block"),
|
||||
"first assistant block\nsecond assistant block",
|
||||
"does not add an extra blank line when the next block starts with newline",
|
||||
);
|
||||
|
||||
eq(
|
||||
appendTurnActionCopyText("final answer", " \n\t"),
|
||||
"final answer",
|
||||
"ignores whitespace-only assistant fragments",
|
||||
);
|
||||
|
||||
console.log(`\n${passed} passed, ${failed} failed`);
|
||||
if (failed > 0) process.exit(1);
|
||||
@@ -113,6 +113,10 @@ eq(
|
||||
"var(--statusbar-dock-height)",
|
||||
"status bar min-height follows the scaled dock token",
|
||||
);
|
||||
eq(finalDeclaration(".provider-template-grid", "grid-auto-rows"), "92px", "provider preset cards use compact equal-height grid rows");
|
||||
eq(finalDeclaration(".provider-template-card", "height"), "100%", "provider preset cards stretch to the grid row height");
|
||||
eq(finalDeclaration(".provider-template-card strong", "-webkit-line-clamp"), "1", "provider preset card titles clamp to one line");
|
||||
eq(finalDeclaration(".provider-template-card span", "-webkit-line-clamp"), "2", "provider preset card descriptions clamp to two lines");
|
||||
|
||||
eq(finalDeclaration(".statusbar", "white-space"), "nowrap", "status bar keeps metrics on one row");
|
||||
eq(finalDeclaration(".statusbar", "overflow"), "hidden", "status bar clips instead of overflowing");
|
||||
@@ -160,6 +164,16 @@ ok(
|
||||
);
|
||||
|
||||
eq(finalDeclaration(".composer-modebar", "overflow"), "hidden", "chat mode switcher contains enlarged labels");
|
||||
eq(
|
||||
finalDeclaration(".app--creation .tool:not(.tool--open) > .tool__body", "height"),
|
||||
"0 !important",
|
||||
"collapsed creation tool bodies keep mounted content clipped",
|
||||
);
|
||||
eq(
|
||||
finalDeclaration(".app--creation .tool:not(.tool--open) > .tool__body", "visibility"),
|
||||
"hidden",
|
||||
"collapsed creation tool bodies do not paint hidden tool text",
|
||||
);
|
||||
ok(
|
||||
/@container\s*\(max-width:\s*760px\)[\s\S]*?\.composer-meta__control--model\s*\{[\s\S]*?flex\s*:\s*0 1 auto[\s\S]*?width\s*:\s*fit-content[\s\S]*?max-width\s*:\s*min\(240px,\s*42vw\)[\s\S]*?\.composer-meta--has-intent-chip\s+\.composer-meta__control--model\s*\{[\s\S]*?flex\s*:\s*0 1 auto[\s\S]*?width\s*:\s*fit-content[\s\S]*?max-width\s*:\s*min\(220px,\s*38vw\)[\s\S]*?\.composer-meta__control--effort\s*\{[\s\S]*?display\s*:\s*none[\s\S]*?\.composer-meta__control--more\s*\{[\s\S]*?display\s*:\s*inline-flex/.test(styles),
|
||||
"composer compact controls activate at the capped theme width",
|
||||
|
||||
@@ -102,6 +102,7 @@ globalThis.cancelAnimationFrame = dom.window.cancelAnimationFrame.bind(dom.windo
|
||||
const eventHandlers: Array<(e: WireEvent) => void> = [];
|
||||
let backendRunning = false;
|
||||
let cancelCalls = 0;
|
||||
let effortCalls = 0;
|
||||
const context: ContextInfo = { used: 0, window: 100, sessionTokens: 0 };
|
||||
const effort: EffortInfo = { supported: true, current: "auto", default: "auto", levels: ["auto"] };
|
||||
|
||||
@@ -119,6 +120,10 @@ window.go = {
|
||||
MetaForTab: async () => meta(),
|
||||
ContextUsageForTab: async () => context,
|
||||
EffortForTab: async () => effort,
|
||||
SetEffortForTab: async () => {
|
||||
effortCalls += 1;
|
||||
throw new Error("finish or cancel the current turn, answer pending prompts, and stop background jobs before changing effort");
|
||||
},
|
||||
BalanceForTab: async () => ({ available: false, display: "" }),
|
||||
JobsForTab: async () => [],
|
||||
CheckpointsForTab: async () => [],
|
||||
@@ -178,6 +183,15 @@ eq(controller?.state.running, false, "cancel reconciliation clears the running s
|
||||
eq(cancelCalls, 1, "CancelTab is called once");
|
||||
eq(controller?.state.cancelRequested, false, "cancel reconciliation clears cancelRequested");
|
||||
|
||||
await act(async () => {
|
||||
await controller?.setEffort("max");
|
||||
await flushPromises();
|
||||
});
|
||||
|
||||
const effortNotice = controller?.state.items.find((item) => item.kind === "notice" && item.text.includes("cannot change yet"));
|
||||
eq(effortCalls, 1, "SetEffortForTab is called once");
|
||||
ok(Boolean(effortNotice), "busy effort switch surfaces a non-failure warning notice");
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// Run: tsx src/__tests__/use-controller-meta.test.ts
|
||||
|
||||
import { foregroundRunningFromRuntimeMeta, initialState, metaFromTab, reducer, sameMeta, shouldReconcileStaleTurn } from "../lib/useController";
|
||||
import { effortSwitchNoticeText, foregroundRunningFromRuntimeMeta, initialState, metaFromTab, reducer, sameMeta, shouldReconcileStaleTurn } from "../lib/useController";
|
||||
import type { Meta, TabMeta, WireUsage } from "../lib/types";
|
||||
|
||||
type LooseTabMeta = Omit<TabMeta, "toolApprovalMode"> & { toolApprovalMode?: TabMeta["toolApprovalMode"] | "" };
|
||||
@@ -82,6 +82,34 @@ function usage(source: string): WireUsage {
|
||||
|
||||
console.log("\nuse controller meta");
|
||||
|
||||
{
|
||||
eq(
|
||||
effortSwitchNoticeText("finish or cancel the current turn, answer pending prompts, and stop background jobs before changing effort"),
|
||||
"Reasoning effort cannot change yet. Stop the current answer, handle pending prompts, or wait for background jobs to finish.",
|
||||
"effort busy guard is worded as temporary",
|
||||
);
|
||||
eq(
|
||||
effortSwitchNoticeText("this session is already open in another Reasonix window or still running in the background; close the other window or open a copy before changing effort"),
|
||||
"This session is open in another Reasonix window or still running in the background. Close that window, stop the background run, or open a copy before changing effort.",
|
||||
"effort lease conflict explains the safe path",
|
||||
);
|
||||
eq(
|
||||
effortSwitchNoticeText("workspace is still starting"),
|
||||
"This session is still starting. Try changing reasoning effort again in a moment.",
|
||||
"effort startup race asks the user to retry later",
|
||||
);
|
||||
eq(
|
||||
effortSwitchNoticeText('tab "tab-a" changed while switching effort; retry'),
|
||||
"The current session changed while switching reasoning effort. Try once more.",
|
||||
"effort tab race asks the user to retry",
|
||||
);
|
||||
eq(
|
||||
effortSwitchNoticeText("unknown model \"missing\""),
|
||||
"Reasoning effort switch failed: unknown model \"missing\"",
|
||||
"effort true failure keeps the underlying error",
|
||||
);
|
||||
}
|
||||
|
||||
{
|
||||
eq(sameMeta(meta(), meta()), true, "identical meta is unchanged");
|
||||
eq(sameMeta(meta({ collaborationMode: "normal" }), meta({ collaborationMode: "plan" })), false, "collaboration mode changes invalidate meta equality");
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
// Run: tsx src/__tests__/use-controller-send-fallback.test.tsx
|
||||
|
||||
import { JSDOM } from "jsdom";
|
||||
import React, { act } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import type { AppBindings } from "../lib/bridge";
|
||||
import { useController } from "../lib/useController";
|
||||
import type { BalanceInfo, CheckpointMeta, ContextInfo, EffortInfo, HistoryMessage, JobView, Meta, TabMeta } from "../lib/types";
|
||||
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
|
||||
function ok(value: boolean, label: string) {
|
||||
if (value) {
|
||||
process.stdout.write(` PASS ${label}\n`);
|
||||
passed += 1;
|
||||
} else {
|
||||
process.stdout.write(` FAIL ${label}\n`);
|
||||
failed += 1;
|
||||
}
|
||||
}
|
||||
|
||||
function eq(actual: unknown, expected: unknown, label: string) {
|
||||
ok(actual === expected, `${label}${actual === expected ? "" : `: expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`}`);
|
||||
}
|
||||
|
||||
function flushPromises(): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, 0));
|
||||
}
|
||||
|
||||
function tabMeta(overrides: Partial<TabMeta> = {}): TabMeta {
|
||||
return {
|
||||
id: "tab-send",
|
||||
scope: "project",
|
||||
workspaceRoot: "/repo/send",
|
||||
workspaceName: "send",
|
||||
workspacePath: "/repo/send",
|
||||
gitBranch: "main",
|
||||
topicId: "topic-send",
|
||||
topicTitle: "Send",
|
||||
label: "model-send",
|
||||
ready: true,
|
||||
running: false,
|
||||
mode: "normal",
|
||||
toolApprovalMode: "ask",
|
||||
tokenMode: "full",
|
||||
active: true,
|
||||
cwd: "/repo/send",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function metaFor(tab: TabMeta): Meta {
|
||||
return {
|
||||
label: tab.label,
|
||||
ready: tab.ready,
|
||||
startupErr: tab.startupErr,
|
||||
eventChannel: "agent:event",
|
||||
cwd: tab.cwd || tab.workspaceRoot,
|
||||
workspaceRoot: tab.workspaceRoot,
|
||||
workspaceName: tab.workspaceName,
|
||||
workspacePath: tab.workspacePath,
|
||||
gitBranch: tab.gitBranch,
|
||||
autoApproveTools: false,
|
||||
bypass: false,
|
||||
collaborationMode: tab.collaborationMode ?? "normal",
|
||||
toolApprovalMode: tab.toolApprovalMode ?? "ask",
|
||||
tokenMode: tab.tokenMode ?? "full",
|
||||
goal: "",
|
||||
goalStatus: "stopped",
|
||||
};
|
||||
}
|
||||
|
||||
console.log("\nuse controller send fallback");
|
||||
|
||||
const dom = new JSDOM("<!doctype html><html><body><div id=\"root\"></div></body></html>", {
|
||||
pretendToBeVisual: true,
|
||||
url: "http://localhost/",
|
||||
});
|
||||
(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
globalThis.window = dom.window as unknown as Window & typeof globalThis;
|
||||
globalThis.document = dom.window.document;
|
||||
Object.defineProperty(globalThis, "navigator", { configurable: true, value: dom.window.navigator });
|
||||
globalThis.Node = dom.window.Node;
|
||||
globalThis.HTMLElement = dom.window.HTMLElement;
|
||||
globalThis.Event = dom.window.Event;
|
||||
globalThis.CustomEvent = dom.window.CustomEvent;
|
||||
globalThis.KeyboardEvent = dom.window.KeyboardEvent;
|
||||
globalThis.MouseEvent = dom.window.MouseEvent;
|
||||
globalThis.localStorage = dom.window.localStorage;
|
||||
globalThis.requestAnimationFrame = dom.window.requestAnimationFrame.bind(dom.window);
|
||||
globalThis.cancelAnimationFrame = dom.window.cancelAnimationFrame.bind(dom.window);
|
||||
|
||||
const backendTab = tabMeta({ backgroundJobs: 2 });
|
||||
const context: ContextInfo = { used: 0, window: 100, sessionTokens: 0 };
|
||||
const effort: EffortInfo = { supported: true, current: "auto", default: "auto", levels: ["auto"] };
|
||||
const balance: BalanceInfo = { available: false, display: "" };
|
||||
const jobs: JobView[] = [];
|
||||
const checkpoints: CheckpointMeta[] = [];
|
||||
let tabsAvailable = false;
|
||||
let submitCalls = 0;
|
||||
|
||||
window.runtime = {
|
||||
EventsOn: () => () => {},
|
||||
BrowserOpenURL: () => {},
|
||||
};
|
||||
window.go = {
|
||||
main: {
|
||||
App: {
|
||||
ListTabs: async () => (tabsAvailable ? [backendTab] : []),
|
||||
MetaForTab: async () => metaFor(backendTab),
|
||||
ContextUsageForTab: async () => context,
|
||||
EffortForTab: async () => effort,
|
||||
BalanceForTab: async () => balance,
|
||||
JobsForTab: async () => jobs,
|
||||
CheckpointsForTab: async () => checkpoints,
|
||||
HistoryForTab: async (): Promise<HistoryMessage[]> => [],
|
||||
HistoryPageForTab: async () => ({ messages: [], startTurn: 0, endTurn: 0, totalTurns: 0, hasOlder: false }),
|
||||
HistoryCheckpointTurnsForTab: async () => [],
|
||||
ReplayPendingPrompts: async () => {},
|
||||
SubmitToTab: async (tabId: string) => {
|
||||
submitCalls += tabId === "tab-send" ? 1 : 0;
|
||||
},
|
||||
} as Partial<AppBindings> as AppBindings,
|
||||
},
|
||||
};
|
||||
|
||||
type Controller = ReturnType<typeof useController>;
|
||||
let controller: Controller | undefined;
|
||||
|
||||
function Probe() {
|
||||
controller = useController();
|
||||
return null;
|
||||
}
|
||||
|
||||
const rootEl = document.getElementById("root");
|
||||
if (!rootEl) throw new Error("missing root");
|
||||
const root = createRoot(rootEl);
|
||||
|
||||
await act(async () => {
|
||||
root.render(<Probe />);
|
||||
await flushPromises();
|
||||
});
|
||||
eq(controller?.activeTabId, undefined, "startup has no active tab when backend has no tabs");
|
||||
|
||||
tabsAvailable = true;
|
||||
await act(async () => {
|
||||
await controller?.send("hello from fallback");
|
||||
await flushPromises();
|
||||
});
|
||||
|
||||
eq(controller?.activeTabId, "tab-send", "send fallback activates the backend-selected tab");
|
||||
eq(controller?.state.backgroundJobs, 2, "send fallback reconciles backend runtime metadata");
|
||||
ok(controller?.state.items.some((item) => item.kind === "user" && item.text === "hello from fallback") ?? false, "send fallback keeps the optimistic user turn");
|
||||
eq(submitCalls, 1, "send fallback submits to the activated tab");
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
dom.window.close();
|
||||
|
||||
console.log(`\n${passed} passed, ${failed} failed, ${passed + failed} total`);
|
||||
if (failed > 0) process.exit(1);
|
||||
@@ -0,0 +1,124 @@
|
||||
// Run: tsx src/__tests__/wails-resize-fix.test.tsx
|
||||
|
||||
import { JSDOM } from "jsdom";
|
||||
import React from "react";
|
||||
import { act } from "react";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { useWailsResizeFix } from "../lib/useWailsResizeFix";
|
||||
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
|
||||
function ok(value: boolean, label: string) {
|
||||
if (value) {
|
||||
process.stdout.write(` PASS ${label}\n`);
|
||||
passed += 1;
|
||||
} else {
|
||||
process.stdout.write(` FAIL ${label}\n`);
|
||||
failed += 1;
|
||||
}
|
||||
}
|
||||
|
||||
function eq(actual: unknown, expected: unknown, label: string) {
|
||||
if (actual === expected) {
|
||||
ok(true, label);
|
||||
} else {
|
||||
ok(false, `${label}: expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`);
|
||||
}
|
||||
}
|
||||
|
||||
function installDom(): JSDOM {
|
||||
const dom = new JSDOM("<!doctype html><html><body><div id=\"root\"></div></body></html>", {
|
||||
pretendToBeVisual: true,
|
||||
url: "http://localhost/",
|
||||
});
|
||||
(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
globalThis.window = dom.window as unknown as Window & typeof globalThis;
|
||||
globalThis.document = dom.window.document;
|
||||
Object.defineProperty(globalThis, "navigator", { configurable: true, value: dom.window.navigator });
|
||||
globalThis.HTMLElement = dom.window.HTMLElement;
|
||||
globalThis.MouseEvent = dom.window.MouseEvent;
|
||||
Object.defineProperty(window, "innerWidth", { configurable: true, value: 100 });
|
||||
Object.defineProperty(window, "innerHeight", { configurable: true, value: 80 });
|
||||
return dom;
|
||||
}
|
||||
|
||||
function installWailsFlags(flags?: Partial<NonNullable<Window["wails"]>["flags"]>) {
|
||||
window.wails = {
|
||||
Callback: () => undefined,
|
||||
EventsNotify: () => undefined,
|
||||
flags: {
|
||||
enableResize: true,
|
||||
resizeEdge: undefined,
|
||||
borderThickness: 6,
|
||||
defaultCursor: null,
|
||||
cssDragProperty: "--wails-draggable",
|
||||
cssDragValue: "drag",
|
||||
cssDropProperty: "--wails-drop-target",
|
||||
cssDropValue: "drop",
|
||||
shouldDrag: false,
|
||||
deferDragToMouseMove: false,
|
||||
disableScrollbarDrag: false,
|
||||
disableDefaultContextMenu: false,
|
||||
enableWailsDragAndDrop: false,
|
||||
...flags,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function Harness({ enabled }: { enabled: boolean }) {
|
||||
useWailsResizeFix(enabled);
|
||||
return null;
|
||||
}
|
||||
|
||||
async function renderHarness(root: Root, enabled: boolean) {
|
||||
await act(async () => {
|
||||
root.render(<Harness enabled={enabled} />);
|
||||
});
|
||||
}
|
||||
|
||||
async function unmount(root: Root) {
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
}
|
||||
|
||||
console.log("\nwails resize fix");
|
||||
|
||||
{
|
||||
const dom = installDom();
|
||||
installWailsFlags({ enableResize: true, resizeEdge: "n-resize" });
|
||||
const root = createRoot(document.getElementById("root")!);
|
||||
|
||||
await renderHarness(root, false);
|
||||
window.dispatchEvent(new MouseEvent("mousemove", { clientX: 97, clientY: 40 }));
|
||||
eq(window.wails?.flags.enableResize, true, "disabled hook leaves Wails resize enabled");
|
||||
eq(window.wails?.flags.resizeEdge, "n-resize", "disabled hook leaves resize edge unchanged");
|
||||
|
||||
await unmount(root);
|
||||
dom.window.close();
|
||||
}
|
||||
|
||||
{
|
||||
const dom = installDom();
|
||||
document.documentElement.style.cursor = "grab";
|
||||
installWailsFlags({ enableResize: true, resizeEdge: "n-resize" });
|
||||
const root = createRoot(document.getElementById("root")!);
|
||||
|
||||
await renderHarness(root, true);
|
||||
eq(window.wails?.flags.enableResize, false, "enabled hook disables Wails built-in mousemove resize");
|
||||
|
||||
window.dispatchEvent(new MouseEvent("mousemove", { clientX: 97, clientY: 40 }));
|
||||
eq(window.wails?.flags.resizeEdge, "e-resize", "enabled hook detects right edge using innerWidth CSS pixels");
|
||||
eq(document.documentElement.style.cursor, "e-resize", "enabled hook mirrors detected edge cursor");
|
||||
|
||||
await unmount(root);
|
||||
eq(window.wails?.flags.enableResize, true, "cleanup restores previous enableResize");
|
||||
eq(window.wails?.flags.resizeEdge, "n-resize", "cleanup restores previous resizeEdge");
|
||||
eq(document.documentElement.style.cursor, "grab", "cleanup restores previous cursor");
|
||||
|
||||
dom.window.close();
|
||||
}
|
||||
|
||||
console.log(`\n${passed} passed, ${failed} failed, ${passed + failed} total`);
|
||||
if (failed > 0) process.exit(1);
|
||||
@@ -7,7 +7,7 @@ import { createRoot } from "react-dom/client";
|
||||
import { WorkspacePanel } from "../components/WorkspacePanel";
|
||||
import { LocaleProvider } from "../lib/i18n";
|
||||
import type { AppBindings } from "../lib/bridge";
|
||||
import type { WorkspaceChangesView } from "../lib/types";
|
||||
import type { DirEntry, WorkspaceChangesView } from "../lib/types";
|
||||
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
@@ -101,6 +101,47 @@ async function renderWorkspace(changes: WorkspaceChangesView) {
|
||||
return { dom, root };
|
||||
}
|
||||
|
||||
async function renderFilesWorkspace(methods: Partial<AppBindings>, props: Partial<Parameters<typeof WorkspacePanel>[0]> = {}) {
|
||||
const dom = installDom();
|
||||
window.go = {
|
||||
main: {
|
||||
App: {
|
||||
ListDir: async () => [],
|
||||
SearchFileRefs: async () => [],
|
||||
WorkspaceGitHistory: async () => [],
|
||||
WorkspaceChanges: async () => ({ files: [], gitAvailable: true }),
|
||||
...methods,
|
||||
} as Partial<AppBindings> as AppBindings,
|
||||
},
|
||||
};
|
||||
const rootEl = document.getElementById("root");
|
||||
if (!rootEl) throw new Error("missing root");
|
||||
const root = createRoot(rootEl);
|
||||
let currentProps: Parameters<typeof WorkspacePanel>[0] = {
|
||||
open: true,
|
||||
tabId: "tab-a",
|
||||
cwd: "/repo",
|
||||
maximized: false,
|
||||
initialViewMode: "files",
|
||||
onClose: () => {},
|
||||
onToggleMaximized: () => {},
|
||||
...props,
|
||||
};
|
||||
const rerender = async (nextProps: Partial<Parameters<typeof WorkspacePanel>[0]> = {}) => {
|
||||
currentProps = { ...currentProps, ...nextProps };
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<LocaleProvider>
|
||||
<WorkspacePanel {...currentProps} />
|
||||
</LocaleProvider>,
|
||||
);
|
||||
await flushPromises();
|
||||
});
|
||||
};
|
||||
await rerender();
|
||||
return { dom, root, rerender };
|
||||
}
|
||||
|
||||
console.log("\nworkspace changes git errors");
|
||||
|
||||
{
|
||||
@@ -151,5 +192,28 @@ console.log("\nworkspace changes git errors");
|
||||
dom.window.close();
|
||||
}
|
||||
|
||||
{
|
||||
const calls: string[] = [];
|
||||
const listDir = async (dir: string): Promise<DirEntry[]> => {
|
||||
calls.push(dir);
|
||||
return [];
|
||||
};
|
||||
const { dom, root, rerender } = await renderFilesWorkspace(
|
||||
{ ListDir: listDir },
|
||||
{ fileListRequest: { id: 1, paths: ["src/app.ts"] } },
|
||||
);
|
||||
|
||||
await waitFor("initial referenced file dirs", () => calls.filter((dir) => dir === "src/").length === 1);
|
||||
await rerender({ fileListRequest: { id: 2, paths: ["src/app.ts"] } });
|
||||
await waitFor("referenced file dirs revalidated", () => calls.filter((dir) => dir === "src/").length === 2);
|
||||
|
||||
ok(calls.filter((dir) => dir === "src/").length === 2, "workspace file tree revalidates cached directories for repeated file-list requests");
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
dom.window.close();
|
||||
}
|
||||
|
||||
console.log(`\n${passed} passed, ${failed} failed, ${passed + failed} total`);
|
||||
if (failed > 0) process.exit(1);
|
||||
@@ -58,6 +58,7 @@ export function AppChrome({
|
||||
}: AppChromeProps) {
|
||||
const t = useT();
|
||||
const darwinChrome = platform === "darwin";
|
||||
const titlebarDragRail = darwinChrome || platform === "windows";
|
||||
const chromeClassName = [
|
||||
"app-chrome",
|
||||
"app-chrome--tabs",
|
||||
@@ -90,7 +91,7 @@ export function AppChrome({
|
||||
<span />
|
||||
</div>
|
||||
)}
|
||||
{darwinChrome && <span className="app-chrome__drag-rail" aria-hidden="true" />}
|
||||
{titlebarDragRail && <span className="app-chrome__drag-rail" aria-hidden="true" />}
|
||||
<button
|
||||
className={[
|
||||
"app-chrome__panel-toggle",
|
||||
|
||||
@@ -27,6 +27,10 @@ function animateShelfExit(
|
||||
options.onComplete();
|
||||
}
|
||||
|
||||
function requiresFreshHumanApproval(tool: string): boolean {
|
||||
return tool === "remember" || tool === "forget" || tool === "exit_plan_mode";
|
||||
}
|
||||
|
||||
export function ApprovalModal({
|
||||
approval,
|
||||
onAnswer,
|
||||
@@ -48,6 +52,9 @@ export function ApprovalModal({
|
||||
}) {
|
||||
const t = useT();
|
||||
const isPlanApproval = approval.tool === "exit_plan_mode";
|
||||
const isPlanModeReadOnlyCommand = approval.tool === "plan_mode_read_only_command";
|
||||
const toolLabel = isPlanModeReadOnlyCommand ? "bash" : approval.tool;
|
||||
const isFreshHumanApproval = requiresFreshHumanApproval(approval.tool);
|
||||
const subject = approval.subject.trim();
|
||||
const subjectSummary = subject.split(/\r?\n/).find((line) => line.trim())?.trim() ?? "";
|
||||
const toolMeta = approval.reason?.trim() || subjectSummary || approval.tool;
|
||||
@@ -93,9 +100,10 @@ export function ApprovalModal({
|
||||
|
||||
const chooseToolAction = (key: string) => {
|
||||
if (key === "1") answerWithExit(() => onAnswer(true, false, false));
|
||||
else if (key === "2") answerWithExit(() => onAnswer(true, true, false));
|
||||
else if (key === "3") answerWithExit(() => onAnswer(true, true, true));
|
||||
else if (key === "4") answerWithExit(() => onAnswer(false, false, false));
|
||||
else if (isFreshHumanApproval && key === "4") answerWithExit(() => onAnswer(false, false, false));
|
||||
else if (!isFreshHumanApproval && key === "2") answerWithExit(() => onAnswer(true, true, false));
|
||||
else if (!isFreshHumanApproval && key === "3") answerWithExit(() => onAnswer(true, true, true));
|
||||
else if (!isFreshHumanApproval && key === "4") answerWithExit(() => onAnswer(false, false, false));
|
||||
else if (key === "Escape") answerWithExit(onStop);
|
||||
};
|
||||
|
||||
@@ -108,7 +116,7 @@ export function ApprovalModal({
|
||||
playAttentionChime();
|
||||
}, [approval.id, isPlanApproval, showToolDetailsByDefault]);
|
||||
|
||||
const actionCount = isPlanApproval ? 3 : 4;
|
||||
const actionCount = isPlanApproval ? 3 : isFreshHumanApproval ? 2 : 4;
|
||||
const selectedIndexRef = useRef(selectedIndex);
|
||||
selectedIndexRef.current = selectedIndex;
|
||||
|
||||
@@ -129,6 +137,7 @@ export function ApprovalModal({
|
||||
event.preventDefault();
|
||||
const key = String(selectedIndexRef.current + 1);
|
||||
if (isPlanApproval) choosePlanAction(key);
|
||||
else if (isFreshHumanApproval) chooseToolAction(selectedIndexRef.current === 0 ? "1" : "4");
|
||||
else chooseToolAction(key);
|
||||
} else if (event.key === "1" || event.key === "2" || event.key === "3" || event.key === "4" || event.key === "Escape") {
|
||||
event.preventDefault();
|
||||
@@ -138,7 +147,7 @@ export function ApprovalModal({
|
||||
};
|
||||
document.addEventListener("keydown", onKeyDown);
|
||||
return () => document.removeEventListener("keydown", onKeyDown);
|
||||
}, [isPlanApproval, onAnswer, onExitPlan, onStop, actionCount]);
|
||||
}, [isPlanApproval, isFreshHumanApproval, onAnswer, onExitPlan, onStop, actionCount]);
|
||||
|
||||
useEffect(() => {
|
||||
if (revisionOpen) {
|
||||
@@ -291,7 +300,7 @@ export function ApprovalModal({
|
||||
barRef={cardRef}
|
||||
titleId="tool-approval-title"
|
||||
title={t("approval.toolPending")}
|
||||
badges={<PromptBadge>{approval.tool}</PromptBadge>}
|
||||
badges={<PromptBadge>{toolLabel}</PromptBadge>}
|
||||
meta={toolMeta}
|
||||
headerActions={
|
||||
<>
|
||||
@@ -308,9 +317,15 @@ export function ApprovalModal({
|
||||
actions={
|
||||
<>
|
||||
<PromptAction keyLabel="1" label={t("approval.allowOnce")} onClick={() => answerWithExit(() => onAnswer(true, false, false))} selected={selectedIndex === 0} />
|
||||
<PromptAction keyLabel="2" label={t("approval.allowRuleSession")} onClick={() => answerWithExit(() => onAnswer(true, true, false))} selected={selectedIndex === 1} />
|
||||
<PromptAction keyLabel="3" label={t("approval.allowRulePersistent")} onClick={() => answerWithExit(() => onAnswer(true, true, true))} selected={selectedIndex === 2} />
|
||||
<PromptAction keyLabel="4" label={t("approval.deny")} onClick={() => answerWithExit(() => onAnswer(false, false, false))} selected={selectedIndex === 3} />
|
||||
{isFreshHumanApproval ? (
|
||||
<PromptAction keyLabel="4" label={t("approval.deny")} onClick={() => answerWithExit(() => onAnswer(false, false, false))} selected={selectedIndex === 1} />
|
||||
) : (
|
||||
<>
|
||||
<PromptAction keyLabel="2" label={t("approval.allowRuleSession")} onClick={() => answerWithExit(() => onAnswer(true, true, false))} selected={selectedIndex === 1} />
|
||||
<PromptAction keyLabel="3" label={t("approval.allowRulePersistent")} onClick={() => answerWithExit(() => onAnswer(true, true, true))} selected={selectedIndex === 2} />
|
||||
<PromptAction keyLabel="4" label={t("approval.deny")} onClick={() => answerWithExit(() => onAnswer(false, false, false))} selected={selectedIndex === 3} />
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
}
|
||||
>
|
||||
|
||||
@@ -4,7 +4,7 @@ import { asArray } from "../lib/array";
|
||||
import { app, openExternal } from "../lib/bridge";
|
||||
import { useT } from "../lib/i18n";
|
||||
import { mcpServerLifecycleActions, mcpServerRetryableFromAvailableList } from "../lib/mcpServerLifecycle";
|
||||
import type { CapabilitiesView, MCPServerInput, ServerView, SkillRootSkillView, SkillRootView, SkillsSettingsView, SkillView, TabMeta } from "../lib/types";
|
||||
import type { CapabilitiesView, MCPServerInput, PluginHookView, PluginInstallOptions, PluginMCPServerView, PluginSkillView, PluginView, ServerView, SkillRootSkillView, SkillRootView, SkillsSettingsView, SkillView, TabMeta } from "../lib/types";
|
||||
import { InlineConfirmButton } from "./InlineConfirmButton";
|
||||
import { ResizableDrawer } from "./ResizableDrawer";
|
||||
import { Tooltip } from "./Tooltip";
|
||||
@@ -20,6 +20,7 @@ type SettingsSnapshot<T> = { key: string; value: T };
|
||||
|
||||
let mcpSettingsSnapshot: SettingsSnapshot<ServerView[]> | null = null;
|
||||
let skillsSettingsSnapshot: SettingsSnapshot<SkillsSettingsView> | null = null;
|
||||
let pluginsSettingsSnapshot: SettingsSnapshot<PluginView[]> | null = null;
|
||||
|
||||
function settingsSnapshotKey(meta: Awaited<ReturnType<typeof app.Meta>> | null | undefined, tabs: TabMeta[] | null | undefined): string {
|
||||
const active = tabs?.find((tab) => tab.active);
|
||||
@@ -50,7 +51,7 @@ export function CapabilitiesPanel({
|
||||
const [expandedServerTools, setExpandedServerTools] = useState<Set<string>>(() => new Set());
|
||||
|
||||
const reload = useCallback(async () => {
|
||||
setView(normalizeCapabilitiesView(await app.Capabilities().catch(() => ({ servers: [], skills: [], skillRoots: [] }))));
|
||||
setView(normalizeCapabilitiesView(await app.Capabilities().catch(() => ({ servers: [], skills: [], skillRoots: [], plugins: [] }))));
|
||||
}, []);
|
||||
useEffect(() => {
|
||||
void reload();
|
||||
@@ -314,6 +315,7 @@ export function CapabilitiesPanel({
|
||||
function normalizeCapabilitiesView(view: CapabilitiesView | null | undefined): CapabilitiesView {
|
||||
return {
|
||||
servers: normalizeServerViews(view?.servers),
|
||||
plugins: asArray(view?.plugins),
|
||||
...normalizeSkillsSettingsView(view),
|
||||
};
|
||||
}
|
||||
@@ -1590,6 +1592,619 @@ function AddServerForm({
|
||||
);
|
||||
}
|
||||
|
||||
type PluginInstallPlanAction = {
|
||||
action?: string;
|
||||
kind?: string;
|
||||
name?: string;
|
||||
source?: string;
|
||||
status?: string;
|
||||
message?: string;
|
||||
error?: string;
|
||||
};
|
||||
|
||||
type PluginInstallPlanView = {
|
||||
raw: string;
|
||||
ok?: boolean;
|
||||
status?: string;
|
||||
name?: string;
|
||||
actions: PluginInstallPlanAction[];
|
||||
warnings: string[];
|
||||
error?: string;
|
||||
};
|
||||
|
||||
type PluginInstallMode = "local" | "git";
|
||||
|
||||
// PluginsSettingsPage is the desktop plugin package manager embedded inside
|
||||
// Settings. It mirrors the MCP/Skills density: install planning on top, package
|
||||
// rows below, and diagnostics/details only when a row is expanded.
|
||||
export function PluginsSettingsPage() {
|
||||
const t = useT();
|
||||
const [snapshotKey, setSnapshotKey] = useState("");
|
||||
const [plugins, setPlugins] = useState<PluginView[] | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [err, setErr] = useState<string | null>(null);
|
||||
const [installMode, setInstallMode] = useState<PluginInstallMode>("local");
|
||||
const [localSource, setLocalSource] = useState("");
|
||||
const [gitSource, setGitSource] = useState("");
|
||||
const [name, setName] = useState("");
|
||||
const [link, setLink] = useState(false);
|
||||
const [replace, setReplace] = useState(false);
|
||||
const [plan, setPlan] = useState<PluginInstallPlanView | null>(null);
|
||||
const [notice, setNotice] = useState<string | null>(null);
|
||||
const [expanded, setExpanded] = useState<Set<string>>(() => new Set());
|
||||
const [diagnostics, setDiagnostics] = useState<Record<string, PluginView>>({});
|
||||
|
||||
const reload = useCallback(async () => {
|
||||
const [meta, tabs] = await Promise.all([
|
||||
app.Meta().catch(() => null),
|
||||
app.ListTabs().catch(() => []),
|
||||
]);
|
||||
const key = settingsSnapshotKey(meta, tabs);
|
||||
setSnapshotKey(key);
|
||||
const cached = key ? pluginsSettingsSnapshot : null;
|
||||
if (cached?.key === key) {
|
||||
setPlugins(cached.value);
|
||||
} else {
|
||||
setPlugins(null);
|
||||
}
|
||||
const next = normalizePluginViews(await app.Plugins().catch(() => []));
|
||||
pluginsSettingsSnapshot = { key, value: next };
|
||||
setPlugins(next);
|
||||
}, []);
|
||||
useEffect(() => { void reload(); }, [reload]);
|
||||
|
||||
const run = async (fn: () => Promise<unknown>, reloadAfter = true) => {
|
||||
setBusy(true);
|
||||
setErr(null);
|
||||
setNotice(null);
|
||||
try {
|
||||
const result = await fn();
|
||||
if (typeof result === "string" && result.trim()) {
|
||||
const parsed = parsePluginInstallPlan(result);
|
||||
setNotice(pluginPlanNotice(parsed, t));
|
||||
}
|
||||
if (reloadAfter) await reload();
|
||||
return true;
|
||||
} catch (e) {
|
||||
setErr(String((e as Error)?.message ?? e));
|
||||
if (reloadAfter) await reload();
|
||||
return false;
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const sourceValue = (installMode === "local" ? localSource : gitSource).trim();
|
||||
const installOptions = (): PluginInstallOptions => ({
|
||||
dryRun: false,
|
||||
link: installMode === "local" ? link : false,
|
||||
replace,
|
||||
name: installMode === "git" ? name.trim() || undefined : undefined,
|
||||
});
|
||||
const actionBusy = busy || !snapshotKey || !plugins;
|
||||
const canPlan = sourceValue.length > 0 && !actionBusy;
|
||||
const summary = plugins ? pluginListSummary(plugins, t) : "";
|
||||
const togglePlugin = useCallback((pluginName: string) => {
|
||||
setExpanded((prev) => { const next = new Set(prev); if (next.has(pluginName)) next.delete(pluginName); else next.add(pluginName); return next; });
|
||||
}, []);
|
||||
const setMode = (mode: PluginInstallMode) => {
|
||||
setInstallMode(mode);
|
||||
setPlan(null);
|
||||
};
|
||||
const previewInstall = () => {
|
||||
if (!sourceValue) return;
|
||||
void run(async () => {
|
||||
const raw = await app.PlanPluginInstall(sourceValue, { ...installOptions(), dryRun: true });
|
||||
setPlan(parsePluginInstallPlan(raw));
|
||||
}, false);
|
||||
};
|
||||
const install = () => {
|
||||
if (!sourceValue) return;
|
||||
void run(async () => {
|
||||
const raw = await app.InstallPlugin(sourceValue, installOptions());
|
||||
setPlan(parsePluginInstallPlan(raw));
|
||||
return raw;
|
||||
});
|
||||
};
|
||||
const runDoctor = (pluginName: string) => {
|
||||
void run(async () => {
|
||||
const view = normalizePluginView(await app.PluginDoctor(pluginName));
|
||||
setDiagnostics((prev) => ({ ...prev, [pluginName]: view }));
|
||||
setExpanded((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.add(pluginName);
|
||||
return next;
|
||||
});
|
||||
}, false);
|
||||
};
|
||||
const updateLocalSource = (value: string) => {
|
||||
setLocalSource(value);
|
||||
setPlan(null);
|
||||
};
|
||||
const updateGitSource = (value: string) => {
|
||||
setGitSource(value);
|
||||
setPlan(null);
|
||||
};
|
||||
const pickPluginFolder = () => {
|
||||
void run(async () => {
|
||||
const path = await app.PickPluginFolder();
|
||||
if (path) {
|
||||
setInstallMode("local");
|
||||
updateLocalSource(path);
|
||||
}
|
||||
}, false);
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="mem-section">
|
||||
{err && <div className="banner banner--error">{err}</div>}
|
||||
{notice && !err && <div className="banner banner--success">{notice}</div>}
|
||||
<div className="cap-plugin-installer">
|
||||
<div className="cap-plugin-installer__head">
|
||||
<div className="cap-plugin-installer__copy">
|
||||
<div className="cap-plugin-installer__title">{t("caps.pluginInstallTitle")}</div>
|
||||
<div className="cap-plugin-installer__hint">{t("caps.pluginInstallHint")}</div>
|
||||
</div>
|
||||
<div className="cap-tabs cap-plugin-installer__mode" role="group" aria-label={t("caps.pluginInstallMethod")}>
|
||||
<button
|
||||
className={`cap-tab${installMode === "local" ? " cap-tab--active" : ""}`}
|
||||
type="button"
|
||||
aria-pressed={installMode === "local"}
|
||||
onClick={() => setMode("local")}
|
||||
>
|
||||
{t("caps.pluginInstallLocal")}
|
||||
</button>
|
||||
<button
|
||||
className={`cap-tab${installMode === "git" ? " cap-tab--active" : ""}`}
|
||||
type="button"
|
||||
aria-pressed={installMode === "git"}
|
||||
onClick={() => setMode("git")}
|
||||
>
|
||||
{t("caps.pluginInstallGit")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="cap-plugin-form-grid">
|
||||
{installMode === "local" ? (
|
||||
<div className="cap-plugin-fields cap-plugin-fields--local">
|
||||
<div className="cap-plugin-folder-field">
|
||||
<button className="btn btn--small" disabled={actionBusy} type="button" onClick={pickPluginFolder}>
|
||||
{t("caps.pluginChooseLocalFolder")}
|
||||
</button>
|
||||
<div
|
||||
className={`cap-plugin-path${localSource ? "" : " cap-plugin-path--empty"}`}
|
||||
aria-label={t("caps.pluginLocalFolder")}
|
||||
>
|
||||
{localSource || t("caps.pluginNoLocalFolder")}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="cap-plugin-fields cap-plugin-fields--git">
|
||||
<input
|
||||
className="mem-input"
|
||||
aria-label={t("caps.pluginGitSource")}
|
||||
placeholder={t("caps.pluginSourcePlaceholder")}
|
||||
value={gitSource}
|
||||
onInput={(e) => updateGitSource(e.currentTarget.value)}
|
||||
onChange={(e) => updateGitSource(e.target.value)}
|
||||
/>
|
||||
<div className="cap-plugin-field">
|
||||
<input
|
||||
className="mem-input"
|
||||
aria-label={t("caps.pluginInstallName")}
|
||||
placeholder={t("caps.pluginInstallNamePlaceholder")}
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="cap-plugin-installer__options">
|
||||
<div className="cap-plugin-option-block">
|
||||
<label className="cap-plugin-option">
|
||||
<input type="checkbox" checked={replace} disabled={actionBusy} onChange={(e) => setReplace(e.target.checked)} />
|
||||
<span>{t("caps.pluginReplace")}</span>
|
||||
</label>
|
||||
<div className="cap-plugin-option-hint">{t("caps.pluginReplaceHint")}</div>
|
||||
</div>
|
||||
{installMode === "local" && (
|
||||
<div className="cap-plugin-option-block">
|
||||
<label className="cap-plugin-option">
|
||||
<input type="checkbox" checked={link} disabled={actionBusy} onChange={(e) => setLink(e.target.checked)} />
|
||||
<span>{t("caps.pluginLink")}</span>
|
||||
</label>
|
||||
<div className="cap-plugin-option-hint">{t("caps.pluginLinkHint")}</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="cap-plugin-installer__actions">
|
||||
<button className="btn btn--small" type="button" disabled={!canPlan} onClick={previewInstall}>
|
||||
{t("caps.pluginPreview")}
|
||||
</button>
|
||||
<button className="btn btn--primary btn--small" type="button" disabled={!canPlan} onClick={install}>
|
||||
{t("caps.pluginInstall")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{plan && <PluginPlanPreview plan={plan} />}
|
||||
<div className="cap-server-section cap-plugin-section">
|
||||
<div className="cap-server-section__head">
|
||||
<div className="cap-server-section__copy">
|
||||
<div className="cap-server-section__title">{t("caps.installedPlugins")}</div>
|
||||
{plugins && plugins.length > 0 && <div className="drawer__summary">{summary}</div>}
|
||||
</div>
|
||||
<button className="btn btn--small" disabled={actionBusy} type="button" onClick={() => void reload()}>
|
||||
{t("caps.pluginRefresh")}
|
||||
</button>
|
||||
</div>
|
||||
{!plugins ? (
|
||||
<div className="mem-empty">{t("caps.loading")}</div>
|
||||
) : plugins.length === 0 ? (
|
||||
<div className="mem-empty mem-empty--cta">
|
||||
<strong>{t("caps.noPluginsTitle")}</strong>
|
||||
<span>{t("caps.noPluginsHint")}</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="cap-server-group">
|
||||
{plugins.map((plugin) => (
|
||||
<PluginRow
|
||||
key={plugin.name}
|
||||
plugin={plugin}
|
||||
diagnostic={diagnostics[plugin.name]}
|
||||
busy={actionBusy}
|
||||
expanded={expanded.has(plugin.name)}
|
||||
onToggleDetails={() => togglePlugin(plugin.name)}
|
||||
onToggleEnabled={(enabled) => void run(() => app.SetPluginEnabled(plugin.name, enabled))}
|
||||
onUpdate={() => void run(() => app.UpdatePlugin(plugin.name))}
|
||||
onDoctor={() => runDoctor(plugin.name)}
|
||||
onRemove={() => void run(() => app.RemovePlugin(plugin.name))}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function PluginPlanPreview({ plan }: { plan: PluginInstallPlanView }) {
|
||||
const t = useT();
|
||||
return (
|
||||
<div className={`cap-plugin-plan${plan.error ? " cap-plugin-plan--error" : ""}`}>
|
||||
<div className="cap-plugin-plan__head">
|
||||
<div className="cap-plugin-plan__title">{plan.error ? t("caps.pluginPlanError") : t("caps.pluginPlanReady")}</div>
|
||||
{plan.status && <span className="cap-source-badge">{plan.status}</span>}
|
||||
</div>
|
||||
{plan.name && <div className="cap-plugin-plan__meta">{plan.name}</div>}
|
||||
{plan.error && <div className="cap-plugin-plan__warning">{plan.error}</div>}
|
||||
{plan.warnings.map((warning, idx) => (
|
||||
<div className="cap-plugin-plan__warning" key={`${warning}-${idx}`}>{warning}</div>
|
||||
))}
|
||||
{plan.actions.length > 0 ? (
|
||||
<div className="cap-plugin-actions">
|
||||
{plan.actions.map((action, idx) => (
|
||||
<div className="cap-plugin-action" key={`${action.action || action.kind || "action"}-${idx}`}>
|
||||
<span className="cap-plugin-action__name">{pluginPlanActionLabel(action, t)}</span>
|
||||
{action.status && <span className="cap-source-badge">{action.status}</span>}
|
||||
{action.source && <span className="cap-plugin-action__source">{action.source}</span>}
|
||||
{action.message && <span className="cap-plugin-action__source">{action.message}</span>}
|
||||
{action.error && <span className="cap-plugin-plan__warning">{action.error}</span>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<pre className="cap-plugin-plan__raw">{plan.raw}</pre>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PluginRow({
|
||||
plugin,
|
||||
diagnostic,
|
||||
busy,
|
||||
expanded,
|
||||
onToggleDetails,
|
||||
onToggleEnabled,
|
||||
onUpdate,
|
||||
onDoctor,
|
||||
onRemove,
|
||||
}: {
|
||||
plugin: PluginView;
|
||||
diagnostic?: PluginView;
|
||||
busy: boolean;
|
||||
expanded: boolean;
|
||||
onToggleDetails: () => void;
|
||||
onToggleEnabled: (enabled: boolean) => void;
|
||||
onUpdate: () => void;
|
||||
onDoctor: () => void;
|
||||
onRemove: () => void;
|
||||
}) {
|
||||
const t = useT();
|
||||
const status = plugin.error ? "failed" : plugin.enabled ? "connected" : "disabled";
|
||||
const warnings = pluginWarnings(plugin, diagnostic);
|
||||
const sub = plugin.error || pluginCapabilitiesSummary(plugin, t);
|
||||
return (
|
||||
<div className={`cap-server-entry cap-plugin-entry${plugin.enabled ? "" : " cap-server-entry--disabled"}`}>
|
||||
<Tooltip label={plugin.error} disabled={!plugin.error} fill block>
|
||||
<div className={`cap-row${plugin.enabled ? "" : " cap-row--disabled"}`}>
|
||||
<Tooltip label={expanded ? t("caps.collapseDetails") : t("caps.expandDetails")}>
|
||||
<button
|
||||
className="cap-disclosure"
|
||||
aria-expanded={expanded}
|
||||
type="button"
|
||||
onClick={onToggleDetails}
|
||||
>
|
||||
{expanded ? "⌄" : "›"}
|
||||
</button>
|
||||
</Tooltip>
|
||||
<span className={`cap-dot cap-dot--${status}`} />
|
||||
<div className="cap-row__text">
|
||||
<div className="cap-row__head">
|
||||
<span className="cap-row__name">{plugin.name}</span>
|
||||
{plugin.manifestKind && <span className="cap-row__transport">{plugin.manifestKind}</span>}
|
||||
{plugin.version && <span className="cap-source-badge">{plugin.version}</span>}
|
||||
{warnings.length > 0 && <span className="cap-row__update cap-row__update--error">{t("caps.pluginWarnings", { count: warnings.length })}</span>}
|
||||
</div>
|
||||
<div className="cap-row__sub">{sub}</div>
|
||||
</div>
|
||||
<div className="cap-row__actions">
|
||||
<Tooltip label={plugin.enabled ? t("caps.pluginDisable") : t("caps.pluginEnable")}>
|
||||
<label className="cap-switch">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={plugin.enabled}
|
||||
disabled={busy}
|
||||
onChange={(e) => onToggleEnabled(e.target.checked)}
|
||||
/>
|
||||
<span className="cap-switch__track" />
|
||||
</label>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
</Tooltip>
|
||||
{expanded && (
|
||||
<div className="cap-server-details">
|
||||
<div className="cap-detail-grid">
|
||||
<div className="cap-detail">
|
||||
<span className="cap-detail__label">{t("caps.status")}</span>
|
||||
<span className="cap-detail__value">{plugin.enabled ? t("caps.pluginEnabled") : t("caps.pluginDisabled")}</span>
|
||||
</div>
|
||||
{plugin.version && (
|
||||
<div className="cap-detail">
|
||||
<span className="cap-detail__label">{t("caps.pluginVersion")}</span>
|
||||
<span className="cap-detail__value">{plugin.version}</span>
|
||||
</div>
|
||||
)}
|
||||
{plugin.source && (
|
||||
<div className="cap-detail cap-detail--wide">
|
||||
<span className="cap-detail__label">{t("caps.pluginSource")}</span>
|
||||
<span className="cap-detail__code">{plugin.source}</span>
|
||||
</div>
|
||||
)}
|
||||
{plugin.root && (
|
||||
<div className="cap-detail cap-detail--wide">
|
||||
<span className="cap-detail__label">{t("caps.pluginRoot")}</span>
|
||||
<span className="cap-detail__code">{plugin.root}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{plugin.description && <div className="cap-plugin-description">{plugin.description}</div>}
|
||||
<PluginUsageDetails plugin={plugin} />
|
||||
{diagnostic?.error && <div className="cap-source__warning">{diagnostic.error}</div>}
|
||||
{warnings.map((warning, idx) => (
|
||||
<div className="cap-source__warning" key={`${plugin.name}-warning-${idx}`}>{warning}</div>
|
||||
))}
|
||||
<div className="cap-detail-actions">
|
||||
<button className="btn btn--small" disabled={busy} type="button" onClick={onUpdate}>
|
||||
{t("caps.pluginUpdate")}
|
||||
</button>
|
||||
<button className="btn btn--small" disabled={busy} type="button" onClick={onDoctor}>
|
||||
{t("caps.pluginDoctor")}
|
||||
</button>
|
||||
<InlineConfirmButton
|
||||
label={t("caps.pluginRemove")}
|
||||
confirmLabel={t("caps.pluginConfirmRemove")}
|
||||
cancelLabel={t("common.cancel")}
|
||||
disabled={busy}
|
||||
danger
|
||||
onConfirm={onRemove}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PluginUsageDetails({ plugin }: { plugin: PluginView }) {
|
||||
const t = useT();
|
||||
const skills = asArray(plugin.skillDetails);
|
||||
const hooks = asArray(plugin.hookDetails);
|
||||
const mcps = asArray(plugin.mcpServerDetails);
|
||||
const hasDetails = skills.length > 0 || hooks.length > 0 || mcps.length > 0;
|
||||
return (
|
||||
<div className="cap-plugin-usage">
|
||||
<div className="cap-plugin-usage__title">{t("caps.pluginUsageTitle")}</div>
|
||||
<div className="cap-plugin-usage__hint">
|
||||
{plugin.enabled ? t("caps.pluginUsageEnabledHint") : t("caps.pluginUsageDisabledHint")}
|
||||
</div>
|
||||
{hasDetails ? (
|
||||
<div className="cap-plugin-capabilities">
|
||||
{skills.length > 0 && <PluginSkillList skills={skills} />}
|
||||
{hooks.length > 0 && <PluginHookList hooks={hooks} />}
|
||||
{mcps.length > 0 && <PluginMCPList servers={mcps} />}
|
||||
</div>
|
||||
) : (
|
||||
<div className="cap-plugin-usage__empty">{t("caps.pluginNoCapabilityDetails")}</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PluginSkillList({ skills }: { skills: PluginSkillView[] }) {
|
||||
const t = useT();
|
||||
return (
|
||||
<div className="cap-plugin-capability">
|
||||
<div className="cap-plugin-capability__head">{t("caps.pluginSkillsTitle")}</div>
|
||||
<div className="cap-plugin-capability__hint">{t("caps.pluginSkillsHint")}</div>
|
||||
<div className="cap-plugin-capability__list">
|
||||
{skills.map((skill) => (
|
||||
<div className="cap-plugin-capability__item" key={`${skill.name}-${skill.path || skill.invocation || ""}`}>
|
||||
<div className="cap-plugin-capability__line">
|
||||
<span className="cap-plugin-capability__name">{skill.invocation || `/${skill.name}`}</span>
|
||||
{skill.runAs && <span className="cap-source-badge">{skill.runAs}</span>}
|
||||
</div>
|
||||
<div className="cap-plugin-capability__desc">{skill.description || t("caps.pluginNoDescription")}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PluginHookList({ hooks }: { hooks: PluginHookView[] }) {
|
||||
const t = useT();
|
||||
return (
|
||||
<div className="cap-plugin-capability">
|
||||
<div className="cap-plugin-capability__head">{t("caps.pluginHooksTitle")}</div>
|
||||
<div className="cap-plugin-capability__hint">{t("caps.pluginHooksHint")}</div>
|
||||
<div className="cap-plugin-capability__list">
|
||||
{hooks.map((hook, idx) => {
|
||||
const target = hook.command || hook.contextFile || t("caps.pluginHookNoTarget");
|
||||
return (
|
||||
<div className="cap-plugin-capability__item" key={`${hook.event}-${hook.match || "*"}-${target}-${idx}`}>
|
||||
<div className="cap-plugin-capability__line">
|
||||
<span className="cap-plugin-capability__name">{hook.event}</span>
|
||||
<span className="cap-source-badge">{hook.match || "*"}</span>
|
||||
</div>
|
||||
<div className="cap-plugin-capability__desc">{hook.description || target}</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PluginMCPList({ servers }: { servers: PluginMCPServerView[] }) {
|
||||
const t = useT();
|
||||
return (
|
||||
<div className="cap-plugin-capability">
|
||||
<div className="cap-plugin-capability__head">{t("caps.pluginMCPTitle")}</div>
|
||||
<div className="cap-plugin-capability__hint">{t("caps.pluginMCPHint")}</div>
|
||||
<div className="cap-plugin-capability__list">
|
||||
{servers.map((server) => (
|
||||
<div className="cap-plugin-capability__item" key={server.name}>
|
||||
<div className="cap-plugin-capability__line">
|
||||
<span className="cap-plugin-capability__name">{server.name}</span>
|
||||
{server.transport && <span className="cap-source-badge">{server.transport}</span>}
|
||||
</div>
|
||||
<div className="cap-plugin-capability__desc">{server.command || server.url || t("caps.pluginMCPNoTarget")}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function normalizePluginViews(plugins: PluginView[] | null | undefined): PluginView[] {
|
||||
return sortPluginsForDisplay(asArray(plugins).map(normalizePluginView));
|
||||
}
|
||||
|
||||
function normalizePluginView(plugin: PluginView): PluginView {
|
||||
return {
|
||||
...plugin,
|
||||
name: plugin.name || "plugin",
|
||||
root: plugin.root || "",
|
||||
enabled: Boolean(plugin.enabled),
|
||||
skills: Number.isFinite(plugin.skills) ? plugin.skills : 0,
|
||||
hooks: Number.isFinite(plugin.hooks) ? plugin.hooks : 0,
|
||||
mcpServers: Number.isFinite(plugin.mcpServers) ? plugin.mcpServers : 0,
|
||||
skillDetails: asArray(plugin.skillDetails),
|
||||
hookDetails: asArray(plugin.hookDetails),
|
||||
mcpServerDetails: asArray(plugin.mcpServerDetails),
|
||||
warnings: asArray(plugin.warnings),
|
||||
};
|
||||
}
|
||||
|
||||
function sortPluginsForDisplay(plugins: PluginView[]): PluginView[] {
|
||||
return [...plugins].sort((a, b) => {
|
||||
const priority = pluginDisplayPriority(a) - pluginDisplayPriority(b);
|
||||
if (priority !== 0) return priority;
|
||||
return a.name.localeCompare(b.name, undefined, { sensitivity: "base" });
|
||||
});
|
||||
}
|
||||
|
||||
function pluginDisplayPriority(plugin: PluginView): number {
|
||||
if (plugin.error) return 0;
|
||||
if (plugin.enabled) return 1;
|
||||
return 2;
|
||||
}
|
||||
|
||||
function pluginListSummary(plugins: PluginView[], t: ReturnType<typeof useT>): string {
|
||||
const enabled = plugins.filter((plugin) => plugin.enabled && !plugin.error).length;
|
||||
const issues = plugins.filter((plugin) => Boolean(plugin.error) || asArray(plugin.warnings).length > 0).length;
|
||||
return t("caps.pluginsSummary", { enabled, total: plugins.length, issues });
|
||||
}
|
||||
|
||||
function pluginCapabilitiesSummary(plugin: PluginView, t: ReturnType<typeof useT>): string {
|
||||
if (plugin.skills === 0 && plugin.hooks === 0 && plugin.mcpServers === 0) return t("caps.pluginNoCapabilities");
|
||||
return t("caps.pluginCounts", { skills: plugin.skills, hooks: plugin.hooks, mcps: plugin.mcpServers });
|
||||
}
|
||||
|
||||
function pluginWarnings(plugin: PluginView, diagnostic?: PluginView): string[] {
|
||||
const warnings = [...asArray(plugin.warnings), ...asArray(diagnostic?.warnings)];
|
||||
return Array.from(new Set(warnings.filter((warning) => warning.trim().length > 0)));
|
||||
}
|
||||
|
||||
function parsePluginInstallPlan(raw: string): PluginInstallPlanView {
|
||||
try {
|
||||
const value = JSON.parse(raw) as Record<string, unknown>;
|
||||
const actions = (Array.isArray(value.actions) ? value.actions : []).flatMap((action) => {
|
||||
if (!action || typeof action !== "object") return [];
|
||||
const item = action as Record<string, unknown>;
|
||||
return [{
|
||||
action: stringValue(item.action),
|
||||
kind: stringValue(item.kind),
|
||||
name: stringValue(item.name),
|
||||
source: stringValue(item.source),
|
||||
status: stringValue(item.status),
|
||||
message: stringValue(item.message),
|
||||
error: stringValue(item.error),
|
||||
}];
|
||||
});
|
||||
return {
|
||||
raw,
|
||||
ok: typeof value.ok === "boolean" ? value.ok : undefined,
|
||||
status: stringValue(value.status),
|
||||
name: stringValue(value.name),
|
||||
actions,
|
||||
warnings: (Array.isArray(value.warnings) ? value.warnings : []).flatMap((warning) => typeof warning === "string" ? [warning] : []),
|
||||
error: stringValue(value.error),
|
||||
};
|
||||
} catch {
|
||||
return { raw, actions: [], warnings: [] };
|
||||
}
|
||||
}
|
||||
|
||||
function stringValue(value: unknown): string | undefined {
|
||||
return typeof value === "string" && value.trim() ? value.trim() : undefined;
|
||||
}
|
||||
|
||||
function pluginPlanActionLabel(action: PluginInstallPlanAction, t: ReturnType<typeof useT>): string {
|
||||
const verb = action.action || action.kind || t("caps.pluginAction");
|
||||
return [verb, action.name].filter(Boolean).join(" · ");
|
||||
}
|
||||
|
||||
function pluginPlanNotice(plan: PluginInstallPlanView, t: ReturnType<typeof useT>): string {
|
||||
if (plan.error) return plan.error;
|
||||
if (plan.status === "done" || plan.status === "applied" || plan.status === "complete") return t("caps.pluginPlanInstalled");
|
||||
return plan.status ? t("caps.pluginPlanStatus", { status: plan.status }) : t("caps.pluginPlanComplete");
|
||||
}
|
||||
|
||||
// MCPServersSettingsPage is a self-contained MCP servers management page
|
||||
// embedded inside the settings centre.
|
||||
export function MCPServersSettingsPage() {
|
||||
|
||||
@@ -26,6 +26,7 @@ import { EffortSwitcher } from "./EffortSwitcher";
|
||||
import { ModelSwitcher } from "./ModelSwitcher";
|
||||
import { Tooltip } from "./Tooltip";
|
||||
import { ComposerContextCard } from "./ComposerContextCard";
|
||||
import { ImageViewer } from "./ImageViewer";
|
||||
import { VirtualMenu } from "./VirtualMenu";
|
||||
import { dirEntryMenuLabel, dirEntrySubmitPath } from "./FileReferenceMenu";
|
||||
|
||||
@@ -57,6 +58,7 @@ const PROMPT_HISTORY_PREFETCH_REMAINING = 3;
|
||||
// it; the real gap is a few ms, so keep it short or a deliberate quick second
|
||||
// Enter (submit) gets eaten too.
|
||||
const IME_CONFIRM_GRACE_MS = 100;
|
||||
const FILE_REF_SEARCH_CACHE_TTL_MS = 5000;
|
||||
|
||||
type PastedBlock = {
|
||||
label: string;
|
||||
@@ -69,6 +71,11 @@ type PendingGuidance = {
|
||||
submitText: string;
|
||||
};
|
||||
|
||||
type FileRefSearchCacheEntry = {
|
||||
entries: DirEntry[];
|
||||
cachedAt: number;
|
||||
};
|
||||
|
||||
type ComposerDraft = {
|
||||
text: string;
|
||||
attachments: Attachment[];
|
||||
@@ -444,6 +451,7 @@ export function Composer({
|
||||
retry,
|
||||
transientDismissSignal,
|
||||
sessionKey,
|
||||
fileRefRefreshKey,
|
||||
guidanceConsumedKey,
|
||||
guidanceConsumedText,
|
||||
guidanceQueuePreviewItems,
|
||||
@@ -486,6 +494,7 @@ export function Composer({
|
||||
retry?: { attempt: number; max: number };
|
||||
transientDismissSignal?: number;
|
||||
sessionKey?: string;
|
||||
fileRefRefreshKey?: number | string;
|
||||
guidanceConsumedKey?: string;
|
||||
guidanceConsumedText?: string;
|
||||
guidanceQueuePreviewItems?: readonly string[];
|
||||
@@ -496,6 +505,15 @@ export function Composer({
|
||||
const now = useTick(running);
|
||||
const [text, setText] = useState("");
|
||||
const [attachments, setAttachments] = useState<Attachment[]>([]);
|
||||
const [imageViewer, setImageViewer] = useState<{ open: boolean; url: string; name: string }>({ open: false, url: "", name: "" });
|
||||
const openComposerImageViewer = useCallback((url: string, name: string) => {
|
||||
setImageViewer({ open: true, url, name });
|
||||
}, []);
|
||||
|
||||
const closeComposerImageViewer = useCallback(() => {
|
||||
setImageViewer((prev) => (prev.open ? { ...prev, open: false } : prev));
|
||||
}, []);
|
||||
|
||||
const [workspaceRefs, setWorkspaceRefs] = useState<WorkspaceReference[]>([]);
|
||||
const [pastedBlocks, setPastedBlocks] = useState<PastedBlock[]>([]);
|
||||
const [openPastedLabels, setOpenPastedLabels] = useState<string[]>([]);
|
||||
@@ -743,16 +761,9 @@ export function Composer({
|
||||
const [entries, setEntries] = useState<DirEntry[]>([]);
|
||||
const [searchEntries, setSearchEntries] = useState<DirEntry[]>([]);
|
||||
const dirCache = useRef<Record<string, DirEntry[]>>({});
|
||||
const searchCache = useRef<Record<string, DirEntry[]>>({});
|
||||
const searchCache = useRef<Record<string, FileRefSearchCacheEntry>>({});
|
||||
|
||||
// When the workspace/project changes (cwd prop), invalidate all @ mention
|
||||
// state so the picker reloads candidates for the new project. Without this,
|
||||
// dirCache/searchCache retain entries from the old project and the picker
|
||||
// shows stale results (issue #3601).
|
||||
const prevCwdRef = useRef(cwd);
|
||||
useEffect(() => {
|
||||
if (prevCwdRef.current === cwd) return; // skip mount — state already initial
|
||||
prevCwdRef.current = cwd;
|
||||
const clearFileRefState = useCallback(() => {
|
||||
dirCache.current = {};
|
||||
searchCache.current = {};
|
||||
setEntries([]);
|
||||
@@ -763,29 +774,50 @@ export function Composer({
|
||||
setLoadingPastChats(false);
|
||||
setActive(0);
|
||||
setDismissed(false);
|
||||
}, [cwd]);
|
||||
}, []);
|
||||
|
||||
// When the workspace/project changes (cwd prop), invalidate all @ mention
|
||||
// state so the picker reloads candidates for the new project. Without this,
|
||||
// dirCache/searchCache retain entries from the old project and the picker
|
||||
// shows stale results (issue #3601).
|
||||
const prevCwdRef = useRef(cwd);
|
||||
useEffect(() => {
|
||||
if (prevCwdRef.current === cwd) return; // skip mount — state already initial
|
||||
prevCwdRef.current = cwd;
|
||||
clearFileRefState();
|
||||
}, [clearFileRefState, cwd]);
|
||||
|
||||
const prevFileRefRefreshKeyRef = useRef(fileRefRefreshKey);
|
||||
useEffect(() => {
|
||||
if (prevFileRefRefreshKeyRef.current === fileRefRefreshKey) return;
|
||||
prevFileRefRefreshKeyRef.current = fileRefRefreshKey;
|
||||
clearFileRefState();
|
||||
}, [clearFileRefState, fileRefRefreshKey]);
|
||||
|
||||
useEffect(() => {
|
||||
if (atRaw === null) return;
|
||||
const cached = dirCache.current[atDir];
|
||||
if (cached) {
|
||||
setEntries(cached);
|
||||
return;
|
||||
} else {
|
||||
setEntries([]);
|
||||
}
|
||||
let live = true;
|
||||
app
|
||||
.ListDir(atDir)
|
||||
.then((es) => {
|
||||
const list = asArray(es);
|
||||
if (!live) return;
|
||||
dirCache.current[atDir] = list;
|
||||
if (live) setEntries(list);
|
||||
setEntries(list);
|
||||
})
|
||||
.catch(() => {});
|
||||
return () => {
|
||||
live = false;
|
||||
};
|
||||
// re-fetch when the menu opens or the directory level changes
|
||||
}, [atRaw === null, atDir, cwd]);
|
||||
// Re-fetch when the menu opens, the directory level changes, or the
|
||||
// workspace tree refreshes; cached data is only a fast first paint.
|
||||
}, [atRaw === null, atDir, cwd, fileRefRefreshKey]);
|
||||
useEffect(() => {
|
||||
if (atRaw === null || atDir !== "" || atFrag === "") {
|
||||
setSearchEntries([]);
|
||||
@@ -793,23 +825,25 @@ export function Composer({
|
||||
}
|
||||
const cached = searchCache.current[atFrag];
|
||||
if (cached) {
|
||||
setSearchEntries(cached);
|
||||
return;
|
||||
setSearchEntries(cached.entries);
|
||||
if (Date.now() - cached.cachedAt < FILE_REF_SEARCH_CACHE_TTL_MS) return;
|
||||
} else {
|
||||
setSearchEntries([]);
|
||||
}
|
||||
setSearchEntries([]);
|
||||
let live = true;
|
||||
app
|
||||
.SearchFileRefs(atFrag)
|
||||
.then((es) => {
|
||||
const list = es ?? [];
|
||||
searchCache.current[atFrag] = list;
|
||||
if (live) setSearchEntries(list);
|
||||
const list = asArray(es);
|
||||
if (!live) return;
|
||||
searchCache.current[atFrag] = { entries: list, cachedAt: Date.now() };
|
||||
setSearchEntries(list);
|
||||
})
|
||||
.catch(() => {});
|
||||
return () => {
|
||||
live = false;
|
||||
};
|
||||
}, [atRaw === null, atDir, atFrag, cwd]);
|
||||
}, [atRaw === null, atDir, atFrag, cwd, fileRefRefreshKey]);
|
||||
const atMatches = useMemo(
|
||||
() => {
|
||||
if (atRaw === null) return [];
|
||||
@@ -2362,10 +2396,11 @@ export function Composer({
|
||||
<ComposerContextCard
|
||||
key={a.path}
|
||||
variant="attachment"
|
||||
tooltipLabel={a.path}
|
||||
tooltipLabel={a.previewUrl ? `${t("imageViewer.clickToPreview")} — ${a.path}` : a.path}
|
||||
removeLabel={t("composer.removeImage")}
|
||||
onRemove={() => removeAttachment(a.path)}
|
||||
previewUrl={a.previewUrl}
|
||||
onImageClick={a.previewUrl ? () => openComposerImageViewer(a.previewUrl!, attachmentName(a)) : undefined}
|
||||
imageOnly={imageOnly}
|
||||
name={attachmentName(a)}
|
||||
meta={attachmentExt(attachmentName(a)) || t("msg.fileAttachment")}
|
||||
@@ -2409,6 +2444,12 @@ export function Composer({
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<ImageViewer
|
||||
open={imageViewer.open}
|
||||
imageUrl={imageViewer.url}
|
||||
imageName={imageViewer.name}
|
||||
onClose={closeComposerImageViewer}
|
||||
/>
|
||||
{activePastedBlocks.length > 0 && (
|
||||
<div className="composer__pasted">
|
||||
{activePastedBlocks.map((block) => {
|
||||
|
||||
@@ -12,6 +12,7 @@ export function ComposerContextCard({
|
||||
removeDisabled = false,
|
||||
removeIconSize,
|
||||
previewUrl,
|
||||
onImageClick,
|
||||
imageOnly = false,
|
||||
folder = false,
|
||||
name,
|
||||
@@ -26,6 +27,8 @@ export function ComposerContextCard({
|
||||
removeDisabled?: boolean;
|
||||
removeIconSize?: number;
|
||||
previewUrl?: string;
|
||||
/** called when the image thumbnail is clicked (only for image attachments with previewUrl) */
|
||||
onImageClick?: () => void;
|
||||
imageOnly?: boolean;
|
||||
folder?: boolean;
|
||||
name?: string;
|
||||
@@ -46,7 +49,13 @@ export function ComposerContextCard({
|
||||
<Tooltip label={tooltipLabel}>
|
||||
<span className="composer-context__label">
|
||||
{previewUrl ? (
|
||||
<span className="composer-context__thumb">
|
||||
<span
|
||||
className={`composer-context__thumb${onImageClick ? " composer-context__thumb--interactive" : ""}`}
|
||||
onClick={onImageClick}
|
||||
role={onImageClick ? "button" : undefined}
|
||||
tabIndex={onImageClick ? 0 : undefined}
|
||||
onKeyDown={onImageClick ? (e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); onImageClick(); } } : undefined}
|
||||
>
|
||||
<img src={previewUrl} alt="" draggable={false} />
|
||||
</span>
|
||||
) : variant === "attachment" ? (
|
||||
|
||||
@@ -77,6 +77,22 @@ export function formatCacheHitRate(hitTokens: number, missTokens: number): strin
|
||||
|
||||
type MetricTone = "accent" | "good" | "notice" | "warn";
|
||||
type UsageAnalysisView = "source" | "type";
|
||||
type ContextUsageRefreshFields = Pick<
|
||||
WireUsage,
|
||||
"totalTokens" | "promptTokens" | "completionTokens" | "reasoningTokens" | "sessionCacheHitTokens" | "sessionCacheMissTokens"
|
||||
>;
|
||||
|
||||
export function contextUsageRefreshKey(usage?: ContextUsageRefreshFields): string {
|
||||
if (!usage) return "";
|
||||
return [
|
||||
usage.totalTokens ?? 0,
|
||||
usage.promptTokens ?? 0,
|
||||
usage.completionTokens ?? 0,
|
||||
usage.reasoningTokens ?? 0,
|
||||
usage.sessionCacheHitTokens ?? 0,
|
||||
usage.sessionCacheMissTokens ?? 0,
|
||||
].join(":");
|
||||
}
|
||||
|
||||
export function cacheHitTone(hitTokens: number, missTokens: number): MetricTone | undefined {
|
||||
const denom = hitTokens + missTokens;
|
||||
@@ -287,6 +303,8 @@ export function ContextPanel({
|
||||
const [info, setInfo] = useState<ContextPanelInfo | null>(null);
|
||||
const [analysisView, setAnalysisView] = useState<UsageAnalysisView>("source");
|
||||
const refreshSeq = useRef(0);
|
||||
const lastRefreshTime = useRef(0);
|
||||
const usageRefreshKey = contextUsageRefreshKey(usage);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
if (!tabId) return;
|
||||
@@ -311,19 +329,23 @@ export function ContextPanel({
|
||||
void refresh();
|
||||
}, [refresh, refreshKey]);
|
||||
|
||||
const hasPanelUsage = Boolean(
|
||||
(info?.requestCount ?? 0) > 0 ||
|
||||
(info?.promptTokens ?? 0) > 0 ||
|
||||
(info?.completionTokens ?? 0) > 0 ||
|
||||
(info?.totalTokens ?? 0) > 0 ||
|
||||
(info?.reasoningTokens ?? 0) > 0 ||
|
||||
(info?.cacheHitTokens ?? 0) > 0 ||
|
||||
(info?.cacheMissTokens ?? 0) > 0
|
||||
);
|
||||
// Refresh the panel snapshot while usage events stream. The key includes
|
||||
// general token fields so providers without cache telemetry still tick.
|
||||
useEffect(() => {
|
||||
if (!usageRefreshKey) return;
|
||||
const now = Date.now();
|
||||
if (now - lastRefreshTime.current >= 1000) {
|
||||
lastRefreshTime.current = now;
|
||||
void refresh();
|
||||
}
|
||||
}, [usageRefreshKey, refresh]);
|
||||
|
||||
const usedTokens = context?.used && context.used > 0 ? context.used : info?.usedTokens ?? 0;
|
||||
const windowTokens = context?.window && context.window > 0 ? context.window : info?.windowTokens ?? 0;
|
||||
const promptTokens = hasPanelUsage ? info?.promptTokens ?? 0 : usage?.promptTokens ?? 0;
|
||||
const completionTokens = hasPanelUsage ? info?.completionTokens ?? 0 : usage?.completionTokens ?? 0;
|
||||
// Prefer live usage props (updated in real-time by the reducer during streaming)
|
||||
// over the async-fetched info snapshot (only refreshed on turn_done).
|
||||
const promptTokens = usage?.promptTokens ?? info?.promptTokens ?? 0;
|
||||
const completionTokens = usage?.completionTokens ?? info?.completionTokens ?? 0;
|
||||
const totalTokens = info?.totalTokens && info.totalTokens > 0
|
||||
? info.totalTokens
|
||||
: sessionTokens && sessionTokens > 0
|
||||
@@ -331,10 +353,12 @@ export function ContextPanel({
|
||||
: usage?.totalTokens && usage.totalTokens > 0
|
||||
? usage.totalTokens
|
||||
: promptTokens + completionTokens;
|
||||
const reasoningTokens = hasPanelUsage ? info?.reasoningTokens ?? 0 : usage?.reasoningTokens ?? 0;
|
||||
const reasoningTokens = usage?.reasoningTokens ?? info?.reasoningTokens ?? 0;
|
||||
// Session-cumulative values for the top summary.
|
||||
const sessionCacheHit = info?.sessionCacheHitTokens ?? usage?.sessionCacheHitTokens ?? context?.cacheHitTokens ?? 0;
|
||||
const sessionCacheMiss = info?.sessionCacheMissTokens ?? usage?.sessionCacheMissTokens ?? context?.cacheMissTokens ?? 0;
|
||||
// Prefer usage.sessionCacheHitTokens — it is the session-cumulative value from
|
||||
// the Go agent (includes background tasks) and arrives with every usage event.
|
||||
const sessionCacheHit = usage?.sessionCacheHitTokens ?? info?.sessionCacheHitTokens ?? context?.cacheHitTokens ?? 0;
|
||||
const sessionCacheMiss = usage?.sessionCacheMissTokens ?? info?.sessionCacheMissTokens ?? context?.cacheMissTokens ?? 0;
|
||||
const totalTokensMetric = formatMetricTokens(totalTokens, locale);
|
||||
const cost = contextCostDisplay({ info, sessionCost, sessionCurrency, usage });
|
||||
const sourceUsageRows = contextSourceRows(info, sessionCurrency);
|
||||
|
||||
@@ -6,6 +6,13 @@ import { app } from "../lib/bridge";
|
||||
import type { DirEntry } from "../lib/types";
|
||||
import { VirtualMenu } from "./VirtualMenu";
|
||||
|
||||
const FILE_REF_SEARCH_CACHE_TTL_MS = 5000;
|
||||
|
||||
type FileRefSearchCacheEntry = {
|
||||
entries: DirEntry[];
|
||||
cachedAt: number;
|
||||
};
|
||||
|
||||
export function dirEntrySubmitPath(entry: DirEntry, atDir: string): string {
|
||||
return entry.path || atDir + entry.name;
|
||||
}
|
||||
@@ -58,7 +65,7 @@ export function useFileReferenceMenu(text: string, cwd?: string) {
|
||||
const [active, setActive] = useState(0);
|
||||
const [dismissed, setDismissed] = useState(false);
|
||||
const dirCache = useRef<Record<string, DirEntry[]>>({});
|
||||
const searchCache = useRef<Record<string, DirEntry[]>>({});
|
||||
const searchCache = useRef<Record<string, FileRefSearchCacheEntry>>({});
|
||||
const prevCwdRef = useRef(cwd);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -82,15 +89,17 @@ export function useFileReferenceMenu(text: string, cwd?: string) {
|
||||
const cached = dirCache.current[atDir];
|
||||
if (cached) {
|
||||
setEntries(cached);
|
||||
return;
|
||||
} else {
|
||||
setEntries([]);
|
||||
}
|
||||
let live = true;
|
||||
app
|
||||
.ListDir(atDir)
|
||||
.then((next) => {
|
||||
const list = asArray(next);
|
||||
if (!live) return;
|
||||
dirCache.current[atDir] = list;
|
||||
if (live) setEntries(list);
|
||||
setEntries(list);
|
||||
})
|
||||
.catch(() => {});
|
||||
return () => {
|
||||
@@ -105,17 +114,19 @@ export function useFileReferenceMenu(text: string, cwd?: string) {
|
||||
}
|
||||
const cached = searchCache.current[atFrag];
|
||||
if (cached) {
|
||||
setSearchEntries(cached);
|
||||
return;
|
||||
setSearchEntries(cached.entries);
|
||||
if (Date.now() - cached.cachedAt < FILE_REF_SEARCH_CACHE_TTL_MS) return;
|
||||
} else {
|
||||
setSearchEntries([]);
|
||||
}
|
||||
setSearchEntries([]);
|
||||
let live = true;
|
||||
app
|
||||
.SearchFileRefs(atFrag)
|
||||
.then((next) => {
|
||||
const list = next ?? [];
|
||||
searchCache.current[atFrag] = list;
|
||||
if (live) setSearchEntries(list);
|
||||
const list = asArray(next);
|
||||
if (!live) return;
|
||||
searchCache.current[atFrag] = { entries: list, cachedAt: Date.now() };
|
||||
setSearchEntries(list);
|
||||
})
|
||||
.catch(() => {});
|
||||
return () => {
|
||||
|
||||
@@ -470,6 +470,7 @@ export function HistoryPanel({
|
||||
{!isTrash && s.current && <span className="hist-item__badge hist-item__badge--current">{tr("history.current")}</span>}
|
||||
{!isTrash && !s.current && s.open && <span className="hist-item__badge hist-item__badge--open">{tr("history.open")}</span>}
|
||||
{isTrash && <span className="hist-item__badge hist-item__badge--deleted">{tr("history.deleted")}</span>}
|
||||
{s.recovered && <span className="hist-item__badge">{tr("recovery.badge")}</span>}
|
||||
{sessionLocation(s, tr) && <span className="hist-item__scope">{sessionLocation(s, tr)}</span>}
|
||||
<span className="hist-item__metaspacer" />
|
||||
<span className="hist-item__stat">{tr(s.turns === 1 ? "history.turnOne" : "history.turnOther", { n: s.turns })}</span>
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { X } from "lucide-react";
|
||||
import { useT } from "../lib/i18n";
|
||||
|
||||
export interface ImageViewerProps {
|
||||
open: boolean;
|
||||
/** data-URL of the image to display */
|
||||
imageUrl: string;
|
||||
/** optional filename shown at the bottom */
|
||||
imageName?: string;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* ImageViewer renders a full-size image preview in a portal overlay.
|
||||
*
|
||||
* It follows the same portal pattern as MermaidDiagram fullscreen:
|
||||
* the overlay is portaled into .chat-pane (fallback document.body) so it
|
||||
* covers the chat area without being clipped by overflow containers.
|
||||
*/
|
||||
export function ImageViewer({ open, imageUrl, imageName, onClose }: ImageViewerProps) {
|
||||
const t = useT();
|
||||
const [portalTarget, setPortalTarget] = useState<Element | null>(null);
|
||||
const [visible, setVisible] = useState(false);
|
||||
|
||||
// Resolve portal target when opening.
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
setVisible(false);
|
||||
setPortalTarget(null);
|
||||
return;
|
||||
}
|
||||
const target = document.querySelector(".chat-pane") ?? document.body;
|
||||
setPortalTarget(target);
|
||||
// Trigger enter animation on the next frame.
|
||||
const raf = requestAnimationFrame(() => setVisible(true));
|
||||
return () => cancelAnimationFrame(raf);
|
||||
}, [open]);
|
||||
|
||||
// Close on Escape.
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const onKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === "Escape") onClose();
|
||||
};
|
||||
document.addEventListener("keydown", onKeyDown);
|
||||
return () => document.removeEventListener("keydown", onKeyDown);
|
||||
}, [open, onClose]);
|
||||
|
||||
// Prevent body scroll while open.
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const prev = document.body.style.overflow;
|
||||
document.body.style.overflow = "hidden";
|
||||
return () => {
|
||||
document.body.style.overflow = prev;
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
const handleBackdropClick = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
if (e.target === e.currentTarget) onClose();
|
||||
},
|
||||
[onClose],
|
||||
);
|
||||
|
||||
if (!open || !portalTarget || !imageUrl) return null;
|
||||
|
||||
const overlay = (
|
||||
<div
|
||||
className={`image-viewer-backdrop${visible ? " image-viewer--enter" : ""}`}
|
||||
onClick={handleBackdropClick}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={imageName || t("imageViewer.title")}
|
||||
>
|
||||
<button
|
||||
className="image-viewer__close"
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
aria-label={t("imageViewer.closePreview")}
|
||||
>
|
||||
<X size={22} />
|
||||
</button>
|
||||
<div className="image-viewer__content">
|
||||
<img
|
||||
className="image-viewer__image"
|
||||
src={imageUrl}
|
||||
alt={imageName || ""}
|
||||
draggable={false}
|
||||
/>
|
||||
{imageName && <div className="image-viewer__name">{imageName}</div>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return createPortal(overlay, portalTarget);
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { memo, useEffect, useRef, useState } from "react";
|
||||
import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import type { FormEvent, KeyboardEvent as ReactKeyboardEvent } from "react";
|
||||
import { BrainCircuit, ChevronDown, ChevronRight, FileText, Folder, GitBranch, Image, MessageSquare, Pencil, RotateCcw, ScrollText } from "lucide-react";
|
||||
import { Markdown } from "./Markdown";
|
||||
@@ -10,6 +10,7 @@ import type { DisplayAttachment } from "../lib/attachmentDisplay";
|
||||
import { app } from "../lib/bridge";
|
||||
import { replaySubmitText } from "../lib/editReplay";
|
||||
import { useT } from "../lib/i18n";
|
||||
import { ImageViewer } from "./ImageViewer";
|
||||
import { Tooltip } from "./Tooltip";
|
||||
import { useGSAPCollapse } from "../lib/useGSAPCollapse";
|
||||
import { displayReasoningText } from "../lib/reasoningDisplay";
|
||||
@@ -81,6 +82,31 @@ function mergeDisplayAttachments(existing: DisplayAttachment[], incoming: Displa
|
||||
return merged;
|
||||
}
|
||||
|
||||
type PastedBlockInfo = {
|
||||
label: string;
|
||||
content: string;
|
||||
};
|
||||
|
||||
const PASTE_LABEL_RE = /\[(?:已粘贴文本|已貼上文字|Pasted text) #\d+ · \d+ (?:行|lines)\]/g;
|
||||
|
||||
export function parsePastedBlocks(text: string, submitText?: string): PastedBlockInfo[] {
|
||||
const labels = text.match(PASTE_LABEL_RE);
|
||||
if (!labels || labels.length === 0 || !submitText) return [];
|
||||
const unique = [...new Set(labels)];
|
||||
const blocks: PastedBlockInfo[] = [];
|
||||
for (const label of unique) {
|
||||
const beginMarker = `--- Begin ${label} ---`;
|
||||
const endMarker = `--- End ${label} ---`;
|
||||
const beginIdx = submitText.indexOf(beginMarker);
|
||||
const endIdx = submitText.indexOf(endMarker);
|
||||
if (beginIdx < 0 || endIdx <= beginIdx) continue;
|
||||
const contentStart = beginIdx + beginMarker.length;
|
||||
const content = submitText.slice(contentStart, endIdx).replace(/^\r?\n/, "");
|
||||
blocks.push({ label, content });
|
||||
}
|
||||
return blocks;
|
||||
}
|
||||
|
||||
function MemoryCitations({ citations }: { citations?: MemoryCitation[] }) {
|
||||
const t = useT();
|
||||
const bodyRef = useRef<HTMLDivElement>(null);
|
||||
@@ -181,6 +207,65 @@ export function UserMessage({
|
||||
const [editSubmitting, setEditSubmitting] = useState(false);
|
||||
const editRef = useRef<HTMLTextAreaElement>(null);
|
||||
const [imagePreviews, setImagePreviews] = useState<Record<string, string>>({});
|
||||
const [imageViewer, setImageViewer] = useState<{ open: boolean; url: string; name: string }>({ open: false, url: "", name: "" });
|
||||
const openImageViewer = useCallback(async (path: string, name: string) => {
|
||||
let url = imagePreviews[path];
|
||||
if (!url) {
|
||||
try {
|
||||
url = await app.AttachmentDataURL(path);
|
||||
setImagePreviews((prev) => (prev[path] ? prev : { ...prev, [path]: url }));
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
}
|
||||
setImageViewer({ open: true, url, name });
|
||||
}, [imagePreviews]);
|
||||
|
||||
const closeImageViewer = useCallback(() => {
|
||||
setImageViewer((prev) => (prev.open ? { ...prev, open: false } : prev));
|
||||
}, []);
|
||||
|
||||
const pasteBlocks = useMemo(() => parsePastedBlocks(actionText, submitText), [actionText, submitText]);
|
||||
const [expandedPasteLabels, setExpandedPasteLabels] = useState<Record<string, boolean>>({});
|
||||
|
||||
type DisplaySegment =
|
||||
| { type: "text"; content: string }
|
||||
| { type: "paste"; block: PastedBlockInfo };
|
||||
|
||||
const displaySegments = useMemo((): DisplaySegment[] => {
|
||||
if (pasteBlocks.length === 0) return [{ type: "text", content: displayText }];
|
||||
const segments: DisplaySegment[] = [];
|
||||
// Order blocks by their position in the text so cards appear inline.
|
||||
const ordered = pasteBlocks
|
||||
.map((b) => ({ block: b, pos: displayText.indexOf(b.label) }))
|
||||
.filter((x) => x.pos >= 0)
|
||||
.sort((a, b) => a.pos - b.pos);
|
||||
let remaining = displayText;
|
||||
for (const { block } of ordered) {
|
||||
const idx = remaining.indexOf(block.label);
|
||||
if (idx < 0) continue;
|
||||
// Text before the label: strip the trailing newline that separated the
|
||||
// label from the preceding line so the card sits tight against the text.
|
||||
if (idx > 0) {
|
||||
let before = remaining.slice(0, idx);
|
||||
before = before.replace(/\n$/, "");
|
||||
if (before) segments.push({ type: "text", content: before });
|
||||
}
|
||||
segments.push({ type: "paste", block });
|
||||
remaining = remaining.slice(idx + block.label.length);
|
||||
}
|
||||
// Strip the leading newline that followed the label.
|
||||
remaining = remaining.replace(/^\n/, "");
|
||||
if (remaining.trim()) segments.push({ type: "text", content: remaining });
|
||||
return segments.length > 0 ? segments : [{ type: "text", content: displayText }];
|
||||
}, [displayText, pasteBlocks]);
|
||||
|
||||
const togglePasteExpand = (label: string) => {
|
||||
setExpandedPasteLabels((prev) => ({
|
||||
...prev,
|
||||
[label]: !prev[label],
|
||||
}));
|
||||
};
|
||||
const orderedDraftAttachments = sortDisplayAttachments(draftAttachments);
|
||||
const imagePreviewKey = orderedAttachments
|
||||
.concat(orderedDraftAttachments)
|
||||
@@ -305,11 +390,12 @@ export function UserMessage({
|
||||
<ComposerContextCard
|
||||
key={attachment.path}
|
||||
variant={attachment.source === "workspace" ? "workspace" : "attachment"}
|
||||
tooltipLabel={attachment.source === "workspace" ? formatAttachmentRefForSubmit(attachment) : attachment.path}
|
||||
tooltipLabel={imagePreview ? `${t("imageViewer.clickToPreview")} — ${attachment.path}` : attachment.source === "workspace" ? formatAttachmentRefForSubmit(attachment) : attachment.path}
|
||||
removeLabel={attachment.source === "workspace" ? t("composer.removeReference") : t("composer.removeImage")}
|
||||
removeDisabled={editSubmitting}
|
||||
onRemove={() => removeDraftAttachment(attachment.path)}
|
||||
previewUrl={imagePreview}
|
||||
onImageClick={imagePreview ? () => openImageViewer(attachment.path, attachment.name) : undefined}
|
||||
imageOnly={imageOnly}
|
||||
folder={attachment.kind === "folder"}
|
||||
label={attachment.kind === "folder" ? `${attachment.name}/` : attachment.name}
|
||||
@@ -355,26 +441,78 @@ export function UserMessage({
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
displayText && <div className="msg__text">{displayText}</div>
|
||||
<>
|
||||
{displaySegments.map((seg, i) => {
|
||||
if (seg.type === "text") {
|
||||
return seg.content ? <div className="msg__text" key={`s${i}`}>{seg.content}</div> : null;
|
||||
}
|
||||
const expanded = Boolean(expandedPasteLabels[seg.block.label]);
|
||||
return (
|
||||
<div className="msg-pasted" key={seg.block.label}>
|
||||
<div className="msg-pasted-block">
|
||||
<div className="msg-pasted-head">
|
||||
<FileText size={15} />
|
||||
<span className="msg-pasted-label">{seg.block.label}</span>
|
||||
<div className="msg-pasted-actions">
|
||||
<Tooltip label={t(expanded ? "msg.pastedCollapseTooltip" : "msg.pastedExpandTooltip")}>
|
||||
<button type="button" onClick={() => togglePasteExpand(seg.block.label)}>
|
||||
{expanded ? t("common.collapse") : t("composer.pastedExpand")}
|
||||
</button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
{expanded && (
|
||||
<div className="msg-pasted-expanded">{seg.block.content}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
{failed && <div className="msg__send-failed">{t("msg.sendFailed")}</div>}
|
||||
{orderedAttachments.length > 0 && (
|
||||
<div className="msg-attachments" aria-label={t("msg.attachments")}>
|
||||
{orderedAttachments.map((attachment, index) => (
|
||||
<div className={`msg-attachment msg-attachment--${attachment.kind}`} key={`${attachment.path}:${index}`} title={attachment.path}>
|
||||
<span className={`msg-attachment__icon msg-attachment__icon--${attachment.kind}`} aria-hidden="true">
|
||||
{attachment.kind === "image" && imagePreviews[attachment.path] ? <img src={imagePreviews[attachment.path]} alt="" draggable={false} /> : attachmentIcon(attachment.kind)}
|
||||
</span>
|
||||
<span className="msg-attachment__main">
|
||||
<span className="msg-attachment__name">{attachment.name}</span>
|
||||
<span className="msg-attachment__meta">
|
||||
{attachment.kind === "folder"
|
||||
? t("msg.folderReference")
|
||||
: `${attachment.ext || t("msg.fileAttachment")} · ${attachment.source === "workspace" ? t("msg.workspaceReference") : attachment.kind === "image" ? t("msg.imageAttachment") : t("msg.fileAttachment")}`}
|
||||
{orderedAttachments.map((attachment, index) => {
|
||||
const isImage = attachment.kind === "image";
|
||||
const el = (
|
||||
<div
|
||||
className={`msg-attachment msg-attachment--${attachment.kind}`}
|
||||
key={isImage ? undefined : `${attachment.path}:${index}`}
|
||||
title={isImage ? undefined : attachment.path}
|
||||
onClick={isImage ? () => openImageViewer(attachment.path, attachment.name) : undefined}
|
||||
role={isImage ? "button" : undefined}
|
||||
tabIndex={isImage ? 0 : undefined}
|
||||
onKeyDown={isImage ? (e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); openImageViewer(attachment.path, attachment.name); } } : undefined}
|
||||
>
|
||||
<span className={`msg-attachment__icon msg-attachment__icon--${attachment.kind}`} aria-hidden="true">
|
||||
{isImage && imagePreviews[attachment.path] ? <img src={imagePreviews[attachment.path]} alt="" draggable={false} /> : attachmentIcon(attachment.kind)}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
<span className="msg-attachment__main">
|
||||
<span className="msg-attachment__name">{attachment.name}</span>
|
||||
<span className="msg-attachment__meta">
|
||||
{attachment.kind === "folder"
|
||||
? t("msg.folderReference")
|
||||
: `${attachment.ext || t("msg.fileAttachment")} · ${attachment.source === "workspace" ? t("msg.workspaceReference") : attachment.kind === "image" ? t("msg.imageAttachment") : t("msg.fileAttachment")}`}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
if (isImage) {
|
||||
return (
|
||||
<Tooltip key={`${attachment.path}:${index}`} label={t("imageViewer.clickToPreview")} block>
|
||||
{el}
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
return el;
|
||||
})}
|
||||
<ImageViewer
|
||||
open={imageViewer.open}
|
||||
imageUrl={imageViewer.url}
|
||||
imageName={imageViewer.name}
|
||||
onClose={closeImageViewer}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -123,16 +123,20 @@ function topicIsActive(node: ProjectNode, activeScope?: string, activeWorkspaceR
|
||||
);
|
||||
}
|
||||
|
||||
function topicMetaLine(node: ProjectNode, t: Translator, compact = false): string {
|
||||
export function projectTreeTopicMetaLine(node: ProjectNode, t: Translator, compact = false): string {
|
||||
const parts: string[] = [];
|
||||
const turns = node.turns ?? 0;
|
||||
if (turns > 0) parts.push(t(turns === 1 ? "history.turnOne" : "history.turnOther", { n: turns }));
|
||||
const activityAt = node.lastActivityAt || node.createdAt || 0;
|
||||
if (activityAt) parts.push(topicActivityLabel(activityAt, t, compact));
|
||||
if (parts.length === 0) parts.push(t("projectTree.justNow"));
|
||||
if (parts.length === 0) parts.push(t("projectTree.previously"));
|
||||
return parts.join(" · ");
|
||||
}
|
||||
|
||||
function topicUnknownTimeLabel(node: ProjectNode, t: Translator): string {
|
||||
return topicActivityAt(node) ? "" : t("projectTree.previously");
|
||||
}
|
||||
|
||||
const topicStatusLabels: Record<ProjectTopicStatus, DictKey> = {
|
||||
thinking: "projectTree.status.thinking",
|
||||
streaming: "projectTree.status.streaming",
|
||||
@@ -1050,8 +1054,10 @@ export function ProjectTree({
|
||||
const filtered = tree
|
||||
.map(filterNode)
|
||||
.filter((node): node is ProjectNode => node !== null);
|
||||
return compactTopics ? arrangeWorkbenchTree(filtered, workbenchOrganizeMode, workbenchSortMode) : filtered;
|
||||
}, [compactTopics, query, tree, timeFilter, workbenchOrganizeMode, workbenchSortMode]);
|
||||
if (compactTopics) return arrangeWorkbenchTree(filtered, workbenchOrganizeMode, workbenchSortMode);
|
||||
if (creationTopics) return arrangeWorkbenchTree(filtered, "project", "updated");
|
||||
return filtered;
|
||||
}, [compactTopics, creationTopics, query, tree, timeFilter, workbenchOrganizeMode, workbenchSortMode]);
|
||||
|
||||
const workbenchTreeSections = useMemo<WorkbenchTreeSections>(() => {
|
||||
if (!compactTopics) return { pinned: [], projects: visibleTree };
|
||||
@@ -1126,11 +1132,12 @@ export function ProjectTree({
|
||||
const accentStyle = projectAccentStyle(node.projectColor, scope === "global" ? "var(--project-tree-global-accent)" : undefined);
|
||||
const active = topicIsActive(node, activeScope, activeWorkspaceRoot, activeTopicId, activeSessionPath);
|
||||
const label = (node.label || node.topicId || "Untitled").replace(/^●\s*/, "");
|
||||
const conflictCopyLabel = isSessionNode && node.recovered ? t("recovery.badge") : "";
|
||||
const activityAt = node.lastActivityAt || node.createdAt || 0;
|
||||
const sideTimeVisible = compactTopics || creationTopics;
|
||||
const timeLabel = sideTimeVisible && activityAt ? topicActivityLabel(activityAt, t, true) : "";
|
||||
const timeLabel = sideTimeVisible ? (activityAt ? topicActivityLabel(activityAt, t, true) : topicUnknownTimeLabel(node, t)) : "";
|
||||
const exactTimeLabel = sideTimeVisible && activityAt ? topicActivityDateLabel(activityAt) : "";
|
||||
const meta = topicMetaLine(node, t, compactTopics);
|
||||
const meta = projectTreeTopicMetaLine(node, t, compactTopics);
|
||||
const status = topicStatus(node);
|
||||
const statusLabel = topicStatusLabel(node, t);
|
||||
const showStatusInSide = status === "thinking" || status === "streaming" || status === "waiting_confirmation" || status === "background_job";
|
||||
@@ -1140,7 +1147,8 @@ export function ProjectTree({
|
||||
const imSourceLabel = imSource?.label || "";
|
||||
const imSourceTitle = imSourceLabel ? t("msg.fromIm", { source: imSourceLabel }) : "";
|
||||
const imSourcePlatform = (imSource?.platform || "im").replace(/[^a-z0-9_-]/gi, "").toLowerCase() || "im";
|
||||
const title = [label, imSourceTitle, statusLabel, meta, exactTimeLabel].filter(Boolean).join(" · ");
|
||||
const conflictCopyTitle = isSessionNode && node.recovered ? t("recovery.branch") : "";
|
||||
const title = [label, conflictCopyTitle, imSourceTitle, statusLabel, meta, exactTimeLabel].filter(Boolean).join(" · ");
|
||||
const topicMenuOpen = !isSessionNode && menuTopic === topicId;
|
||||
const pinned = Boolean(node.pinned);
|
||||
const pinLabel = t(pinned ? "projectTree.unpinTopic" : "projectTree.pinTopic");
|
||||
@@ -1259,7 +1267,7 @@ export function ProjectTree({
|
||||
>
|
||||
<span className="project-tree__topic-copy">
|
||||
<span className="project-tree__topic-heading">
|
||||
<span className="project-tree__topic-label">{label}</span>
|
||||
<span className="project-tree__topic-label">{conflictCopyLabel ? `${label} · ${conflictCopyLabel}` : label}</span>
|
||||
{imSource && (
|
||||
<span
|
||||
className={`project-tree__topic-im project-tree__topic-im--${imSourcePlatform}`}
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -91,7 +91,7 @@ export const ToolCard = memo(function ToolCard({ item, subcalls, tabId, displayN
|
||||
app.ToolResultForTab(tabId, item.id).then((d) => {
|
||||
if (!cancelled && d) setFullData(d);
|
||||
}).catch(() => {}),
|
||||
);
|
||||
).catch(() => {});
|
||||
return () => { cancelled = true; };
|
||||
}, [open, item.id, item.dataArchived, fullData, tabId]);
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@ import { useGSAPCollapse } from "../lib/useGSAPCollapse";
|
||||
import { useEntranceAnimation } from "../lib/useEntranceAnimation";
|
||||
import { useScrollManager } from "../lib/useScrollManager";
|
||||
import { buildStepGroups, buildTurnGroups, compactQuestionText, createWarmLayerState, questionAnchorId, questionTurnsById, scrollVersion, warmColdPageForTurn, warmLayerWithColdPageAtLeast, warmLayerWithExpandedTurn, warmLayerWithNextColdPage, warmPagination, warmUserPreview, type QuestionAnchor, type TurnGroup, type WarmLayerState } from "../lib/transcriptGrouping";
|
||||
import { appendTurnActionCopyText } from "../lib/turnActionCopy";
|
||||
|
||||
type ToolItem = Extract<Item, { kind: "tool" }>;
|
||||
type AssistantItem = Extract<Item, { kind: "assistant" }>;
|
||||
@@ -500,7 +501,7 @@ export function Transcript({
|
||||
/>,
|
||||
);
|
||||
if (!it.streaming && it.text.trim() !== "") {
|
||||
actionText = it.text;
|
||||
actionText = appendTurnActionCopyText(actionText, it.text);
|
||||
actionReady = true;
|
||||
}
|
||||
}
|
||||
@@ -573,7 +574,7 @@ export function Transcript({
|
||||
case "assistant":
|
||||
out.push(<LiveAssistantMessage key={it.id} item={it as AssistantItem} defaultExpanded={false} creationMode={creationMode} />);
|
||||
if (!it.streaming && it.text.trim() !== "") {
|
||||
actionText = it.text;
|
||||
actionText = appendTurnActionCopyText(actionText, it.text);
|
||||
actionReady = true;
|
||||
}
|
||||
break;
|
||||
@@ -926,7 +927,7 @@ function WarmTurnItems({
|
||||
case "assistant": {
|
||||
nodes.push(<AssistantMessage key={it.id} item={it} defaultExpanded={false} creationMode={creationMode} />);
|
||||
if (!it.streaming && it.text.trim() !== "") {
|
||||
actionText = it.text;
|
||||
actionText = appendTurnActionCopyText(actionText, it.text);
|
||||
actionReady = true;
|
||||
}
|
||||
break;
|
||||
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
Search,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { asArray } from "../lib/array";
|
||||
import { app } from "../lib/bridge";
|
||||
import { useT } from "../lib/i18n";
|
||||
import {
|
||||
@@ -206,6 +207,7 @@ export function WorkspacePanel({
|
||||
onPreviewModeChange,
|
||||
onAddToChat,
|
||||
onRequestPanelWidth,
|
||||
onFileTreeRefresh,
|
||||
refreshKey,
|
||||
initialViewMode = "files",
|
||||
revealPathRequest,
|
||||
@@ -224,6 +226,7 @@ export function WorkspacePanel({
|
||||
onPreviewModeChange?: (active: boolean) => void;
|
||||
onAddToChat?: (text: string) => void;
|
||||
onRequestPanelWidth?: (width: number) => void;
|
||||
onFileTreeRefresh?: () => void;
|
||||
refreshKey?: number;
|
||||
initialViewMode?: "files" | "changed";
|
||||
revealPathRequest?: WorkspaceRevealRequest | null;
|
||||
@@ -275,6 +278,8 @@ export function WorkspacePanel({
|
||||
const workspaceChangesRequestIdRef = useRef(0);
|
||||
const gitHistoryRequestIdRef = useRef(0);
|
||||
const commitDetailRequestIdRef = useRef(0);
|
||||
const dirLoadGenerationRef = useRef(0);
|
||||
const dirLoadRequestIdsRef = useRef<Record<string, number>>({});
|
||||
const recentAnchorRef = useRef<HTMLButtonElement>(null);
|
||||
const openDirsRef = useRef(openDirs);
|
||||
const pendingTreeRevealPathRef = useRef<string | null>(null);
|
||||
@@ -284,8 +289,12 @@ export function WorkspacePanel({
|
||||
}, [openDirs]);
|
||||
|
||||
const loadDir = useCallback(async (dir: string) => {
|
||||
const entries = await app.ListDir(dir).catch(() => []);
|
||||
setEntriesByDir((prev) => ({ ...prev, [dir]: entries ?? [] }));
|
||||
const generation = dirLoadGenerationRef.current;
|
||||
const requestId = (dirLoadRequestIdsRef.current[dir] ?? 0) + 1;
|
||||
dirLoadRequestIdsRef.current[dir] = requestId;
|
||||
const entries = await app.ListDir(dir).catch((): DirEntry[] => []);
|
||||
if (dirLoadGenerationRef.current !== generation || dirLoadRequestIdsRef.current[dir] !== requestId) return;
|
||||
setEntriesByDir((prev) => ({ ...prev, [dir]: asArray(entries) }));
|
||||
}, []);
|
||||
|
||||
const loadGitHistory = useCallback(async () => {
|
||||
@@ -399,15 +408,15 @@ export function WorkspacePanel({
|
||||
setOpenTabs((tabs) => [...tabs.filter((tab) => tab !== path), path].slice(-WORKSPACE_MAX_PREVIEW_TABS));
|
||||
const dirs = parentDirs(path);
|
||||
setOpenDirs((prev) => new Set([...Array.from(prev), ...dirs]));
|
||||
dirs.forEach((dir) => {
|
||||
if (!entriesByDir[dir]) void loadDir(dir);
|
||||
});
|
||||
dirs.forEach((dir) => void loadDir(dir));
|
||||
},
|
||||
[entriesByDir, loadDir, openTabs.length, panelWidth, selectedPath, treeVisible],
|
||||
[loadDir, openTabs.length, panelWidth, selectedPath, treeVisible],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
dirLoadGenerationRef.current += 1;
|
||||
dirLoadRequestIdsRef.current = {};
|
||||
setEntriesByDir({});
|
||||
setOpenDirs(new Set([""]));
|
||||
setSelectedPath(null);
|
||||
@@ -503,10 +512,8 @@ export function WorkspacePanel({
|
||||
setTreeMenu(null);
|
||||
const dirs = Array.from(new Set(paths.flatMap(parentDirs)));
|
||||
setOpenDirs((prev) => new Set([...Array.from(prev), ...dirs]));
|
||||
dirs.forEach((dir) => {
|
||||
if (!entriesByDir[dir]) void loadDir(dir);
|
||||
});
|
||||
}, [entriesByDir, fileListRequest, loadDir, open, scopedFilePaths, viewMode]);
|
||||
dirs.forEach((dir) => void loadDir(dir));
|
||||
}, [fileListRequest, loadDir, open, scopedFilePaths, viewMode]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || changeListRequest) return;
|
||||
@@ -649,10 +656,10 @@ export function WorkspacePanel({
|
||||
void loadWorkspaceChanges();
|
||||
return;
|
||||
}
|
||||
onFileTreeRefresh?.();
|
||||
const dirs = Array.from(openDirsRef.current);
|
||||
setEntriesByDir({});
|
||||
dirs.forEach((dir) => void loadDir(dir));
|
||||
}, [loadGitHistory, loadWorkspaceChanges, loadDir, viewMode]);
|
||||
}, [loadGitHistory, loadWorkspaceChanges, loadDir, onFileTreeRefresh, viewMode]);
|
||||
|
||||
const refreshSelected = useCallback(() => {
|
||||
if (!selectedPath) return;
|
||||
@@ -698,12 +705,12 @@ export function WorkspacePanel({
|
||||
next.delete(dir);
|
||||
} else {
|
||||
next.add(dir);
|
||||
if (!entriesByDir[dir]) void loadDir(dir);
|
||||
void loadDir(dir);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
},
|
||||
[entriesByDir, loadDir],
|
||||
[loadDir],
|
||||
);
|
||||
|
||||
const breadcrumbDirs = selectedPath ? parentDirs(selectedPath) : [""];
|
||||
@@ -1262,6 +1269,7 @@ export function WorkspacePanel({
|
||||
setFilter("");
|
||||
showTreeEvenSplit();
|
||||
setOpenDirs((prev) => new Set([...Array.from(prev), ""]));
|
||||
void loadDir("");
|
||||
}}
|
||||
>
|
||||
{shortCwd(cwd) || t("workspace.title")}
|
||||
|
||||
@@ -193,7 +193,7 @@ export function HeartbeatPanel({ open, onClose, startNew, onOpenTopic }: Heartbe
|
||||
notifyChannels: false,
|
||||
createdAt: Date.now(),
|
||||
});
|
||||
});
|
||||
}).catch(() => {});
|
||||
}
|
||||
}, [open, startNew]);
|
||||
|
||||
@@ -210,18 +210,22 @@ export function HeartbeatPanel({ open, onClose, startNew, onOpenTopic }: Heartbe
|
||||
);
|
||||
|
||||
const handleAdd = useCallback(async () => {
|
||||
const id = await heartbeatGenerateID();
|
||||
setEditing({
|
||||
id,
|
||||
title: "",
|
||||
prompt: "",
|
||||
interval: "30m",
|
||||
enabled: true,
|
||||
approvalMode: "yolo",
|
||||
newConversationEachRun: false,
|
||||
notifyChannels: false,
|
||||
createdAt: Date.now(),
|
||||
});
|
||||
try {
|
||||
const id = await heartbeatGenerateID();
|
||||
setEditing({
|
||||
id,
|
||||
title: "",
|
||||
prompt: "",
|
||||
interval: "30m",
|
||||
enabled: true,
|
||||
approvalMode: "yolo",
|
||||
newConversationEachRun: false,
|
||||
notifyChannels: false,
|
||||
createdAt: Date.now(),
|
||||
});
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleEdit = useCallback((task: HeartbeatTask) => {
|
||||
@@ -238,8 +242,12 @@ export function HeartbeatPanel({ open, onClose, startNew, onOpenTopic }: Heartbe
|
||||
|
||||
const handleTrigger = useCallback(
|
||||
async (id: string) => {
|
||||
await heartbeatTriggerNow(id);
|
||||
void loadTasks();
|
||||
try {
|
||||
await heartbeatTriggerNow(id);
|
||||
void loadTasks();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
},
|
||||
[loadTasks],
|
||||
);
|
||||
|
||||
@@ -48,13 +48,18 @@ import type {
|
||||
Mode,
|
||||
ModelInfo,
|
||||
NetworkView,
|
||||
PluginInstallOptions,
|
||||
PluginView,
|
||||
ProjectNode,
|
||||
PromptHistoryEntry,
|
||||
PromptHistoryResult,
|
||||
ProviderPresetView,
|
||||
ProviderView,
|
||||
QuestionAnswer,
|
||||
ServerView,
|
||||
SessionMeta,
|
||||
SessionRecoveryFailedEvent,
|
||||
SessionRecoveryEvent,
|
||||
SettingsView,
|
||||
SkillsSettingsView,
|
||||
SkillRootView,
|
||||
@@ -207,6 +212,13 @@ export interface AppBindings {
|
||||
Capabilities(): Promise<CapabilitiesView>;
|
||||
MCPServers(): Promise<ServerView[]>;
|
||||
SkillsSettings(): Promise<SkillsSettingsView>;
|
||||
Plugins(): Promise<PluginView[]>;
|
||||
PlanPluginInstall(source: string, options: PluginInstallOptions): Promise<string>;
|
||||
InstallPlugin(source: string, options: PluginInstallOptions): Promise<string>;
|
||||
RemovePlugin(name: string): Promise<void>;
|
||||
SetPluginEnabled(name: string, enabled: boolean): Promise<void>;
|
||||
UpdatePlugin(name: string): Promise<string>;
|
||||
PluginDoctor(name: string): Promise<PluginView>;
|
||||
AddMCPServer(input: MCPServerInput): Promise<number>;
|
||||
UpdateMCPServer(name: string, input: MCPServerInput): Promise<void>;
|
||||
RemoveMCPServer(name: string): Promise<void>;
|
||||
@@ -216,6 +228,7 @@ export interface AppBindings {
|
||||
TrustMCPServerTools(name: string, toolNames: string[]): Promise<void>;
|
||||
UntrustMCPServerTool(name: string, toolName: string): Promise<void>;
|
||||
PickSkillFolder(): Promise<string>;
|
||||
PickPluginFolder(): Promise<string>;
|
||||
AddSkillPath(path: string): Promise<void>;
|
||||
RemoveSkillPath(path: string): Promise<void>;
|
||||
RefreshSkills(): Promise<void>;
|
||||
@@ -277,18 +290,24 @@ export interface AppBindings {
|
||||
SetPlannerModel(ref: string): Promise<void>;
|
||||
SetSubagentModel(ref: string): Promise<void>;
|
||||
SetSubagentEffort(level: string): Promise<void>;
|
||||
SetMaxSubagentDepth(depth: number): Promise<void>;
|
||||
SetAutoPlan(mode: string): Promise<void>;
|
||||
SetDefaultToolApprovalMode(mode: string): Promise<void>;
|
||||
SaveProvider(p: ProviderView): Promise<void>;
|
||||
SaveProviderWithKey(p: ProviderView, key: string): Promise<string>;
|
||||
AddOfficialProviderAccess(kind: string, key: string): Promise<string>;
|
||||
AddProviderPresetAccess(id: string, key: string): Promise<string>;
|
||||
ResetProviderPresetAccess(id: string): Promise<void>;
|
||||
FetchProviderModels(p: ProviderView): Promise<string[]>;
|
||||
DeleteProvider(name: string): Promise<void>;
|
||||
RemoveProviderAccess(name: string): Promise<void>;
|
||||
SaveProviderKey(apiKeyEnv: string, value: string): Promise<string>;
|
||||
SetProviderKey(apiKeyEnv: string, value: string): Promise<string>;
|
||||
ClearProviderKey(apiKeyEnv: string): Promise<void>;
|
||||
SetPermissionMode(mode: string): Promise<void>;
|
||||
AddPermissionRule(list: string, rule: string): Promise<void>;
|
||||
RemovePermissionRule(list: string, rule: string): Promise<void>;
|
||||
ReloadSettings(): Promise<void>;
|
||||
SetSandbox(bash: string, network: boolean, workspaceRoot: string, allowWrite: string[], shell: string): Promise<void>;
|
||||
SetNetwork(n: NetworkView): Promise<void>;
|
||||
SetBotSettings(b: BotSettingsView): Promise<void>;
|
||||
@@ -571,17 +590,31 @@ export function onProjectTreeChanged(cb: () => void): () => void {
|
||||
return () => {};
|
||||
}
|
||||
|
||||
export function onSessionRecovered(cb: (payload: SessionRecoveryEvent) => void): () => void {
|
||||
if (realApp() && typeof window !== "undefined" && window.runtime) {
|
||||
return window.runtime.EventsOn("session:recovered", (payload?: unknown) => cb((payload ?? {}) as SessionRecoveryEvent));
|
||||
}
|
||||
return () => {};
|
||||
}
|
||||
|
||||
export function onSessionRecoveryFailed(cb: (payload: SessionRecoveryFailedEvent) => void): () => void {
|
||||
if (realApp() && typeof window !== "undefined" && window.runtime) {
|
||||
return window.runtime.EventsOn("session:recovery-failed", (payload?: unknown) => cb((payload ?? {}) as SessionRecoveryFailedEvent));
|
||||
}
|
||||
return () => {};
|
||||
}
|
||||
|
||||
// app proxies each call to the live binding (or the dev mock only when truly
|
||||
// outside the shell), so a late-injected window.go is picked up transparently.
|
||||
function bridgeBreadcrumb(method: string): string {
|
||||
if (method === "ReportCrash") return "";
|
||||
if (/^(Submit|SubmitDisplay|RunShell|Steer|Cancel|Approve|AnswerQuestion|ReplayPendingPrompts)/.test(method))
|
||||
return `turn ${method}`;
|
||||
if (/^(SetModel|SetEffort|SetTokenMode|SetDefaultModel|SetPlannerModel|SetSubagentModel|SetSubagentEffort)/.test(method))
|
||||
if (/^(SetModel|SetEffort|SetTokenMode|SetDefaultModel|SetPlannerModel|SetSubagentModel|SetSubagentEffort|SetMaxSubagentDepth)/.test(method))
|
||||
return `model ${method}`;
|
||||
if (/^(SetDesktop|SetCloseBehavior|SetDisplayMode|SetStatusBar|SetExpandThinking|SetAutoPlan|SetDefaultToolApprovalMode|SetMemoryCompilerEnabled|SetReasoningLanguage)/.test(method))
|
||||
return `settings ${method}`;
|
||||
if (/^(SaveProvider|AddOfficialProviderAccess|RemoveProviderAccess|DeleteProvider|SetProviderKey|ClearProviderKey|FetchProviderModels|ConnectKey)/.test(method))
|
||||
if (/^(SaveProvider|AddOfficialProviderAccess|AddProviderPresetAccess|ResetProviderPresetAccess|RemoveProviderAccess|DeleteProvider|SaveProviderKey|SetProviderKey|ClearProviderKey|FetchProviderModels|ConnectKey)/.test(method))
|
||||
return `provider ${method}`;
|
||||
if (/^(CheckUpdate|DownloadUpdate|InstallUpdate|ApplyUpdate|OpenDownloadPage)/.test(method)) return `update ${method}`;
|
||||
if (/^(AddMCPServer|UpdateMCPServer|RemoveMCPServer|ReconnectMCPServer|ClearMCPServerAuthentication|TrustMCPServerTool|TrustMCPServerTools|UntrustMCPServerTool|SetMCPServer)/.test(method))
|
||||
@@ -695,11 +728,151 @@ function mockScenario(): "demo" | "fresh" | "running" | "guidance" {
|
||||
return "demo";
|
||||
}
|
||||
|
||||
type MockProviderPresetTemplate = {
|
||||
id: string;
|
||||
label: string;
|
||||
description: string;
|
||||
keyEnv: string;
|
||||
provider: ProviderView;
|
||||
};
|
||||
|
||||
function mockProviderTemplate(p: Pick<ProviderView, "name" | "kind" | "baseUrl" | "models" | "default" | "apiKeyEnv"> & Partial<ProviderView>): ProviderView {
|
||||
return {
|
||||
name: p.name,
|
||||
builtIn: false,
|
||||
added: true,
|
||||
kind: p.kind,
|
||||
baseUrl: p.baseUrl,
|
||||
modelsUrl: p.modelsUrl ?? "",
|
||||
models: p.models,
|
||||
visionModels: p.visionModels ?? [],
|
||||
visionModelsConfigured: Boolean(p.visionModelsConfigured ?? ((p.visionModels ?? []).length > 0)),
|
||||
default: p.default,
|
||||
apiKeyEnv: p.apiKeyEnv,
|
||||
headers: p.headers,
|
||||
extraBody: p.extraBody,
|
||||
authHeader: p.authHeader,
|
||||
keySet: Boolean(p.keySet),
|
||||
balanceUrl: p.balanceUrl ?? "",
|
||||
contextWindow: p.contextWindow ?? 0,
|
||||
reasoningProtocol: p.reasoningProtocol ?? "",
|
||||
thinking: p.thinking ?? "",
|
||||
supportedEfforts: p.supportedEfforts ?? [],
|
||||
defaultEffort: p.defaultEffort ?? "",
|
||||
modelOverrides: p.modelOverrides,
|
||||
};
|
||||
}
|
||||
|
||||
function mockPreset(id: string, label: string, description: string, keyEnv: string, provider: ProviderView): MockProviderPresetTemplate {
|
||||
return { id, label, description, keyEnv, provider };
|
||||
}
|
||||
|
||||
const mockKimiAPIModels = ["kimi-k2.7-code", "kimi-k2.7-code-highspeed", "kimi-k2.6", "kimi-k2.5"];
|
||||
const mockLongCatModels = ["LongCat-2.0"];
|
||||
const mockMiMoV25Models = ["mimo-v2.5-pro", "mimo-v2.5"];
|
||||
const mockMiniMaxModels = ["MiniMax-M3", "MiniMax-M2.7", "MiniMax-M2.7-highspeed"];
|
||||
const mockGLMAPIModels = ["glm-5.2", "glm-5.1", "glm-5", "glm-5-turbo", "glm-5v-turbo", "glm-4.7", "glm-4.7-flash", "glm-4.7-flashx", "glm-4.6", "glm-4.5", "glm-4.5-air", "glm-4.5-flash"];
|
||||
const mockGLMCodingModels = ["glm-5.2", "glm-5.1", "glm-5", "glm-4.7"];
|
||||
const mockGLMAnthropicModels = ["glm-5.2[1m]", "glm-5.2", "glm-5.1", "glm-5", "glm-4.7", "glm-4.5-air"];
|
||||
const mockQwenAPIModels = ["qwen3.7-plus", "qwen3.7-max", "qwen3.6-plus", "qwen3.5-plus", "qwen3-max-2026-01-23", "qwen3-coder-next", "qwen3-coder-plus", "MiniMax-M2.5", "glm-5", "glm-4.7", "kimi-k2.5"];
|
||||
const mockQwenPlanModels = ["qwen3.7-plus", "qwen3.6-plus", "kimi-k2.5", "glm-5", "MiniMax-M2.5", "qwen3.5-plus", "qwen3-max-2026-01-23", "qwen3-coder-next", "qwen3-coder-plus", "glm-4.7"];
|
||||
const mockQwenPlanVisionModels = ["qwen3.7-plus", "qwen3.6-plus", "qwen3.5-plus", "kimi-k2.5"];
|
||||
const mockStepFunModels = ["step-3.7-flash", "step-3.5-flash", "step-3.5-flash-2603"];
|
||||
const mockOpenCodeGoModels = ["glm-5.2", "glm-5.1", "kimi-k2.7-code", "kimi-k2.6", "deepseek-v4-pro", "deepseek-v4-flash", "mimo-v2.5-pro", "mimo-v2.5"];
|
||||
const mockOpenCodeGoAnthropicModels = ["qwen3.7-plus", "qwen3.7-max", "qwen3.6-plus", "minimax-m3", "minimax-m2.7", "minimax-m2.5"];
|
||||
const mockOpenCodeZenAnthropicModels = ["claude-sonnet-4-6", "claude-opus-4-8", "claude-haiku-4-5", "qwen3.6-plus", "qwen3.5-plus", "qwen3.6-plus-free"];
|
||||
const mockNovitaModels = ["zai-org/glm-5.2", "moonshotai/kimi-k2.7-code", "minimax/minimax-m3", "deepseek/deepseek-v4-pro", "deepseek/deepseek-v4-flash", "qwen/qwen3.7-max", "qwen/qwen3.6-plus", "zai-org/glm-5v-turbo"];
|
||||
const mockGMIModels = ["zai-org/GLM-5.2-FP8", "deepseek-ai/DeepSeek-V4-Pro", "deepseek-ai/DeepSeek-V4-Flash", "moonshotai/Kimi-K2.7-Code", "anthropic/claude-sonnet-4.6", "openai/gpt-5.5"];
|
||||
const mockVercelModels = ["anthropic/claude-sonnet-4.6", "anthropic/claude-opus-4.8", "openai/gpt-5.4", "openai/gpt-5.4-pro", "moonshotai/kimi-k2.7-code", "zai/glm-5.2", "deepseek/deepseek-v4-pro"];
|
||||
const mockOllamaCloudModels = ["glm-5.2", "kimi-k2.7-code", "deepseek-v4-pro", "deepseek-v4-flash", "minimax-m3", "nemotron-3-nano:30b", "qwen3-coder-next"];
|
||||
|
||||
const mockProviderPresetTemplates: MockProviderPresetTemplate[] = [
|
||||
mockPreset("longcat-openai", "LongCat OpenAI", "LongCat Platform OpenAI-compatible endpoint for LongCat-2.0.", "LONGCAT_API_KEY", mockProviderTemplate({ name: "longcat-openai", kind: "openai", baseUrl: "https://api.longcat.chat/openai/v1", modelsUrl: "https://api.longcat.chat/openai/v1/models", models: mockLongCatModels, default: "LongCat-2.0", apiKeyEnv: "LONGCAT_API_KEY", contextWindow: 131072, thinking: "enabled", supportedEfforts: ["enabled", "disabled"], defaultEffort: "enabled" })),
|
||||
mockPreset("longcat-anthropic", "LongCat Anthropic", "LongCat Platform Anthropic-compatible Messages endpoint for LongCat-2.0.", "LONGCAT_API_KEY", mockProviderTemplate({ name: "longcat-anthropic", kind: "anthropic", baseUrl: "https://api.longcat.chat/anthropic", modelsUrl: "https://api.longcat.chat/anthropic/v1/models", models: mockLongCatModels, default: "LongCat-2.0", apiKeyEnv: "LONGCAT_API_KEY", authHeader: true, contextWindow: 131072, thinking: "enabled", supportedEfforts: ["enabled", "disabled"], defaultEffort: "enabled" })),
|
||||
mockPreset("kimi-cn", "Kimi CN API", "Moonshot Kimi China OpenAI-compatible API.", "KIMI_API_KEY", mockProviderTemplate({ name: "kimi-cn", kind: "openai", baseUrl: "https://api.moonshot.cn/v1", models: mockKimiAPIModels, visionModels: mockKimiAPIModels, default: "kimi-k2.7-code", apiKeyEnv: "KIMI_API_KEY", balanceUrl: "https://api.moonshot.cn/v1/users/me/balance", contextWindow: 262144, reasoningProtocol: "none" })),
|
||||
mockPreset("kimi-global", "Kimi Global API", "Moonshot Kimi international OpenAI-compatible API.", "MOONSHOT_API_KEY", mockProviderTemplate({ name: "kimi-global", kind: "openai", baseUrl: "https://api.moonshot.ai/v1", models: mockKimiAPIModels, visionModels: mockKimiAPIModels, default: "kimi-k2.7-code", apiKeyEnv: "MOONSHOT_API_KEY", balanceUrl: "https://api.moonshot.ai/v1/users/me/balance", contextWindow: 262144, reasoningProtocol: "none" })),
|
||||
mockPreset("kimi-coding-plan", "Kimi Coding Plan", "Kimi Coding Plan via its dedicated Anthropic-compatible endpoint.", "KIMI_CODING_API_KEY", mockProviderTemplate({ name: "kimi-coding-plan", kind: "anthropic", baseUrl: "https://api.kimi.com/coding/", models: ["kimi-for-coding"], visionModels: ["kimi-for-coding"], default: "kimi-for-coding", apiKeyEnv: "KIMI_CODING_API_KEY", headers: { "User-Agent": "claude-code/0.1.0" }, thinking: "adaptive", contextWindow: 262144 })),
|
||||
mockPreset("mimo-api", "MiMo API", "Xiaomi MiMo direct API with text and vision-capable models.", "MIMO_API_KEY", mockProviderTemplate({ name: "mimo-api", kind: "openai", baseUrl: "https://api.xiaomimimo.com/v1", models: mockMiMoV25Models, visionModels: ["mimo-v2.5"], default: "mimo-v2.5-pro", apiKeyEnv: "MIMO_API_KEY", contextWindow: 1048576 })),
|
||||
mockPreset("mimo-anthropic", "MiMo Anthropic", "Xiaomi MiMo direct Anthropic-compatible endpoint.", "MIMO_API_KEY", mockProviderTemplate({ name: "mimo-anthropic", kind: "anthropic", baseUrl: "https://api.xiaomimimo.com/anthropic", models: mockMiMoV25Models, visionModels: ["mimo-v2.5"], default: "mimo-v2.5-pro", apiKeyEnv: "MIMO_API_KEY", thinking: "adaptive", contextWindow: 1048576 })),
|
||||
mockPreset("mimo-token-plan-cn", "MiMo Token Plan CN", "Xiaomi MiMo token-plan China endpoint.", "MIMO_TOKEN_PLAN_API_KEY", mockProviderTemplate({ name: "mimo-token-plan-cn", kind: "openai", baseUrl: "https://token-plan-cn.xiaomimimo.com/v1", models: mockMiMoV25Models, visionModels: ["mimo-v2.5"], default: "mimo-v2.5-pro", apiKeyEnv: "MIMO_TOKEN_PLAN_API_KEY", contextWindow: 1048576 })),
|
||||
mockPreset("mimo-token-plan-cn-anthropic", "MiMo Token Plan CN Anthropic", "Xiaomi MiMo token-plan China Anthropic-compatible endpoint.", "MIMO_TOKEN_PLAN_API_KEY", mockProviderTemplate({ name: "mimo-token-plan-cn-anthropic", kind: "anthropic", baseUrl: "https://token-plan-cn.xiaomimimo.com/anthropic", models: mockMiMoV25Models, visionModels: ["mimo-v2.5"], default: "mimo-v2.5-pro", apiKeyEnv: "MIMO_TOKEN_PLAN_API_KEY", thinking: "adaptive", contextWindow: 1048576 })),
|
||||
mockPreset("mimo-token-plan-sgp", "MiMo Token Plan SGP", "Xiaomi MiMo token-plan Singapore endpoint.", "MIMO_TOKEN_PLAN_API_KEY", mockProviderTemplate({ name: "mimo-token-plan-sgp", kind: "openai", baseUrl: "https://token-plan-sgp.xiaomimimo.com/v1", models: mockMiMoV25Models, visionModels: ["mimo-v2.5"], default: "mimo-v2.5-pro", apiKeyEnv: "MIMO_TOKEN_PLAN_API_KEY", contextWindow: 1048576 })),
|
||||
mockPreset("mimo-token-plan-sgp-anthropic", "MiMo Token Plan SGP Anthropic", "Xiaomi MiMo token-plan Singapore Anthropic-compatible endpoint.", "MIMO_TOKEN_PLAN_API_KEY", mockProviderTemplate({ name: "mimo-token-plan-sgp-anthropic", kind: "anthropic", baseUrl: "https://token-plan-sgp.xiaomimimo.com/anthropic", models: mockMiMoV25Models, visionModels: ["mimo-v2.5"], default: "mimo-v2.5-pro", apiKeyEnv: "MIMO_TOKEN_PLAN_API_KEY", thinking: "adaptive", contextWindow: 1048576 })),
|
||||
mockPreset("mimo-token-plan-ams", "MiMo Token Plan AMS", "Xiaomi MiMo token-plan Amsterdam endpoint.", "MIMO_TOKEN_PLAN_API_KEY", mockProviderTemplate({ name: "mimo-token-plan-ams", kind: "openai", baseUrl: "https://token-plan-ams.xiaomimimo.com/v1", models: mockMiMoV25Models, visionModels: ["mimo-v2.5"], default: "mimo-v2.5-pro", apiKeyEnv: "MIMO_TOKEN_PLAN_API_KEY", contextWindow: 1048576 })),
|
||||
mockPreset("mimo-token-plan-ams-anthropic", "MiMo Token Plan AMS Anthropic", "Xiaomi MiMo token-plan Amsterdam Anthropic-compatible endpoint.", "MIMO_TOKEN_PLAN_API_KEY", mockProviderTemplate({ name: "mimo-token-plan-ams-anthropic", kind: "anthropic", baseUrl: "https://token-plan-ams.xiaomimimo.com/anthropic", models: mockMiMoV25Models, visionModels: ["mimo-v2.5"], default: "mimo-v2.5-pro", apiKeyEnv: "MIMO_TOKEN_PLAN_API_KEY", thinking: "adaptive", contextWindow: 1048576 })),
|
||||
mockPreset("minimax-cn-api", "MiniMax CN API", "MiniMax China OpenAI-compatible M-series API endpoint.", "MINIMAX_API_KEY", mockProviderTemplate({ name: "minimax-cn-api", kind: "openai", baseUrl: "https://api.minimaxi.com/v1", models: mockMiniMaxModels, visionModels: ["MiniMax-M3"], default: "MiniMax-M3", apiKeyEnv: "MINIMAX_API_KEY", extraBody: { reasoning_split: true }, contextWindow: 1048576, thinking: "adaptive", supportedEfforts: ["disabled", "adaptive"], defaultEffort: "adaptive" })),
|
||||
mockPreset("minimax-global-api", "MiniMax Global API", "MiniMax international OpenAI-compatible M-series API endpoint.", "MINIMAX_API_KEY", mockProviderTemplate({ name: "minimax-global-api", kind: "openai", baseUrl: "https://api.minimax.io/v1", models: mockMiniMaxModels, visionModels: ["MiniMax-M3"], default: "MiniMax-M3", apiKeyEnv: "MINIMAX_API_KEY", extraBody: { reasoning_split: true }, contextWindow: 1048576, thinking: "adaptive", supportedEfforts: ["disabled", "adaptive"], defaultEffort: "adaptive" })),
|
||||
mockPreset("minimax-cn-anthropic", "MiniMax CN Anthropic", "MiniMax China Anthropic-compatible M-series endpoint.", "MINIMAX_PLAN_API_KEY", mockProviderTemplate({ name: "minimax-cn-anthropic", kind: "anthropic", baseUrl: "https://api.minimaxi.com/anthropic", models: mockMiniMaxModels, visionModels: ["MiniMax-M3"], default: "MiniMax-M3", apiKeyEnv: "MINIMAX_PLAN_API_KEY", authHeader: true, contextWindow: 1048576, thinking: "adaptive", supportedEfforts: ["disabled", "adaptive"], defaultEffort: "adaptive" })),
|
||||
mockPreset("minimax-global-anthropic", "MiniMax Global Anthropic", "MiniMax international Anthropic-compatible endpoint with Bearer auth.", "MINIMAX_API_KEY", mockProviderTemplate({ name: "minimax-global-anthropic", kind: "anthropic", baseUrl: "https://api.minimax.io/anthropic", models: mockMiniMaxModels, visionModels: ["MiniMax-M3"], default: "MiniMax-M3", apiKeyEnv: "MINIMAX_API_KEY", authHeader: true, contextWindow: 1048576, thinking: "adaptive", supportedEfforts: ["disabled", "adaptive"], defaultEffort: "adaptive" })),
|
||||
mockPreset("glm-cn", "GLM CN API", "Zhipu GLM China OpenAI-compatible API with thinking controls.", "GLM_API_KEY", mockProviderTemplate({ name: "glm-cn", kind: "openai", baseUrl: "https://open.bigmodel.cn/api/paas/v4", models: mockGLMAPIModels, visionModels: ["glm-5v-turbo"], default: "glm-5.2", apiKeyEnv: "GLM_API_KEY", contextWindow: 1000000, thinking: "enabled", supportedEfforts: ["enabled", "disabled"], defaultEffort: "enabled" })),
|
||||
mockPreset("zai-global", "Z.AI Global API", "Z.AI international OpenAI-compatible GLM API.", "ZAI_API_KEY", mockProviderTemplate({ name: "zai-global", kind: "openai", baseUrl: "https://api.z.ai/api/paas/v4", models: mockGLMAPIModels, visionModels: ["glm-5v-turbo"], default: "glm-5.2", apiKeyEnv: "ZAI_API_KEY", contextWindow: 1000000, thinking: "enabled", supportedEfforts: ["enabled", "disabled"], defaultEffort: "enabled" })),
|
||||
mockPreset("glm-coding-plan-cn", "GLM Coding Plan CN", "Zhipu GLM China coding-plan endpoint.", "GLM_PLAN_API_KEY", mockProviderTemplate({ name: "glm-coding-plan-cn", kind: "openai", baseUrl: "https://open.bigmodel.cn/api/coding/paas/v4", models: mockGLMCodingModels, default: "glm-5.2", apiKeyEnv: "GLM_PLAN_API_KEY", contextWindow: 1000000, thinking: "enabled", supportedEfforts: ["enabled", "disabled"], defaultEffort: "enabled" })),
|
||||
mockPreset("glm-coding-plan-cn-anthropic", "GLM Coding Plan CN Anthropic", "Zhipu GLM China coding-plan Anthropic-compatible endpoint.", "GLM_PLAN_API_KEY", mockProviderTemplate({ name: "glm-coding-plan-cn-anthropic", kind: "anthropic", baseUrl: "https://open.bigmodel.cn/api/anthropic", models: mockGLMAnthropicModels, default: "glm-5.2[1m]", apiKeyEnv: "GLM_PLAN_API_KEY", authHeader: true, thinking: "adaptive", contextWindow: 1000000 })),
|
||||
mockPreset("zai-coding-plan-global", "Z.AI Coding Plan Global", "Z.AI international coding-plan endpoint.", "ZAI_CODING_API_KEY", mockProviderTemplate({ name: "zai-coding-plan-global", kind: "openai", baseUrl: "https://api.z.ai/api/coding/paas/v4", models: mockGLMCodingModels, default: "glm-5.2", apiKeyEnv: "ZAI_CODING_API_KEY", contextWindow: 1000000, thinking: "enabled", supportedEfforts: ["enabled", "disabled"], defaultEffort: "enabled" })),
|
||||
mockPreset("zai-coding-plan-global-anthropic", "Z.AI Coding Plan Global Anthropic", "Z.AI international coding-plan Anthropic-compatible endpoint.", "ZAI_CODING_API_KEY", mockProviderTemplate({ name: "zai-coding-plan-global-anthropic", kind: "anthropic", baseUrl: "https://api.z.ai/api/anthropic", models: mockGLMAnthropicModels, default: "glm-5.2[1m]", apiKeyEnv: "ZAI_CODING_API_KEY", authHeader: true, thinking: "adaptive", contextWindow: 1000000 })),
|
||||
mockPreset("opencode-go", "OpenCode Go", "OpenCode Go relay with per-model capability overrides.", "OPENCODE_GO_API_KEY", mockProviderTemplate({ name: "opencode-go", kind: "openai", baseUrl: "https://opencode.ai/zen/go/v1", models: mockOpenCodeGoModels, default: "glm-5.2", apiKeyEnv: "OPENCODE_GO_API_KEY", contextWindow: 128000 })),
|
||||
mockPreset("opencode-go-anthropic", "OpenCode Go Anthropic", "OpenCode Go subscription Anthropic-compatible route for Qwen and MiniMax models.", "OPENCODE_GO_API_KEY", mockProviderTemplate({ name: "opencode-go-anthropic", kind: "anthropic", baseUrl: "https://opencode.ai/zen/go", models: mockOpenCodeGoAnthropicModels, visionModels: ["qwen3.7-plus", "qwen3.6-plus"], default: "qwen3.7-plus", apiKeyEnv: "OPENCODE_GO_API_KEY", thinking: "adaptive", contextWindow: 262144 })),
|
||||
mockPreset("opencode-zen-anthropic", "OpenCode Zen Anthropic", "OpenCode Zen Anthropic-compatible route for Claude and Qwen models.", "OPENCODE_API_KEY", mockProviderTemplate({ name: "opencode-zen-anthropic", kind: "anthropic", baseUrl: "https://opencode.ai/zen", models: mockOpenCodeZenAnthropicModels, visionModels: ["claude-sonnet-4-6", "claude-opus-4-8", "claude-haiku-4-5"], default: "claude-sonnet-4-6", apiKeyEnv: "OPENCODE_API_KEY", contextWindow: 262144 })),
|
||||
mockPreset("qwen-cn", "Qwen CN API", "Alibaba DashScope China standard OpenAI-compatible endpoint.", "QWEN_API_KEY", mockProviderTemplate({ name: "qwen-cn", kind: "openai", baseUrl: "https://dashscope.aliyuncs.com/compatible-mode/v1", models: mockQwenAPIModels, visionModels: ["qwen3.7-plus", "qwen3.6-plus", "qwen3.5-plus", "kimi-k2.5"], default: "qwen3.7-plus", apiKeyEnv: "QWEN_API_KEY" })),
|
||||
mockPreset("qwen-global", "Qwen Global API", "Alibaba DashScope international standard OpenAI-compatible endpoint.", "QWEN_API_KEY", mockProviderTemplate({ name: "qwen-global", kind: "openai", baseUrl: "https://dashscope-intl.aliyuncs.com/compatible-mode/v1", models: mockQwenAPIModels, visionModels: ["qwen3.7-plus", "qwen3.6-plus", "qwen3.5-plus", "kimi-k2.5"], default: "qwen3.7-plus", apiKeyEnv: "QWEN_API_KEY" })),
|
||||
mockPreset("qwen-coding-plan-cn", "Qwen Coding Plan CN", "Alibaba Cloud Qwen Coding Plan China endpoint.", "QWEN_CODING_API_KEY", mockProviderTemplate({ name: "qwen-coding-plan-cn", kind: "openai", baseUrl: "https://coding.dashscope.aliyuncs.com/v1", models: mockQwenPlanModels, visionModels: mockQwenPlanVisionModels, default: "qwen3.7-plus", apiKeyEnv: "QWEN_CODING_API_KEY" })),
|
||||
mockPreset("qwen-coding-plan-cn-anthropic", "Qwen Coding Plan CN Anthropic", "Alibaba Cloud Qwen Coding Plan China Anthropic-compatible endpoint.", "QWEN_CODING_API_KEY", mockProviderTemplate({ name: "qwen-coding-plan-cn-anthropic", kind: "anthropic", baseUrl: "https://coding.dashscope.aliyuncs.com/apps/anthropic", models: mockQwenPlanModels, visionModels: mockQwenPlanVisionModels, default: "qwen3.7-plus", apiKeyEnv: "QWEN_CODING_API_KEY", thinking: "adaptive" })),
|
||||
mockPreset("qwen-coding-plan-global", "Qwen Coding Plan Global", "Alibaba Cloud Qwen Coding Plan international endpoint.", "QWEN_CODING_API_KEY", mockProviderTemplate({ name: "qwen-coding-plan-global", kind: "openai", baseUrl: "https://coding-intl.dashscope.aliyuncs.com/v1", models: mockQwenPlanModels, visionModels: mockQwenPlanVisionModels, default: "qwen3.7-plus", apiKeyEnv: "QWEN_CODING_API_KEY" })),
|
||||
mockPreset("qwen-coding-plan-global-anthropic", "Qwen Coding Plan Global Anthropic", "Alibaba Cloud Qwen Coding Plan international Anthropic-compatible endpoint.", "QWEN_CODING_API_KEY", mockProviderTemplate({ name: "qwen-coding-plan-global-anthropic", kind: "anthropic", baseUrl: "https://coding-intl.dashscope.aliyuncs.com/apps/anthropic", models: mockQwenPlanModels, visionModels: mockQwenPlanVisionModels, default: "qwen3.7-plus", apiKeyEnv: "QWEN_CODING_API_KEY", thinking: "adaptive" })),
|
||||
mockPreset("stepfun", "StepFun", "StepFun coding-plan OpenAI-compatible endpoint.", "STEPFUN_API_KEY", mockProviderTemplate({ name: "stepfun", kind: "openai", baseUrl: "https://api.stepfun.ai/step_plan/v1", models: mockStepFunModels, default: "step-3.7-flash", apiKeyEnv: "STEPFUN_API_KEY", supportedEfforts: ["low", "medium", "high"], defaultEffort: "medium" })),
|
||||
mockPreset("stepfun-anthropic", "StepFun Anthropic", "StepFun coding-plan Anthropic-compatible endpoint.", "STEPFUN_API_KEY", mockProviderTemplate({ name: "stepfun-anthropic", kind: "anthropic", baseUrl: "https://api.stepfun.ai/step_plan", models: mockStepFunModels, default: "step-3.7-flash", apiKeyEnv: "STEPFUN_API_KEY", thinking: "adaptive", supportedEfforts: ["low", "medium", "high"], defaultEffort: "medium" })),
|
||||
mockPreset("novita", "NovitaAI", "NovitaAI OpenAI-compatible multi-model gateway.", "NOVITA_API_KEY", mockProviderTemplate({ name: "novita", kind: "openai", baseUrl: "https://api.novita.ai/openai/v1", models: mockNovitaModels, default: "zai-org/glm-5.2", apiKeyEnv: "NOVITA_API_KEY" })),
|
||||
mockPreset("gmi", "GMI Cloud", "GMI Cloud direct multi-model OpenAI-compatible gateway.", "GMI_API_KEY", mockProviderTemplate({ name: "gmi", kind: "openai", baseUrl: "https://api.gmi-serving.com/v1", models: mockGMIModels, default: "zai-org/GLM-5.2-FP8", apiKeyEnv: "GMI_API_KEY", headers: { "User-Agent": "Reasonix" } })),
|
||||
mockPreset("vercel-ai-gateway", "Vercel AI Gateway", "Vercel AI Gateway via Anthropic-compatible Messages API.", "AI_GATEWAY_API_KEY", mockProviderTemplate({ name: "vercel-ai-gateway", kind: "anthropic", baseUrl: "https://ai-gateway.vercel.sh", models: mockVercelModels, visionModels: ["anthropic/claude-sonnet-4.6", "anthropic/claude-opus-4.8", "openai/gpt-5.4", "openai/gpt-5.4-pro", "moonshotai/kimi-k2.7-code"], default: "anthropic/claude-sonnet-4.6", apiKeyEnv: "AI_GATEWAY_API_KEY", authHeader: true, contextWindow: 1000000 })),
|
||||
mockPreset("huggingface", "HuggingFace Router", "HuggingFace Inference Router OpenAI-compatible endpoint.", "HF_TOKEN", mockProviderTemplate({ name: "huggingface", kind: "openai", baseUrl: "https://router.huggingface.co/v1", models: ["zai-org/GLM-5.2", "deepseek-ai/DeepSeek-V3.2", "Qwen/Qwen3.5-72B-Instruct"], default: "zai-org/GLM-5.2", apiKeyEnv: "HF_TOKEN" })),
|
||||
mockPreset("nvidia", "NVIDIA NIM", "NVIDIA NIM OpenAI-compatible accelerated inference endpoint.", "NVIDIA_API_KEY", mockProviderTemplate({ name: "nvidia", kind: "openai", baseUrl: "https://integrate.api.nvidia.com/v1", models: ["nvidia/nemotron-3-nano-30b-a3b", "nvidia/nemotron-3-super-120b-a12b", "nvidia/nemotron-3-ultra-550b-a55b", "deepseek-ai/deepseek-v4-pro", "qwen/qwen3.5-397b-a17b"], default: "nvidia/nemotron-3-nano-30b-a3b", apiKeyEnv: "NVIDIA_API_KEY" })),
|
||||
mockPreset("kilocode", "KiloCode", "Kilo Code gateway OpenAI-compatible endpoint.", "KILOCODE_API_KEY", mockProviderTemplate({ name: "kilocode", kind: "openai", baseUrl: "https://api.kilo.ai/api/gateway", models: ["kilo/auto"], default: "kilo/auto", apiKeyEnv: "KILOCODE_API_KEY" })),
|
||||
mockPreset("ollama-cloud", "Ollama Cloud", "Hosted Ollama Cloud OpenAI-compatible endpoint with max reasoning effort.", "OLLAMA_API_KEY", mockProviderTemplate({ name: "ollama-cloud", kind: "openai", baseUrl: "https://ollama.com/v1", models: mockOllamaCloudModels, default: "glm-5.2", apiKeyEnv: "OLLAMA_API_KEY" })),
|
||||
];
|
||||
|
||||
function mockProviderPresetViews(): ProviderPresetView[] {
|
||||
return [...mockProviderPresetTemplates].sort((a, b) => mockProviderPresetDisplayRank(a.id) - mockProviderPresetDisplayRank(b.id)).map((template) => ({
|
||||
id: template.id,
|
||||
label: template.label,
|
||||
description: template.description,
|
||||
keyEnv: template.keyEnv,
|
||||
providerNames: [template.provider.name],
|
||||
models: [...template.provider.models],
|
||||
added: false,
|
||||
status: "available",
|
||||
statusProviderNames: [],
|
||||
keySet: false,
|
||||
requiresKey: true,
|
||||
configured: false,
|
||||
}));
|
||||
}
|
||||
|
||||
function mockProviderPresetDisplayRank(id: string): number {
|
||||
if (id === "glm-cn" || id === "zai-global" || id.startsWith("glm-coding-plan-") || id.startsWith("zai-coding-plan-")) return 0;
|
||||
if (id.startsWith("longcat-")) return 1;
|
||||
if (id.startsWith("kimi-")) return 2;
|
||||
if (id.startsWith("minimax-")) return 3;
|
||||
return 4;
|
||||
}
|
||||
|
||||
function cloneMockProviderTemplate(id: string, key: string): ProviderView | undefined {
|
||||
const template = mockProviderPresetTemplates.find((candidate) => candidate.id === id);
|
||||
if (!template) return undefined;
|
||||
return {
|
||||
...JSON.parse(JSON.stringify(template.provider)) as ProviderView,
|
||||
keySet: Boolean(key.trim()),
|
||||
};
|
||||
}
|
||||
|
||||
const mockPreviewImageDataURL =
|
||||
"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='160' height='120' viewBox='0 0 160 120'%3E%3Cdefs%3E%3ClinearGradient id='g' x1='0' y1='0' x2='1' y2='1'%3E%3Cstop offset='0' stop-color='%23f97316'/%3E%3Cstop offset='1' stop-color='%232563eb'/%3E%3C/linearGradient%3E%3C/defs%3E%3Crect width='160' height='120' rx='14' fill='url(%23g)'/%3E%3Ccircle cx='44' cy='38' r='16' fill='%23fff7ed' opacity='.9'/%3E%3Cpath d='M18 96 62 58l24 22 18-16 38 32z' fill='%23ffffff' opacity='.9'/%3E%3C/svg%3E";
|
||||
|
||||
function makeMockApp(): AppBindings {
|
||||
const scenario = mockScenario();
|
||||
const freshMock = scenario === "fresh";
|
||||
const guidanceMock = scenario === "guidance";
|
||||
const runningMock = scenario === "running" || guidanceMock;
|
||||
const mockAttachmentDataURLs = new Map<string, string>();
|
||||
let cancelled = false;
|
||||
let pendingAskPreview = false;
|
||||
let pendingApprovalPreview = false;
|
||||
@@ -707,6 +880,7 @@ function makeMockApp(): AppBindings {
|
||||
let cwd = freshMock ? globalWorkspaceRoot : "~/projects/joyquant-db"; // mutable so PickWorkspace is visible in dev
|
||||
let workspaces = freshMock ? [] : ["~/projects/joyquant-db", "~/projects/joyquant-sys", "~/projects/reasonix", "~/projects/blade"];
|
||||
let mockEffort = "auto";
|
||||
let mockDesktopZoomFactor = 1.0;
|
||||
const day = 86_400_000;
|
||||
const t0 = Date.now();
|
||||
// Mutable so MCP add/remove/retry are observable in browser dev.
|
||||
@@ -788,6 +962,7 @@ function makeMockApp(): AppBindings {
|
||||
],
|
||||
},
|
||||
];
|
||||
let capPlugins: PluginView[] = [];
|
||||
const mockSwitchWorkspace = async (path: string) => {
|
||||
cwd = path || "~";
|
||||
workspaces = [cwd, ...workspaces.filter((p) => p !== cwd)].slice(0, 12);
|
||||
@@ -870,37 +1045,64 @@ function makeMockApp(): AppBindings {
|
||||
subagentEffort: "",
|
||||
autoPlan: "off",
|
||||
providers: [
|
||||
{ name: "deepseek", builtIn: true, added: false, kind: "openai", baseUrl: "https://api.deepseek.com", modelsUrl: "", models: ["deepseek-v4-flash"], visionModels: [], visionModelsConfigured: false, default: "deepseek-v4-flash", apiKeyEnv: "DEEPSEEK_API_KEY", keySet: true, balanceUrl: "https://api.deepseek.com/user/balance", contextWindow: 1_000_000, reasoningProtocol: "", supportedEfforts: [], defaultEffort: "" },
|
||||
{ name: "deepseek", builtIn: true, added: false, kind: "openai", baseUrl: "https://api.deepseek.com", modelsUrl: "", models: ["deepseek-v4-flash"], visionModels: [], visionModelsConfigured: false, default: "deepseek-v4-flash", apiKeyEnv: "DEEPSEEK_API_KEY", keySet: true, balanceUrl: "https://api.deepseek.com/user/balance", contextWindow: 1_000_000, reasoningProtocol: "", thinking: "", supportedEfforts: [], defaultEffort: "" },
|
||||
],
|
||||
officialProviders: [
|
||||
{ name: "deepseek", builtIn: true, added: false, kind: "openai", baseUrl: "https://api.deepseek.com", modelsUrl: "", models: ["deepseek-v4-flash", "deepseek-v4-pro"], visionModels: [], visionModelsConfigured: false, default: "deepseek-v4-flash", apiKeyEnv: "DEEPSEEK_API_KEY", keySet: true, balanceUrl: "https://api.deepseek.com/user/balance", contextWindow: 1_000_000, reasoningProtocol: "", supportedEfforts: [], defaultEffort: "" },
|
||||
{ name: "deepseek", builtIn: true, added: false, kind: "openai", baseUrl: "https://api.deepseek.com", modelsUrl: "", models: ["deepseek-v4-flash", "deepseek-v4-pro"], visionModels: [], visionModelsConfigured: false, default: "deepseek-v4-flash", apiKeyEnv: "DEEPSEEK_API_KEY", keySet: true, balanceUrl: "https://api.deepseek.com/user/balance", contextWindow: 1_000_000, reasoningProtocol: "", thinking: "", supportedEfforts: [], defaultEffort: "" },
|
||||
],
|
||||
providerPresets: mockProviderPresetViews(),
|
||||
permissions: { mode: "ask", allow: ["ls", "read_file"], ask: [], deny: ["Bash(rm:*)"] },
|
||||
sandbox: { bash: "enforce", network: true, workspaceRoot: "", allowWrite: [], shell: "auto" },
|
||||
sandbox: { bash: "enforce", network: true, workspaceRoot: "", allowWrite: [], effectiveWorkspaceRoot: cwd, effectiveWriteRoots: [cwd], shell: "auto" },
|
||||
network: {
|
||||
proxyMode: "auto",
|
||||
proxyUrl: "",
|
||||
noProxy: "",
|
||||
proxy: { type: "socks5", server: "127.0.0.1", port: 7890, username: "", password: "" },
|
||||
},
|
||||
agent: { temperature: 0.2, maxSteps: 0, plannerMaxSteps: 0, systemPrompt: "You are Reasonix, a coding agent.", coldResumePrune: true, reasoningLanguage: "auto" },
|
||||
agent: { temperature: 0.2, maxSteps: 0, plannerMaxSteps: 0, maxSubagentDepth: 2, systemPrompt: "You are Reasonix, a coding agent.", coldResumePrune: true, reasoningLanguage: "auto" },
|
||||
bot: {
|
||||
enabled: !freshMock,
|
||||
model: "",
|
||||
toolApprovalMode: "ask",
|
||||
maxSteps: 25,
|
||||
debounceMs: 1500,
|
||||
queueMode: "steer",
|
||||
queueCap: 20,
|
||||
queueDrop: "summarize",
|
||||
ignoreSelfMessages: true,
|
||||
selfUserIds: {
|
||||
qq: [],
|
||||
feishu: [],
|
||||
weixin: [],
|
||||
},
|
||||
control: {
|
||||
enabled: false,
|
||||
addr: "127.0.0.1:37913",
|
||||
tokenEnv: "REASONIX_BOT_CONTROL_TOKEN",
|
||||
},
|
||||
pairing: {
|
||||
enabled: true,
|
||||
requestTtlMinutes: 60,
|
||||
maxPendingPerPlatform: 3,
|
||||
},
|
||||
routes: [],
|
||||
allowlist: {
|
||||
enabled: true,
|
||||
allowAll: false,
|
||||
qqUsers: [],
|
||||
feishuUsers: freshMock ? [] : ["ou_mock_user_001"],
|
||||
weixinUsers: freshMock ? [] : ["wxid_mock_user_001"],
|
||||
qqApprovers: [],
|
||||
feishuApprovers: [],
|
||||
weixinApprovers: [],
|
||||
qqAdmins: [],
|
||||
feishuAdmins: [],
|
||||
weixinAdmins: [],
|
||||
qqGroups: [],
|
||||
feishuGroups: [],
|
||||
weixinGroups: [],
|
||||
},
|
||||
qq: { enabled: false, appId: "", appSecretEnv: "QQ_BOT_APP_SECRET", secretSet: false, sandbox: false },
|
||||
qq: { enabled: false, appId: "", appSecretEnv: "QQ_BOT_APP_SECRET", secretSet: false, sandbox: false, model: "", toolApprovalMode: "ask", workspaceRoot: "", access: { enabled: true, allowAll: false, pairingEnabled: true, users: [], groups: [], approvers: [], admins: [] } },
|
||||
feishu: {
|
||||
enabled: false,
|
||||
domain: "feishu",
|
||||
@@ -927,10 +1129,11 @@ function makeMockApp(): AppBindings {
|
||||
label: "kun",
|
||||
enabled: true,
|
||||
status: "connected",
|
||||
model: "",
|
||||
toolApprovalMode: "",
|
||||
workspaceRoot: "",
|
||||
credential: {
|
||||
model: "",
|
||||
toolApprovalMode: "",
|
||||
workspaceRoot: "",
|
||||
access: { enabled: true, allowAll: false, pairingEnabled: true, users: ["ou_mock_user_001"], groups: [], approvers: [], admins: [] },
|
||||
credential: {
|
||||
appId: "cli_mock_lark",
|
||||
appSecretEnv: "FEISHU_BOT_APP_SECRET",
|
||||
accountId: "",
|
||||
@@ -961,10 +1164,11 @@ function makeMockApp(): AppBindings {
|
||||
label: "kun",
|
||||
enabled: true,
|
||||
status: "connected",
|
||||
model: "",
|
||||
toolApprovalMode: "",
|
||||
workspaceRoot: "",
|
||||
credential: {
|
||||
model: "",
|
||||
toolApprovalMode: "",
|
||||
workspaceRoot: "",
|
||||
access: { enabled: true, allowAll: false, pairingEnabled: true, users: ["wxid_mock_user_001"], groups: [], approvers: [], admins: [] },
|
||||
credential: {
|
||||
appId: "",
|
||||
appSecretEnv: "",
|
||||
accountId: "default",
|
||||
@@ -1004,7 +1208,7 @@ function makeMockApp(): AppBindings {
|
||||
metrics: true,
|
||||
memoryCompilerEnabled: true,
|
||||
configPath: "~/projects/reasonix/reasonix.toml",
|
||||
providerKinds: ["openai"],
|
||||
providerKinds: ["openai", "anthropic"],
|
||||
autoApproveTools: false,
|
||||
bypass: false,
|
||||
};
|
||||
@@ -2155,6 +2359,7 @@ function makeMockApp(): AppBindings {
|
||||
{ name: "model", description: "Switch model", kind: "builtin" as const },
|
||||
{ name: "effort", description: "Set reasoning effort", kind: "builtin" as const },
|
||||
{ name: "skill", description: "List skills", kind: "builtin" as const },
|
||||
{ name: "plugins", description: "Manage plugin packages", kind: "builtin" as const },
|
||||
{ name: "explore", description: "Investigate the codebase in an isolated subagent", kind: "skill" as const },
|
||||
{ name: "review", description: "Review the staged diff", hint: "[focus]", kind: "custom" as const },
|
||||
];
|
||||
@@ -2164,6 +2369,7 @@ function makeMockApp(): AppBindings {
|
||||
servers: capServers.map((s) => ({ ...s })),
|
||||
skills: capSkills.map((s) => ({ ...s })),
|
||||
skillRoots: capSkillRoots.map((s) => ({ ...s })),
|
||||
plugins: capPlugins.map((p) => ({ ...p })),
|
||||
};
|
||||
},
|
||||
async MCPServers() {
|
||||
@@ -2175,6 +2381,59 @@ function makeMockApp(): AppBindings {
|
||||
skillRoots: capSkillRoots.map((s) => ({ ...s })),
|
||||
};
|
||||
},
|
||||
async Plugins() {
|
||||
return capPlugins.map((p) => ({ ...p }));
|
||||
},
|
||||
async PlanPluginInstall(source: string, options: PluginInstallOptions) {
|
||||
const name = options.name || source.split("/").filter(Boolean).pop()?.replace(/\.git$/, "") || "plugin";
|
||||
return JSON.stringify({
|
||||
ok: true,
|
||||
status: "planned",
|
||||
kind: "plugin",
|
||||
actions: [{ kind: "plugin", action: "install_plugin_package", name, source, status: "planned" }],
|
||||
});
|
||||
},
|
||||
async InstallPlugin(source: string, options: PluginInstallOptions) {
|
||||
const name = options.name || source.split("/").filter(Boolean).pop()?.replace(/\.git$/, "") || "plugin";
|
||||
const existing = capPlugins.findIndex((p) => p.name === name);
|
||||
const view: PluginView = {
|
||||
name,
|
||||
version: "dev",
|
||||
description: "Mock plugin",
|
||||
source,
|
||||
root: `~/.reasonix/plugins/${name}`,
|
||||
manifestKind: "reasonix",
|
||||
enabled: true,
|
||||
skills: 1,
|
||||
hooks: 0,
|
||||
mcpServers: 0,
|
||||
skillDetails: [{ name: "plan", description: "Plan work before implementation", invocation: "/plan", runAs: "inline" }],
|
||||
};
|
||||
if (existing >= 0) capPlugins[existing] = view;
|
||||
else capPlugins.push(view);
|
||||
return JSON.stringify({ ok: true, status: "done", kind: "plugin", actions: [{ kind: "plugin", name }] });
|
||||
},
|
||||
async RemovePlugin(name: string) {
|
||||
capPlugins = capPlugins.filter((p) => p.name !== name);
|
||||
},
|
||||
async SetPluginEnabled(name: string, enabled: boolean) {
|
||||
capPlugins = capPlugins.map((p) => p.name === name ? { ...p, enabled } : p);
|
||||
},
|
||||
async UpdatePlugin(name: string) {
|
||||
capPlugins = capPlugins.map((p) => p.name === name ? { ...p, version: p.version || "dev" } : p);
|
||||
return JSON.stringify({ ok: true, status: "done", kind: "plugin", name });
|
||||
},
|
||||
async PluginDoctor(name: string) {
|
||||
return capPlugins.find((p) => p.name === name) || {
|
||||
name,
|
||||
root: "",
|
||||
enabled: false,
|
||||
skills: 0,
|
||||
hooks: 0,
|
||||
mcpServers: 0,
|
||||
error: "plugin is not installed",
|
||||
};
|
||||
},
|
||||
async AddMCPServer(input: MCPServerInput) {
|
||||
const tools = input.transport === "stdio" ? 3 : 5;
|
||||
capServers.push({
|
||||
@@ -2281,6 +2540,9 @@ function makeMockApp(): AppBindings {
|
||||
async PickSkillFolder() {
|
||||
return "~/my-skills";
|
||||
},
|
||||
async PickPluginFolder() {
|
||||
return "~/plugins/superpowers";
|
||||
},
|
||||
async AddSkillPath(path: string) {
|
||||
const dir = path.trim() || "~/my-skills";
|
||||
if (!capSkillRoots.some((r) => r.scope === "custom" && r.dir === dir)) {
|
||||
@@ -2451,14 +2713,20 @@ function makeMockApp(): AppBindings {
|
||||
async RevealPath(path: string) {
|
||||
console.info("mock RevealPath", path);
|
||||
},
|
||||
async SavePastedImage(_dataUrl: string) {
|
||||
return ".reasonix/attachments/mock.png";
|
||||
async SavePastedImage(dataUrl: string) {
|
||||
const path = `.reasonix/attachments/mock-${mockAttachmentDataURLs.size + 1}.png`;
|
||||
mockAttachmentDataURLs.set(path, dataUrl);
|
||||
return path;
|
||||
},
|
||||
async SaveClipboardImage() {
|
||||
return ".reasonix/attachments/mock-clipboard.png";
|
||||
const path = `.reasonix/attachments/mock-clipboard-${mockAttachmentDataURLs.size + 1}.png`;
|
||||
mockAttachmentDataURLs.set(path, mockPreviewImageDataURL);
|
||||
return path;
|
||||
},
|
||||
async SavePastedFile(name: string, _dataUrl: string) {
|
||||
return `.reasonix/attachments/mock-${name}`;
|
||||
async SavePastedFile(name: string, dataUrl: string) {
|
||||
const path = `.reasonix/attachments/mock-${name}`;
|
||||
mockAttachmentDataURLs.set(path, dataUrl);
|
||||
return path;
|
||||
},
|
||||
async PickExportFile(defaultFilename: string, _mimeType: string) {
|
||||
return defaultFilename;
|
||||
@@ -2485,10 +2753,12 @@ function makeMockApp(): AppBindings {
|
||||
const tokenName = name.replace(/[^\w.-]+/g, "-") || "folder";
|
||||
return { kind: "workspace" as const, path: `__reasonix_external_folder/mock/${tokenName}`, isDir: true, displayPath: path };
|
||||
}
|
||||
return { kind: "attachment" as const, path: `.reasonix/attachments/mock-${name}` };
|
||||
const attachmentPath = `.reasonix/attachments/mock-${name}`;
|
||||
mockAttachmentDataURLs.set(attachmentPath, mockPreviewImageDataURL);
|
||||
return { kind: "attachment" as const, path: attachmentPath };
|
||||
},
|
||||
async AttachmentDataURL(_path: string) {
|
||||
return "data:image/png;base64,iVBORw0KGgo=";
|
||||
async AttachmentDataURL(path: string) {
|
||||
return mockAttachmentDataURLs.get(path) ?? mockPreviewImageDataURL;
|
||||
},
|
||||
async Models() {
|
||||
const active = mockTabs.find((tab) => tab.active) ?? mockTabs[0];
|
||||
@@ -2687,6 +2957,9 @@ function makeMockApp(): AppBindings {
|
||||
async SetSubagentEffort(level: string) {
|
||||
settings.subagentEffort = level;
|
||||
},
|
||||
async SetMaxSubagentDepth(depth: number) {
|
||||
settings.agent = { ...settings.agent, maxSubagentDepth: depth <= 1 ? 1 : 2 };
|
||||
},
|
||||
async SetAutoPlan(mode: string) {
|
||||
settings.autoPlan = mode;
|
||||
},
|
||||
@@ -2699,9 +2972,17 @@ function makeMockApp(): AppBindings {
|
||||
if (i >= 0) settings.providers[i] = p;
|
||||
else settings.providers.push(p);
|
||||
},
|
||||
async SaveProviderWithKey(p: ProviderView, key: string) {
|
||||
p.added = true;
|
||||
p.keySet = Boolean(key.trim()) || p.keySet;
|
||||
const i = settings.providers.findIndex((x) => x.name === p.name);
|
||||
if (i >= 0) settings.providers[i] = p;
|
||||
else settings.providers.push(p);
|
||||
return "";
|
||||
},
|
||||
async AddOfficialProviderAccess(kind: string, key: string) {
|
||||
const templates: Record<string, ProviderView> = {
|
||||
deepseek: { name: "deepseek", builtIn: true, added: true, kind: "openai", baseUrl: "https://api.deepseek.com", modelsUrl: "", models: ["deepseek-v4-flash", "deepseek-v4-pro"], visionModels: [], visionModelsConfigured: false, default: "deepseek-v4-flash", apiKeyEnv: "DEEPSEEK_API_KEY", keySet: !!key.trim(), balanceUrl: "https://api.deepseek.com/user/balance", contextWindow: 1_000_000, reasoningProtocol: "", supportedEfforts: [], defaultEffort: "" },
|
||||
deepseek: { name: "deepseek", builtIn: true, added: true, kind: "openai", baseUrl: "https://api.deepseek.com", modelsUrl: "", models: ["deepseek-v4-flash", "deepseek-v4-pro"], visionModels: [], visionModelsConfigured: false, default: "deepseek-v4-flash", apiKeyEnv: "DEEPSEEK_API_KEY", keySet: !!key.trim(), balanceUrl: "https://api.deepseek.com/user/balance", contextWindow: 1_000_000, reasoningProtocol: "", thinking: "", supportedEfforts: [], defaultEffort: "" },
|
||||
};
|
||||
const next = templates[kind];
|
||||
if (!next) throw new Error(`unknown official provider template ${kind}`);
|
||||
@@ -2710,12 +2991,47 @@ function makeMockApp(): AppBindings {
|
||||
else settings.providers.push(next);
|
||||
return "";
|
||||
},
|
||||
async AddProviderPresetAccess(id: string, key: string) {
|
||||
const preset = settings.providerPresets.find((p) => p.id === id);
|
||||
if (!preset) throw new Error(`unknown provider preset ${id}`);
|
||||
const next = cloneMockProviderTemplate(id, key);
|
||||
if (!next) throw new Error(`unknown provider preset ${id}`);
|
||||
const i = settings.providers.findIndex((x) => x.name === next.name);
|
||||
if (i >= 0) settings.providers[i] = { ...settings.providers[i], ...next, keySet: next.keySet || settings.providers[i].keySet };
|
||||
else settings.providers.push(next);
|
||||
preset.added = true;
|
||||
preset.status = "installed";
|
||||
preset.statusProviderNames = [...preset.providerNames];
|
||||
preset.keySet = preset.keySet || !!key.trim();
|
||||
preset.configured = !preset.requiresKey || preset.keySet;
|
||||
return "";
|
||||
},
|
||||
async ResetProviderPresetAccess(id: string) {
|
||||
const preset = settings.providerPresets.find((p) => p.id === id);
|
||||
if (!preset) throw new Error(`unknown provider preset ${id}`);
|
||||
const next = cloneMockProviderTemplate(id, "");
|
||||
if (!next) throw new Error(`unknown provider preset ${id}`);
|
||||
const i = settings.providers.findIndex((x) => x.name === next.name);
|
||||
if (i < 0) throw new Error(`provider preset ${id} cannot be reset because no same-name provider exists`);
|
||||
const existing = settings.providers[i];
|
||||
settings.providers[i] = {
|
||||
...next,
|
||||
added: true,
|
||||
keySet: existing.apiKeyEnv === next.apiKeyEnv ? existing.keySet : next.keySet,
|
||||
};
|
||||
preset.added = true;
|
||||
preset.status = "installed";
|
||||
preset.statusProviderNames = [...preset.providerNames];
|
||||
preset.keySet = preset.keySet || settings.providers[i].keySet;
|
||||
preset.configured = !preset.requiresKey || preset.keySet;
|
||||
},
|
||||
async FetchProviderModels(p: ProviderView) {
|
||||
if (!p.baseUrl.trim()) throw new Error(t("settings.fetchModelsMissingBaseUrl"));
|
||||
if (providerRequiresKey(p) && !p.apiKeyEnv.trim()) throw new Error(t("settings.fetchModelsMissingKeyEnv"));
|
||||
await delay(350);
|
||||
if (p.baseUrl.includes("deepseek")) return ["deepseek-v4-flash", "deepseek-v4-pro"];
|
||||
if (p.baseUrl.includes("mimo") || p.baseUrl.includes("xiaomimimo")) return ["mimo-v2.5", "mimo-v2.5-pro"];
|
||||
if (p.baseUrl.includes("token-plan")) return ["mimo-v2.5", "mimo-v2.5-pro"];
|
||||
if (p.baseUrl.includes("xiaomimimo")) return ["mimo-v2.5-pro", "mimo-v2.5"];
|
||||
return ["gpt-5", "gpt-5-mini", "qwen3-coder"];
|
||||
},
|
||||
async DeleteProvider(name: string) {
|
||||
@@ -2726,6 +3042,12 @@ function makeMockApp(): AppBindings {
|
||||
if (p?.builtIn) p.added = false;
|
||||
else settings.providers = settings.providers.filter((x) => x.name !== name);
|
||||
},
|
||||
async SaveProviderKey(apiKeyEnv: string, _value: string) {
|
||||
settings.providers.forEach((p) => {
|
||||
if (p.apiKeyEnv === apiKeyEnv) p.keySet = true;
|
||||
});
|
||||
return "";
|
||||
},
|
||||
async SetProviderKey(apiKeyEnv: string, _value: string) {
|
||||
settings.providers.forEach((p) => {
|
||||
if (p.apiKeyEnv === apiKeyEnv) p.keySet = true;
|
||||
@@ -2748,8 +3070,10 @@ function makeMockApp(): AppBindings {
|
||||
const k = list as "allow" | "ask" | "deny";
|
||||
settings.permissions[k] = settings.permissions[k].filter((r) => r !== rule);
|
||||
},
|
||||
async ReloadSettings() {},
|
||||
async SetSandbox(bash: string, network: boolean, workspaceRoot: string, allowWrite: string[], shell: string) {
|
||||
settings.sandbox = { bash, network, workspaceRoot, allowWrite, shell };
|
||||
const effectiveWorkspaceRoot = workspaceRoot.trim() || cwd;
|
||||
settings.sandbox = { bash, network, workspaceRoot, allowWrite, effectiveWorkspaceRoot, effectiveWriteRoots: [effectiveWorkspaceRoot, ...allowWrite], shell };
|
||||
},
|
||||
async SetNetwork(n: NetworkView) {
|
||||
settings.network = n;
|
||||
@@ -2823,10 +3147,11 @@ function makeMockApp(): AppBindings {
|
||||
label: domain === "lark" ? "Lark" : domain === "weixin" ? "微信" : "飞书",
|
||||
enabled: true,
|
||||
status: "connected",
|
||||
model: "",
|
||||
toolApprovalMode: "",
|
||||
workspaceRoot: "",
|
||||
credential: {
|
||||
model: "",
|
||||
toolApprovalMode: "",
|
||||
workspaceRoot: "",
|
||||
access: { enabled: true, allowAll: false, pairingEnabled: true, users: [provider === "weixin" ? "wxid_mock_user_001" : "ou_mock_user_001"], groups: [], approvers: [], admins: [] },
|
||||
credential: {
|
||||
appId: provider === "feishu" ? "cli_mock" : "",
|
||||
appSecretEnv: provider === "feishu" ? (domain === "lark" ? "LARK_BOT_APP_SECRET" : "FEISHU_BOT_APP_SECRET") : "",
|
||||
accountId: provider === "weixin" ? "mock-account" : "",
|
||||
@@ -2875,11 +3200,11 @@ function makeMockApp(): AppBindings {
|
||||
async SetDesktopLayoutStyle(style: string) {
|
||||
settings.desktopLayoutStyle = style === "workbench" || style === "creation" ? style : "classic";
|
||||
},
|
||||
async SetDesktopZoomFactor(_factor: number) {
|
||||
// no-op in mock; in production this writes desktop-zoom.json via Go
|
||||
async SetDesktopZoomFactor(factor: number) {
|
||||
mockDesktopZoomFactor = Math.min(2.0, Math.max(0.5, Number.isFinite(factor) ? factor : 1.0));
|
||||
},
|
||||
async GetDesktopZoomFactor() {
|
||||
return 1.0; // default in mock
|
||||
return mockDesktopZoomFactor;
|
||||
},
|
||||
async RestartApplication() {
|
||||
// no-op in mock
|
||||
@@ -2971,7 +3296,7 @@ function makeMockApp(): AppBindings {
|
||||
},
|
||||
async OpenDownloadPage() {
|
||||
if (typeof window !== "undefined") {
|
||||
window.open("https://github.com/esengine/reasonix/releases/latest", "_blank", "noopener");
|
||||
window.open("https://reasonix.io/#start", "_blank", "noopener");
|
||||
}
|
||||
},
|
||||
// Dev seam: drives the overlay flow in the browser until ConnectKey sets the
|
||||
|
||||
@@ -4,11 +4,19 @@ export interface Toast {
|
||||
id: number;
|
||||
text: string;
|
||||
level: "info" | "warn" | "error";
|
||||
actionLabel?: string;
|
||||
onAction?: () => void;
|
||||
}
|
||||
|
||||
export interface ToastOptions {
|
||||
actionLabel?: string;
|
||||
onAction?: () => void;
|
||||
durationMs?: number;
|
||||
}
|
||||
|
||||
export interface ToastContextValue {
|
||||
toasts: Toast[];
|
||||
showToast: (text: string, level?: Toast["level"]) => void;
|
||||
showToast: (text: string, level?: Toast["level"], options?: ToastOptions) => void;
|
||||
}
|
||||
|
||||
const ToastContext = createContext<ToastContextValue>({ toasts: [], showToast: () => {} });
|
||||
@@ -23,13 +31,13 @@ export function ToastProvider({ children }: { children: ReactNode }) {
|
||||
const [toasts, setToasts] = useState<Toast[]>([]);
|
||||
const timers = useRef(new Map<number, ReturnType<typeof setTimeout>>());
|
||||
|
||||
const showToast = useCallback((text: string, level: Toast["level"] = "info") => {
|
||||
const showToast = useCallback((text: string, level: Toast["level"] = "info", options: ToastOptions = {}) => {
|
||||
const id = nextId++;
|
||||
setToasts((prev) => [...prev, { id, text, level }]);
|
||||
setToasts((prev) => [...prev, { id, text, level, actionLabel: options.actionLabel, onAction: options.onAction }]);
|
||||
const timer = setTimeout(() => {
|
||||
setToasts((prev) => prev.filter((t) => t.id !== id));
|
||||
timers.current.delete(id);
|
||||
}, 2500);
|
||||
}, options.durationMs ?? (options.actionLabel ? 8000 : 2500));
|
||||
timers.current.set(id, timer);
|
||||
}, []);
|
||||
|
||||
@@ -49,6 +57,19 @@ export function ToastProvider({ children }: { children: ReactNode }) {
|
||||
{t.level === "warn" && <span className="toast__icon">⚠️</span>}
|
||||
{t.level === "error" && <span className="toast__icon">❌</span>}
|
||||
<span className="toast__text">{t.text}</span>
|
||||
{t.actionLabel && t.onAction && (
|
||||
<button
|
||||
type="button"
|
||||
className="toast__action"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
dismissToast(t.id);
|
||||
t.onAction?.();
|
||||
}}
|
||||
>
|
||||
{t.actionLabel}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
export function appendTurnActionCopyText(current: string, next: string): string {
|
||||
if (next.trim() === "") return current;
|
||||
if (current.trim() === "") return next;
|
||||
if (current.endsWith("\n") || next.startsWith("\n")) return current + next;
|
||||
return `${current}\n\n${next}`;
|
||||
}
|
||||
@@ -208,6 +208,10 @@ export interface TabMeta {
|
||||
goal?: string;
|
||||
goalStatus?: GoalStatus;
|
||||
autoResearch?: AutoResearchCompactView;
|
||||
recovered?: boolean;
|
||||
recoveryReason?: string;
|
||||
recoveryDigest?: string;
|
||||
recoveryParentId?: string;
|
||||
startupErr?: string;
|
||||
active: boolean;
|
||||
cwd: string;
|
||||
@@ -228,6 +232,10 @@ export interface ProjectNode {
|
||||
running?: boolean;
|
||||
status?: ProjectTopicStatus;
|
||||
pinned?: boolean;
|
||||
recovered?: boolean;
|
||||
recoveryReason?: string;
|
||||
recoveryDigest?: string;
|
||||
recoveryParentId?: string;
|
||||
children?: ProjectNode[];
|
||||
}
|
||||
|
||||
@@ -239,6 +247,23 @@ export interface TopicMeta {
|
||||
createdAt: number;
|
||||
}
|
||||
|
||||
export interface SessionRecoveryEvent {
|
||||
originalPath?: string;
|
||||
recoveryPath: string;
|
||||
scope?: string;
|
||||
workspaceRoot?: string;
|
||||
topicId?: string;
|
||||
topicTitle?: string;
|
||||
recoveryReason?: string;
|
||||
recoveryDigest?: string;
|
||||
recoveryParentId?: string;
|
||||
existing?: boolean;
|
||||
}
|
||||
|
||||
export interface SessionRecoveryFailedEvent {
|
||||
reason?: "lease_held" | "lease_unavailable" | string;
|
||||
}
|
||||
|
||||
export interface ContextPanelInfo {
|
||||
usedTokens: number;
|
||||
windowTokens: number;
|
||||
@@ -388,6 +413,7 @@ export interface SessionMeta {
|
||||
userId?: string;
|
||||
threadId?: string;
|
||||
sessionSource?: string;
|
||||
recovered?: boolean; // conflict-recovery copy of another session
|
||||
}
|
||||
|
||||
// SessionReference is a session selected via @ past:chats for context injection.
|
||||
@@ -643,6 +669,7 @@ export interface ServerView {
|
||||
tools: number;
|
||||
prompts: number;
|
||||
resources: number;
|
||||
hasTools?: boolean;
|
||||
error?: string;
|
||||
toolList?: MCPToolView[];
|
||||
trustedReadOnlyTools?: string[];
|
||||
@@ -683,11 +710,55 @@ export interface CapabilitiesView {
|
||||
servers: ServerView[];
|
||||
skills: SkillView[];
|
||||
skillRoots: SkillRootView[];
|
||||
plugins: PluginView[];
|
||||
}
|
||||
export interface SkillsSettingsView {
|
||||
skills: SkillView[];
|
||||
skillRoots: SkillRootView[];
|
||||
}
|
||||
export interface PluginView {
|
||||
name: string;
|
||||
version?: string;
|
||||
description?: string;
|
||||
source?: string;
|
||||
root: string;
|
||||
manifestKind?: string;
|
||||
enabled: boolean;
|
||||
skills: number;
|
||||
hooks: number;
|
||||
mcpServers: number;
|
||||
skillDetails?: PluginSkillView[];
|
||||
hookDetails?: PluginHookView[];
|
||||
mcpServerDetails?: PluginMCPServerView[];
|
||||
warnings?: string[];
|
||||
error?: string;
|
||||
}
|
||||
export interface PluginSkillView {
|
||||
name: string;
|
||||
description?: string;
|
||||
path?: string;
|
||||
invocation?: string;
|
||||
runAs?: string;
|
||||
}
|
||||
export interface PluginHookView {
|
||||
event: string;
|
||||
match?: string;
|
||||
command?: string;
|
||||
contextFile?: string;
|
||||
description?: string;
|
||||
}
|
||||
export interface PluginMCPServerView {
|
||||
name: string;
|
||||
transport?: string;
|
||||
command?: string;
|
||||
url?: string;
|
||||
}
|
||||
export interface PluginInstallOptions {
|
||||
dryRun?: boolean;
|
||||
link?: boolean;
|
||||
replace?: boolean;
|
||||
name?: string;
|
||||
}
|
||||
export interface MCPServerInput {
|
||||
name: string;
|
||||
transport: string; // stdio | http | sse
|
||||
@@ -791,7 +862,7 @@ export interface MemoryView {
|
||||
}
|
||||
|
||||
// SettingsTab is the top-level navigation item in the Settings Centre modal.
|
||||
export type SettingsTab = "general" | "models" | "providers" | "bots" | "mcp" | "skills" | "memory" | "hooks" | "shortcuts" | "permissions" | "sandbox" | "network" | "appearance" | "updates";
|
||||
export type SettingsTab = "general" | "models" | "providers" | "bots" | "mcp" | "skills" | "plugins" | "memory" | "hooks" | "shortcuts" | "permissions" | "sandbox" | "network" | "appearance" | "updates";
|
||||
|
||||
// Settings panel payloads (desktop/settings_app.go).
|
||||
export interface ProviderView {
|
||||
@@ -808,6 +879,8 @@ export interface ProviderView {
|
||||
default: string;
|
||||
apiKeyEnv: string;
|
||||
headers?: Record<string, string> | null; // optional extra request headers for compatible gateways
|
||||
extraBody?: Record<string, unknown> | null; // optional extra top-level request body fields for compatible gateways
|
||||
authHeader?: boolean; // Anthropic-compatible: send Authorization: Bearer instead of x-api-key
|
||||
keySet: boolean; // the env var currently resolves to a value
|
||||
requiresKey?: boolean; // false for explicit no-auth providers
|
||||
configured?: boolean; // selectable: key is set or no key is required
|
||||
@@ -816,11 +889,29 @@ export interface ProviderView {
|
||||
balanceUrl: string; // optional wallet-balance endpoint; "" disables the readout
|
||||
contextWindow: number;
|
||||
reasoningProtocol: string; // auto|deepseek|openai|none; empty = auto/model registry
|
||||
thinking: string; // provider-specific thinking override: ""|enabled|disabled|adaptive
|
||||
supportedEfforts: string[]; // custom /effort levels; empty = use built-in Kind/BaseURL default
|
||||
defaultEffort: string; // /effort level when user picks "auto" or unset; "" = supportedEfforts[0]
|
||||
modelOverrides?: ProviderModelOverrideView[] | null;
|
||||
}
|
||||
|
||||
export interface ProviderPresetView {
|
||||
id: string;
|
||||
label: string;
|
||||
description: string;
|
||||
keyEnv: string;
|
||||
providerNames: string[];
|
||||
models: string[];
|
||||
added: boolean;
|
||||
status?: "available" | "installed" | "installed_modified" | "name_conflict" | "similar_existing";
|
||||
statusProviderNames?: string[];
|
||||
keySet: boolean;
|
||||
requiresKey?: boolean;
|
||||
configured?: boolean;
|
||||
keySource?: string;
|
||||
keySourcePath?: string;
|
||||
}
|
||||
|
||||
export interface ProviderModelOverrideView {
|
||||
model: string;
|
||||
reasoningProtocol: string;
|
||||
@@ -859,6 +950,8 @@ export interface SandboxView {
|
||||
network: boolean;
|
||||
workspaceRoot: string;
|
||||
allowWrite: string[];
|
||||
effectiveWorkspaceRoot: string;
|
||||
effectiveWriteRoots: string[];
|
||||
shell: string; // "auto" | "bash" | "powershell" | "pwsh"
|
||||
}
|
||||
|
||||
@@ -881,6 +974,7 @@ export interface AgentView {
|
||||
temperature: number;
|
||||
maxSteps: number;
|
||||
plannerMaxSteps: number;
|
||||
maxSubagentDepth: number;
|
||||
systemPrompt: string;
|
||||
coldResumePrune: boolean;
|
||||
reasoningLanguage: string; // "auto" | "zh" | "en"
|
||||
@@ -892,17 +986,67 @@ export interface BotAllowlistView {
|
||||
qqUsers: string[];
|
||||
feishuUsers: string[];
|
||||
weixinUsers: string[];
|
||||
qqApprovers: string[];
|
||||
feishuApprovers: string[];
|
||||
weixinApprovers: string[];
|
||||
qqAdmins: string[];
|
||||
feishuAdmins: string[];
|
||||
weixinAdmins: string[];
|
||||
qqGroups: string[];
|
||||
feishuGroups: string[];
|
||||
weixinGroups: string[];
|
||||
}
|
||||
|
||||
export interface BotAccessView {
|
||||
enabled: boolean;
|
||||
allowAll: boolean;
|
||||
pairingEnabled: boolean;
|
||||
users: string[];
|
||||
groups: string[];
|
||||
approvers: string[];
|
||||
admins: string[];
|
||||
}
|
||||
|
||||
export interface BotSelfUserIDsView {
|
||||
qq: string[];
|
||||
feishu: string[];
|
||||
weixin: string[];
|
||||
}
|
||||
|
||||
export interface BotPairingView {
|
||||
enabled: boolean;
|
||||
requestTtlMinutes: number;
|
||||
maxPendingPerPlatform: number;
|
||||
}
|
||||
|
||||
export interface BotControlView {
|
||||
enabled: boolean;
|
||||
addr: string;
|
||||
tokenEnv: string;
|
||||
}
|
||||
|
||||
export interface BotRouteView {
|
||||
connectionId: string;
|
||||
platform: string;
|
||||
chatType: string;
|
||||
chatId: string;
|
||||
userId: string;
|
||||
threadId: string;
|
||||
model: string;
|
||||
toolApprovalMode: ToolApprovalMode | "" | string;
|
||||
workspaceRoot: string;
|
||||
}
|
||||
|
||||
export interface QQBotView {
|
||||
enabled: boolean;
|
||||
appId: string;
|
||||
appSecretEnv: string;
|
||||
secretSet: boolean;
|
||||
sandbox: boolean;
|
||||
model: string;
|
||||
toolApprovalMode: ToolApprovalMode | "" | string;
|
||||
workspaceRoot: string;
|
||||
access: BotAccessView;
|
||||
}
|
||||
|
||||
export interface FeishuBotView {
|
||||
@@ -955,6 +1099,7 @@ export interface BotConnectionView {
|
||||
model: string;
|
||||
toolApprovalMode: ToolApprovalMode | "" | string;
|
||||
workspaceRoot: string;
|
||||
access: BotAccessView;
|
||||
credential: BotConnectionCredentialView;
|
||||
sessionMappings: BotConnectionSessionMappingView[];
|
||||
lastError: string;
|
||||
@@ -968,6 +1113,14 @@ export interface BotSettingsView {
|
||||
toolApprovalMode: ToolApprovalMode | "" | string;
|
||||
maxSteps: number;
|
||||
debounceMs: number;
|
||||
queueMode: string;
|
||||
queueCap: number;
|
||||
queueDrop: string;
|
||||
ignoreSelfMessages: boolean;
|
||||
selfUserIds: BotSelfUserIDsView;
|
||||
control: BotControlView;
|
||||
pairing: BotPairingView;
|
||||
routes: BotRouteView[];
|
||||
allowlist: BotAllowlistView;
|
||||
qq: QQBotView;
|
||||
feishu: FeishuBotView;
|
||||
@@ -1043,6 +1196,7 @@ export interface SettingsView {
|
||||
autoPlan: string;
|
||||
providers: ProviderView[];
|
||||
officialProviders: ProviderView[];
|
||||
providerPresets: ProviderPresetView[];
|
||||
permissions: PermissionsView;
|
||||
sandbox: SandboxView;
|
||||
network: NetworkView;
|
||||
|
||||
@@ -168,6 +168,7 @@ type RuntimeMetaSnapshot = {
|
||||
running: boolean;
|
||||
pendingPrompt?: boolean;
|
||||
backgroundJobs?: number;
|
||||
cancelRequested?: boolean;
|
||||
cancellable?: boolean;
|
||||
};
|
||||
|
||||
@@ -354,6 +355,18 @@ type Action =
|
||||
| { type: "clearAsk" }
|
||||
| { type: "reset" };
|
||||
|
||||
function backendStatusFromRuntimeMeta(meta: RuntimeMetaSnapshot): Extract<Action, { type: "backend_status" }> {
|
||||
const foregroundRunning = foregroundRunningFromRuntimeMeta(meta);
|
||||
return {
|
||||
type: "backend_status",
|
||||
running: foregroundRunning,
|
||||
pendingPrompt: Boolean(meta.pendingPrompt),
|
||||
backgroundJobs: meta.backgroundJobs ?? 0,
|
||||
cancelRequested: Boolean(meta.cancelRequested),
|
||||
cancellable: foregroundRunning,
|
||||
};
|
||||
}
|
||||
|
||||
// ---- reducer helpers (unchanged logic) ----
|
||||
|
||||
export function historyMessagesToItems(messages: HistoryMessage[], idPrefix: string, startSeq = 0): { items: Item[]; seq: number } {
|
||||
@@ -940,6 +953,27 @@ function errorMessage(err: unknown): string {
|
||||
return String(err || "");
|
||||
}
|
||||
|
||||
export function effortSwitchNoticeText(err: unknown): string {
|
||||
const msg = errorMessage(err).trim() || "unknown error";
|
||||
const lower = msg.toLowerCase();
|
||||
if (lower.includes("finish or cancel") && lower.includes("before changing effort")) {
|
||||
return t("status.effortSwitchBusy");
|
||||
}
|
||||
if (lower.includes("already open in another reasonix window") || lower.includes("session lease held")) {
|
||||
return t("status.effortSwitchLeaseHeld");
|
||||
}
|
||||
if (lower.includes("workspace is still starting")) {
|
||||
return t("status.effortSwitchStarting");
|
||||
}
|
||||
if (lower.startsWith("workspace failed to start")) {
|
||||
return t("status.effortSwitchStartupFailed", { err: msg });
|
||||
}
|
||||
if (lower.includes("changed while switching effort") || (lower.includes("tab ") && lower.includes("not found"))) {
|
||||
return t("status.effortSwitchRetry");
|
||||
}
|
||||
return t("status.effortSwitchFailed", { err: msg });
|
||||
}
|
||||
|
||||
async function refreshMetaForTab(tabId: string, dispatchTo: (tabId: string, action: Action) => void): Promise<void> {
|
||||
try {
|
||||
dispatchTo(tabId, { type: "meta", meta: await app.MetaForTab(tabId) });
|
||||
@@ -1060,23 +1094,31 @@ export function useController() {
|
||||
if (reset && sessionLoadCurrent(tabId, seq)) dispatchTo(tabId, { type: "reset" });
|
||||
|
||||
const stillCurrent = () => sessionLoadCurrent(tabId, seq);
|
||||
const requiresVisibleTab = reason === "startup" || reason === "switch-tab" || reason === "open-topic";
|
||||
const stillVisible = () => !requiresVisibleTab || activeTabIdRef.current === tabId;
|
||||
const noteFailure = (label: string, err: unknown) => {
|
||||
addBreadcrumb("tab.hydrate", `${label} failed ${tabId}: ${errorMessage(err)}`);
|
||||
};
|
||||
|
||||
const historyStartedAt = Date.now();
|
||||
const loadTimed = async <T,>(label: string, load: () => Promise<T>): Promise<T | undefined> => {
|
||||
const startedAt = Date.now();
|
||||
addBreadcrumb("tab.hydrate", `${label} start ${reason} ${tabId}`);
|
||||
try {
|
||||
const value = await load();
|
||||
addBreadcrumb("tab.hydrate", `${label} done ${reason} ${tabId} ms=${Date.now() - startedAt}`);
|
||||
return value;
|
||||
} catch (err) {
|
||||
noteFailure(label, err);
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
|
||||
// Phase 1: dispatch fast metadata immediately so the transcript can render
|
||||
// without waiting for slower ancillary calls (e.g. BalanceForTab network).
|
||||
const [meta, historyPage] = await Promise.all([
|
||||
app.MetaForTab(tabId).catch((err) => { noteFailure("meta", err); return undefined; }),
|
||||
skipHistory
|
||||
? Promise.resolve(undefined)
|
||||
: app.HistoryPageForTab(tabId, 0, HISTORY_PAGE_TURNS).catch((err) => { noteFailure("history", err); return undefined; }),
|
||||
]);
|
||||
const historyStartedAt = Date.now();
|
||||
const historyPage = skipHistory
|
||||
? undefined
|
||||
: await loadTimed("history", () => app.HistoryPageForTab(tabId, 0, HISTORY_PAGE_TURNS));
|
||||
|
||||
if (!stillCurrent()) return;
|
||||
if (meta !== undefined) dispatchTo(tabId, { type: "meta", meta });
|
||||
if (!skipHistory && historyPage !== undefined) {
|
||||
const messages = asArray(historyPage.messages);
|
||||
dispatchTo(tabId, { type: "history_page", page: historyPage, mode: "replace" });
|
||||
@@ -1107,21 +1149,20 @@ export function useController() {
|
||||
// in a loading state.
|
||||
await new Promise<void>((resolve) => window.setTimeout(resolve, 0));
|
||||
if (!stillCurrent()) return;
|
||||
if (activeTabIdRef.current !== tabId && (reason === "startup" || reason === "switch-tab" || reason === "open-topic")) {
|
||||
if (!stillVisible()) {
|
||||
addBreadcrumb("tab.hydrate", `ancillary skipped inactive ${reason} ${tabId}`);
|
||||
return;
|
||||
}
|
||||
const meta = await loadTimed("meta", () => app.MetaForTab(tabId));
|
||||
if (!stillCurrent()) return;
|
||||
if (!stillVisible()) {
|
||||
addBreadcrumb("tab.hydrate", `meta ignored inactive ${reason} ${tabId}`);
|
||||
return;
|
||||
}
|
||||
if (meta !== undefined) dispatchTo(tabId, { type: "meta", meta });
|
||||
const ancillaryStartedAt = Date.now();
|
||||
const loadAncillary = async <T,>(label: string, load: () => Promise<T>): Promise<T | undefined> => {
|
||||
const startedAt = Date.now();
|
||||
try {
|
||||
const value = await load();
|
||||
addBreadcrumb("tab.hydrate", `ancillary ${label} ${reason} ${tabId} ms=${Date.now() - startedAt}`);
|
||||
return value;
|
||||
} catch (err) {
|
||||
noteFailure(label, err);
|
||||
return undefined;
|
||||
}
|
||||
return loadTimed(`ancillary ${label}`, load);
|
||||
};
|
||||
const [effort, jobs, context] = await Promise.all([
|
||||
loadAncillary("effort", () => app.EffortForTab(tabId)),
|
||||
@@ -1129,22 +1170,30 @@ export function useController() {
|
||||
loadAncillary("context", () => app.ContextUsageForTab(tabId)),
|
||||
]);
|
||||
if (!stillCurrent()) return;
|
||||
if (!stillVisible()) {
|
||||
addBreadcrumb("tab.hydrate", `ancillary ignored inactive ${reason} ${tabId}`);
|
||||
return;
|
||||
}
|
||||
if (effort !== undefined) dispatchTo(tabId, { type: "effort", effort });
|
||||
if (jobs !== undefined) dispatchTo(tabId, { type: "jobs", jobs: asArray(jobs) });
|
||||
if (context !== undefined) dispatchTo(tabId, { type: "context", context });
|
||||
await new Promise<void>((resolve) => window.setTimeout(resolve, 0));
|
||||
if (!stillCurrent()) return;
|
||||
if (activeTabIdRef.current !== tabId && (reason === "startup" || reason === "switch-tab" || reason === "open-topic")) {
|
||||
if (!stillVisible()) {
|
||||
addBreadcrumb("tab.hydrate", `checkpoints skipped inactive ${reason} ${tabId}`);
|
||||
return;
|
||||
}
|
||||
const checkpoints = await loadAncillary("checkpoints", () => app.CheckpointsForTab(tabId));
|
||||
if (!stillCurrent()) return;
|
||||
if (!stillVisible()) {
|
||||
addBreadcrumb("tab.hydrate", `checkpoints ignored inactive ${reason} ${tabId}`);
|
||||
return;
|
||||
}
|
||||
if (checkpoints !== undefined) dispatchTo(tabId, { type: "checkpoints", checkpoints: asArray(checkpoints) });
|
||||
addBreadcrumb("tab.hydrate", `ancillary ${reason} ${tabId} ms=${Date.now() - ancillaryStartedAt}`);
|
||||
app.BalanceForTab(tabId)
|
||||
.then((balance) => {
|
||||
if (sessionLoadCurrent(tabId, seq)) dispatchTo(tabId, { type: "balance", balance });
|
||||
if (sessionLoadCurrent(tabId, seq) && stillVisible()) dispatchTo(tabId, { type: "balance", balance });
|
||||
})
|
||||
.catch((err) => { noteFailure("balance", err); });
|
||||
})();
|
||||
@@ -1192,6 +1241,22 @@ export function useController() {
|
||||
return tabs.find((tab) => tab.active) ?? tabs[0];
|
||||
}, []);
|
||||
|
||||
const dispatchRuntimeStatusForTab = useCallback((tabId: string, tab: RuntimeMetaSnapshot) => {
|
||||
const foregroundRunning = foregroundRunningFromRuntimeMeta(tab);
|
||||
dispatchTo(tabId, {
|
||||
type: "backend_status",
|
||||
running: foregroundRunning,
|
||||
pendingPrompt: Boolean(tab.pendingPrompt),
|
||||
backgroundJobs: tab.backgroundJobs ?? 0,
|
||||
cancelRequested: Boolean(tab.cancelRequested),
|
||||
cancellable: foregroundRunning,
|
||||
});
|
||||
// backend_status reconciliation can clear a live prompt from frontend state.
|
||||
// If the backend is still blocked, ask it to replay the approval/ask event.
|
||||
if (tab.pendingPrompt) replayPendingPromptsForActiveTab(tabId);
|
||||
return foregroundRunning;
|
||||
}, [dispatchTo]);
|
||||
|
||||
const waitForTabReady = useCallback(async (tabId: string): Promise<void> => {
|
||||
for (let attempt = 0; attempt < 60; attempt += 1) {
|
||||
const tabs = asArray(await app.ListTabs().catch(() => [] as TabMeta[]));
|
||||
@@ -1216,12 +1281,14 @@ export function useController() {
|
||||
confirmBackendActiveTab(active.id);
|
||||
dispatchTo(active.id, { type: "optimistic_meta", meta: metaFromTab(active, statesRef.current.get(active.id)?.meta) });
|
||||
const preserveCachedHistory = options.preserveCachedHistory ?? !reset;
|
||||
if (!reset) dispatchRuntimeStatusForTab(active.id, active);
|
||||
await loadSessionDataForTab(active.id, reset, "startup", {
|
||||
preserveCachedHistory,
|
||||
sessionPath: active.sessionPath,
|
||||
});
|
||||
if (reset) dispatchRuntimeStatusForTab(active.id, active);
|
||||
return active.id;
|
||||
}, [activeTabFromBackend, confirmBackendActiveTab, dispatchTo, loadSessionDataForTab]);
|
||||
}, [activeTabFromBackend, confirmBackendActiveTab, dispatchRuntimeStatusForTab, dispatchTo, loadSessionDataForTab]);
|
||||
|
||||
const reconcileTabRuntime = useCallback(async (
|
||||
tabId: string,
|
||||
@@ -1233,19 +1300,8 @@ export function useController() {
|
||||
if (!tab) return undefined;
|
||||
const local = statesRef.current.get(tabId);
|
||||
const needsInitialLoad = !local?.meta;
|
||||
const foregroundRunning = foregroundRunningFromRuntimeMeta(tab);
|
||||
const foregroundRunning = dispatchRuntimeStatusForTab(tabId, tab);
|
||||
const missedTurnDone = Boolean(local?.running && !foregroundRunning);
|
||||
dispatchTo(tabId, {
|
||||
type: "backend_status",
|
||||
running: foregroundRunning,
|
||||
pendingPrompt: Boolean(tab.pendingPrompt),
|
||||
backgroundJobs: tab.backgroundJobs ?? 0,
|
||||
cancelRequested: Boolean(tab.cancelRequested),
|
||||
cancellable: foregroundRunning,
|
||||
});
|
||||
// backend_status reconciliation can clear a live prompt from frontend state.
|
||||
// If the backend is still blocked, ask it to replay the approval/ask event.
|
||||
if (tab.pendingPrompt) replayPendingPromptsForActiveTab(tabId);
|
||||
if (hydrateSessionData && (needsInitialLoad || missedTurnDone)) {
|
||||
await loadSessionDataForTab(tabId, missedTurnDone, "startup");
|
||||
return tabs;
|
||||
@@ -1259,7 +1315,7 @@ export function useController() {
|
||||
if (effort) dispatchTo(tabId, { type: "effort", effort });
|
||||
if (balance) dispatchTo(tabId, { type: "balance", balance });
|
||||
return tabs;
|
||||
}, [dispatchTo, loadSessionDataForTab]);
|
||||
}, [dispatchRuntimeStatusForTab, loadSessionDataForTab]);
|
||||
|
||||
const clearCancelReconcileTimer = useCallback((tabId: string) => {
|
||||
const timer = cancelReconcileTimers.current.get(tabId);
|
||||
@@ -1422,9 +1478,10 @@ export function useController() {
|
||||
setActiveTabId(active.id);
|
||||
activeTabIdRef.current = active.id;
|
||||
confirmBackendActiveTab(active.id);
|
||||
dispatchRuntimeStatusForTab(active.id, active);
|
||||
return sendToTab(active.id, displayText, submitText);
|
||||
});
|
||||
}, [activeTabFromBackend, activeTabId, confirmBackendActiveTab, sendToTab]);
|
||||
}, [activeTabFromBackend, activeTabId, confirmBackendActiveTab, dispatchRuntimeStatusForTab, sendToTab]);
|
||||
|
||||
const runShell = useCallback(async (command: string) => {
|
||||
if (!activeTabId) throw new Error("workspace is still starting");
|
||||
@@ -1688,7 +1745,12 @@ export function useController() {
|
||||
|
||||
const setEffort = useCallback(async (level: string) => {
|
||||
if (!activeTabId) return;
|
||||
await app.SetEffortForTab(activeTabId, level).catch(() => {});
|
||||
try {
|
||||
await app.SetEffortForTab(activeTabId, level);
|
||||
} catch (err) {
|
||||
dispatchTo(activeTabId, { type: "local_notice", level: "warn", text: effortSwitchNoticeText(err) });
|
||||
return;
|
||||
}
|
||||
try {
|
||||
dispatchTo(activeTabId, { type: "meta", meta: await app.MetaForTab(activeTabId) });
|
||||
dispatchTo(activeTabId, { type: "context", context: await app.ContextUsageForTab(activeTabId) });
|
||||
@@ -1731,11 +1793,13 @@ export function useController() {
|
||||
setActiveTabId(tab.id);
|
||||
activeTabIdRef.current = tab.id;
|
||||
confirmBackendActiveTab(tab.id);
|
||||
dispatchRuntimeStatusForTab(tab.id, tab);
|
||||
// The fork's controller builds in a background goroutine: an immediate
|
||||
// load reads empty history, and the ready-event fallback can still
|
||||
// target the source tab, leaving the fork blank (#3742).
|
||||
await waitForTabReady(tab.id);
|
||||
await loadSessionDataForTab(tab.id, true);
|
||||
await reconcileTabRuntime(tab.id, { hydrateSessionData: false });
|
||||
} else {
|
||||
await syncActiveTabFromBackend(true);
|
||||
}
|
||||
@@ -1758,7 +1822,7 @@ export function useController() {
|
||||
} finally {
|
||||
dispatchTo(sourceTabId, { type: "message_action_done" });
|
||||
}
|
||||
}, [activeTabId, confirmBackendActiveTab, dispatchTo, loadSessionDataForTab, syncActiveTabFromBackend, waitForBackendActiveTab, waitForTabReady]);
|
||||
}, [activeTabId, confirmBackendActiveTab, dispatchRuntimeStatusForTab, dispatchTo, loadSessionDataForTab, reconcileTabRuntime, syncActiveTabFromBackend, waitForBackendActiveTab, waitForTabReady]);
|
||||
|
||||
// Tab management: switch preserves per-tab state; open creates it.
|
||||
const switchTab = useCallback(async (tabId: string, optimisticTab?: TabMeta): Promise<TabMeta[] | undefined> => {
|
||||
@@ -1772,11 +1836,25 @@ export function useController() {
|
||||
dispatchTo(tabId, { type: "backend_activation_start" });
|
||||
if (optimisticTab) {
|
||||
dispatchTo(tabId, { type: "optimistic_meta", meta: metaFromTab(optimisticTab, statesRef.current.get(tabId)?.meta) });
|
||||
const optimisticStatus = backendStatusFromRuntimeMeta(optimisticTab);
|
||||
if (optimisticStatus.running) dispatchTo(tabId, optimisticStatus);
|
||||
}
|
||||
dispatchTo(tabId, { type: "hydrate_start", reason: "switch-tab" });
|
||||
addBreadcrumb("tab.switch", `active-rendered ${tabId} ms=${Date.now() - startedAt}`);
|
||||
const backendActivation = app.SetActiveTab(tabId)
|
||||
.then(() => {
|
||||
.then(async () => {
|
||||
if (activeTabIdRef.current !== tabId) {
|
||||
const currentTabId = activeTabIdRef.current;
|
||||
if (currentTabId) {
|
||||
await app.SetActiveTab(currentTabId).then(() => {
|
||||
if (activeTabIdRef.current === currentTabId) confirmBackendActiveTab(currentTabId);
|
||||
}).catch((err) => {
|
||||
addBreadcrumb("tab.switch", `set-active-stale-reassert-failed ${currentTabId}: ${errorMessage(err)}`);
|
||||
});
|
||||
}
|
||||
addBreadcrumb("tab.switch", `set-active-stale ${tabId} current=${currentTabId ?? ""} ms=${Date.now() - startedAt}`);
|
||||
return false;
|
||||
}
|
||||
confirmBackendActiveTab(tabId);
|
||||
addBreadcrumb("tab.switch", `set-active-done ${tabId} ms=${Date.now() - startedAt}`);
|
||||
return true;
|
||||
@@ -1820,13 +1898,16 @@ export function useController() {
|
||||
activeTabIdRef.current = meta.id;
|
||||
confirmBackendActiveTab(meta.id);
|
||||
dispatchTo(meta.id, { type: "optimistic_meta", meta: metaFromTab(meta, statesRef.current.get(meta.id)?.meta) });
|
||||
void loadSessionDataForTab(meta.id, isNewTab, "open-topic", {
|
||||
dispatchRuntimeStatusForTab(meta.id, meta);
|
||||
const load = loadSessionDataForTab(meta.id, isNewTab, "open-topic", {
|
||||
placeholderItems: isNewTab ? prevItems : undefined,
|
||||
preserveCachedHistory,
|
||||
sessionPath: meta.sessionPath,
|
||||
});
|
||||
if (isNewTab) void load.then(() => reconcileTabRuntime(meta.id, { hydrateSessionData: false })).catch(() => {});
|
||||
else void load;
|
||||
return meta;
|
||||
}, [confirmBackendActiveTab, dispatchTo, loadSessionDataForTab]);
|
||||
}, [confirmBackendActiveTab, dispatchRuntimeStatusForTab, dispatchTo, loadSessionDataForTab, reconcileTabRuntime]);
|
||||
|
||||
const openGlobalTab = useCallback(async (topicId: string): Promise<TabMeta> => {
|
||||
const meta = await app.OpenGlobalTab(topicId);
|
||||
@@ -1838,13 +1919,16 @@ export function useController() {
|
||||
activeTabIdRef.current = meta.id;
|
||||
confirmBackendActiveTab(meta.id);
|
||||
dispatchTo(meta.id, { type: "optimistic_meta", meta: metaFromTab(meta, statesRef.current.get(meta.id)?.meta) });
|
||||
void loadSessionDataForTab(meta.id, isNewTab, "open-topic", {
|
||||
dispatchRuntimeStatusForTab(meta.id, meta);
|
||||
const load = loadSessionDataForTab(meta.id, isNewTab, "open-topic", {
|
||||
placeholderItems: isNewTab ? prevItems : undefined,
|
||||
preserveCachedHistory,
|
||||
sessionPath: meta.sessionPath,
|
||||
});
|
||||
if (isNewTab) void load.then(() => reconcileTabRuntime(meta.id, { hydrateSessionData: false })).catch(() => {});
|
||||
else void load;
|
||||
return meta;
|
||||
}, [confirmBackendActiveTab, dispatchTo, loadSessionDataForTab]);
|
||||
}, [confirmBackendActiveTab, dispatchRuntimeStatusForTab, dispatchTo, loadSessionDataForTab, reconcileTabRuntime]);
|
||||
|
||||
const openTopicSession = useCallback(async (scope: string, workspaceRoot: string, topicId: string, sessionPath: string): Promise<TabMeta> => {
|
||||
const meta = await app.OpenTopicSession(scope, workspaceRoot, topicId, sessionPath);
|
||||
@@ -1856,13 +1940,16 @@ export function useController() {
|
||||
activeTabIdRef.current = meta.id;
|
||||
confirmBackendActiveTab(meta.id);
|
||||
dispatchTo(meta.id, { type: "optimistic_meta", meta: metaFromTab(meta, statesRef.current.get(meta.id)?.meta) });
|
||||
void loadSessionDataForTab(meta.id, isNewTab, "open-topic", {
|
||||
dispatchRuntimeStatusForTab(meta.id, meta);
|
||||
const load = loadSessionDataForTab(meta.id, isNewTab, "open-topic", {
|
||||
placeholderItems: isNewTab ? prevItems : undefined,
|
||||
preserveCachedHistory,
|
||||
sessionPath: meta.sessionPath,
|
||||
});
|
||||
if (isNewTab) void load.then(() => reconcileTabRuntime(meta.id, { hydrateSessionData: false })).catch(() => {});
|
||||
else void load;
|
||||
return meta;
|
||||
}, [confirmBackendActiveTab, dispatchTo, loadSessionDataForTab]);
|
||||
}, [confirmBackendActiveTab, dispatchRuntimeStatusForTab, dispatchTo, loadSessionDataForTab, reconcileTabRuntime]);
|
||||
|
||||
const activateTopic = useCallback(async (scope: string, workspaceRoot: string, topicId: string, sessionPath = ""): Promise<TabMeta> => {
|
||||
const meta = await app.ActivateTopic(scope, workspaceRoot, topicId, sessionPath);
|
||||
@@ -1876,21 +1963,28 @@ export function useController() {
|
||||
activeTabIdRef.current = meta.id;
|
||||
confirmBackendActiveTab(meta.id);
|
||||
dispatchTo(meta.id, { type: "optimistic_meta", meta: metaFromTab(meta, statesRef.current.get(meta.id)?.meta) });
|
||||
void loadSessionDataForTab(meta.id, true, "open-topic", { placeholderItems: prevItems });
|
||||
dispatchRuntimeStatusForTab(meta.id, meta);
|
||||
void loadSessionDataForTab(meta.id, true, "open-topic", { placeholderItems: prevItems })
|
||||
.then(() => reconcileTabRuntime(meta.id, { hydrateSessionData: false }))
|
||||
.catch(() => {});
|
||||
return meta;
|
||||
}, [confirmBackendActiveTab, dispatchTo, loadSessionDataForTab]);
|
||||
}, [confirmBackendActiveTab, dispatchRuntimeStatusForTab, dispatchTo, loadSessionDataForTab, reconcileTabRuntime]);
|
||||
|
||||
// Ensure a blank tab exists for the given scope — reuses an existing one
|
||||
// or creates a new tab, then loads its session data.
|
||||
const ensureBlankTab = useCallback(async (scope: string, workspaceRoot: string): Promise<TabMeta> => {
|
||||
const meta = await app.EnsureBlankTab(scope, workspaceRoot);
|
||||
const isNewTab = !statesRef.current.has(meta.id);
|
||||
setActiveTabId(meta.id);
|
||||
activeTabIdRef.current = meta.id;
|
||||
confirmBackendActiveTab(meta.id);
|
||||
dispatchTo(meta.id, { type: "optimistic_meta", meta: metaFromTab(meta, statesRef.current.get(meta.id)?.meta) });
|
||||
void loadSessionDataForTab(meta.id, !statesRef.current.has(meta.id), "open-topic");
|
||||
dispatchRuntimeStatusForTab(meta.id, meta);
|
||||
const load = loadSessionDataForTab(meta.id, isNewTab, "open-topic");
|
||||
if (isNewTab) void load.then(() => reconcileTabRuntime(meta.id, { hydrateSessionData: false })).catch(() => {});
|
||||
else void load;
|
||||
return meta;
|
||||
}, [confirmBackendActiveTab, dispatchTo, loadSessionDataForTab]);
|
||||
}, [confirmBackendActiveTab, dispatchRuntimeStatusForTab, dispatchTo, loadSessionDataForTab, reconcileTabRuntime]);
|
||||
|
||||
const ensureBlankSurface = useCallback(async (scope: string, workspaceRoot: string): Promise<TabMeta> => {
|
||||
const meta = await app.EnsureBlankSurface(scope, workspaceRoot);
|
||||
@@ -1901,9 +1995,12 @@ export function useController() {
|
||||
activeTabIdRef.current = meta.id;
|
||||
confirmBackendActiveTab(meta.id);
|
||||
dispatchTo(meta.id, { type: "optimistic_meta", meta: metaFromTab(meta, statesRef.current.get(meta.id)?.meta) });
|
||||
void loadSessionDataForTab(meta.id, true, "open-topic");
|
||||
dispatchRuntimeStatusForTab(meta.id, meta);
|
||||
void loadSessionDataForTab(meta.id, true, "open-topic")
|
||||
.then(() => reconcileTabRuntime(meta.id, { hydrateSessionData: false }))
|
||||
.catch(() => {});
|
||||
return meta;
|
||||
}, [confirmBackendActiveTab, dispatchTo, loadSessionDataForTab]);
|
||||
}, [confirmBackendActiveTab, dispatchRuntimeStatusForTab, dispatchTo, loadSessionDataForTab, reconcileTabRuntime]);
|
||||
|
||||
const closeTab = useCallback(async (tabId: string) => {
|
||||
try {
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
import { useEffect } from "react";
|
||||
|
||||
/**
|
||||
* WailsWailsFlags mirrors the `window.wails.flags` object injected by the
|
||||
* Wails v2 runtime (see internal/frontend/runtime/desktop/main.js).
|
||||
*/
|
||||
interface WailsFlags {
|
||||
enableResize: boolean;
|
||||
resizeEdge: string | undefined;
|
||||
borderThickness: number;
|
||||
defaultCursor: string | null;
|
||||
cssDragProperty: string;
|
||||
cssDragValue: string;
|
||||
cssDropProperty: string;
|
||||
cssDropValue: string;
|
||||
shouldDrag: boolean;
|
||||
deferDragToMouseMove: boolean;
|
||||
disableScrollbarDrag: boolean;
|
||||
disableDefaultContextMenu: boolean;
|
||||
enableWailsDragAndDrop: boolean;
|
||||
}
|
||||
|
||||
interface WailsWindow {
|
||||
wails: {
|
||||
flags: WailsFlags;
|
||||
Callback: (msg: string) => void;
|
||||
EventsNotify: (msg: string) => void;
|
||||
};
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
wails?: WailsWindow["wails"];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* useWailsResizeFix
|
||||
*
|
||||
* Workaround for a Wails v2.12 frameless-window resize detection bug where
|
||||
* `window.outerWidth/outerHeight` (device-independent pixels) is compared
|
||||
* with `e.clientX/e.clientY` (CSS pixels). When WebView2 ZoomFactor ≠ 1
|
||||
* these coordinate spaces diverge, causing the right/bottom resize zone to
|
||||
* extend far inward or disappear entirely.
|
||||
*
|
||||
* This hook disables the built-in mousemove handler (which uses the wrong
|
||||
* coordinate space) and replaces it with one that uses `window.innerWidth`
|
||||
* and `window.innerHeight` — both in CSS pixels, matching `e.clientX/Y`.
|
||||
*
|
||||
* The Wails mousedown handler still works because it only reads
|
||||
* `window.wails.flags.resizeEdge`, which we continue to set here.
|
||||
*
|
||||
* Upstream fix: https://github.com/wailsapp/wails/issues/4590 (Wails v3
|
||||
* sidestepped by clamping zoom ≥ 1.0). Once Wails v2 ships a proper fix
|
||||
* this hook can be deleted.
|
||||
*
|
||||
* @example
|
||||
* // In App.tsx or any component mounted for the app's lifetime:
|
||||
* useWailsResizeFix(desktopPlatform === "windows");
|
||||
*/
|
||||
export function useWailsResizeFix(enabled: boolean): void {
|
||||
useEffect(() => {
|
||||
if (!enabled) return;
|
||||
const wails = window.wails;
|
||||
if (!wails) return; // not inside a Wails webview → no-op
|
||||
|
||||
const flags = wails.flags;
|
||||
const bt = flags.borderThickness ?? 6;
|
||||
const previousEnableResize = flags.enableResize;
|
||||
const previousResizeEdge = flags.resizeEdge;
|
||||
const previousCursor = document.documentElement.style.cursor;
|
||||
|
||||
// Restore the default cursor when we're done — memoize the initial value.
|
||||
const defaultCursor = previousCursor;
|
||||
|
||||
const onMouseMove = (e: MouseEvent) => {
|
||||
// Both operands in CSS pixels — the bug fix.
|
||||
const iw = window.innerWidth;
|
||||
const ih = window.innerHeight;
|
||||
const cx = e.clientX;
|
||||
const cy = e.clientY;
|
||||
|
||||
const right = iw - cx < bt;
|
||||
const left = cx < bt;
|
||||
const top = cy < bt;
|
||||
const bottom = ih - cy < bt;
|
||||
|
||||
let edge: string | undefined;
|
||||
if (right && bottom) edge = "se-resize";
|
||||
else if (left && bottom) edge = "sw-resize";
|
||||
else if (right && top) edge = "ne-resize";
|
||||
else if (left && top) edge = "nw-resize";
|
||||
else if (right) edge = "e-resize";
|
||||
else if (left) edge = "w-resize";
|
||||
else if (top) edge = "n-resize";
|
||||
else if (bottom) edge = "s-resize";
|
||||
|
||||
if (edge !== flags.resizeEdge) {
|
||||
flags.resizeEdge = edge;
|
||||
document.documentElement.style.cursor = edge ?? (defaultCursor || "");
|
||||
}
|
||||
};
|
||||
|
||||
// Disable Wails' built-in mousemove handler (the one that uses outerWidth).
|
||||
flags.enableResize = false;
|
||||
window.addEventListener("mousemove", onMouseMove);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("mousemove", onMouseMove);
|
||||
flags.enableResize = previousEnableResize;
|
||||
flags.resizeEdge = previousResizeEdge;
|
||||
document.documentElement.style.cursor = previousCursor;
|
||||
};
|
||||
}, [enabled]);
|
||||
}
|
||||
@@ -205,6 +205,7 @@ export const en = {
|
||||
"caps.mcpSummary": "{connected} connected · {failed} issues · {tools} tools",
|
||||
"caps.connectorsTab": "MCP servers",
|
||||
"caps.skillsTab": "Agent skills",
|
||||
"caps.pluginsTab": "Plugins",
|
||||
"caps.servers": "MCP servers",
|
||||
"caps.noServers": "No MCP servers configured. Add one with /mcp add.",
|
||||
"caps.availableServers": "Available MCPs",
|
||||
@@ -311,6 +312,62 @@ export const en = {
|
||||
"caps.trustedReadOnlyTitle": "Remembered as read-only for future planning and research",
|
||||
"caps.searchSkills": "Search skills…",
|
||||
"caps.noSkillMatches": "No matching skills.",
|
||||
"caps.pluginRefresh": "Refresh plugins",
|
||||
"caps.pluginInstallTitle": "Install plugin",
|
||||
"caps.pluginInstallHint": "Add a local plugin folder or install from a Git repository.",
|
||||
"caps.pluginInstallMethod": "Plugin install method",
|
||||
"caps.pluginInstallLocal": "Local folder",
|
||||
"caps.pluginInstallGit": "Git repository",
|
||||
"caps.pluginSource": "Plugin source",
|
||||
"caps.pluginGitSource": "Git repository URL",
|
||||
"caps.pluginSourcePlaceholder": "git:github.com/obra/superpowers",
|
||||
"caps.pluginChooseLocalFolder": "Choose plugin folder",
|
||||
"caps.pluginLocalFolder": "Local plugin folder",
|
||||
"caps.pluginNoLocalFolder": "No folder selected",
|
||||
"caps.pluginInstallName": "Install name (optional)",
|
||||
"caps.pluginInstallNamePlaceholder": "install name (optional)",
|
||||
"caps.pluginLink": "Developer mode: link source folder",
|
||||
"caps.pluginLinkHint": "Do not copy the plugin; use the selected folder directly. Useful for plugin debugging. Moving or deleting the folder breaks the plugin.",
|
||||
"caps.pluginReplace": "Overwrite same-name plugin",
|
||||
"caps.pluginReplaceHint": "When a plugin with the same name already exists, allow this source to replace it.",
|
||||
"caps.pluginPreview": "Preview",
|
||||
"caps.pluginInstall": "Install plugin",
|
||||
"caps.noPluginsTitle": "No plugins installed",
|
||||
"caps.noPluginsHint": "Install a plugin package from Git or a local path to add skills, hooks, and MCP servers through one managed unit.",
|
||||
"caps.installedPlugins": "Installed plugins",
|
||||
"caps.pluginsSummary": "{enabled} enabled · {total} installed · {issues} issues",
|
||||
"caps.pluginCounts": "{skills} skills · {hooks} hooks · {mcps} MCPs",
|
||||
"caps.pluginNoCapabilities": "No exported capabilities",
|
||||
"caps.pluginWarnings": "{count} warnings",
|
||||
"caps.pluginEnable": "Enable plugin",
|
||||
"caps.pluginDisable": "Disable plugin",
|
||||
"caps.pluginEnabled": "Enabled",
|
||||
"caps.pluginDisabled": "Disabled",
|
||||
"caps.pluginVersion": "Version",
|
||||
"caps.pluginRoot": "Install path",
|
||||
"caps.pluginUpdate": "Update",
|
||||
"caps.pluginDoctor": "Doctor",
|
||||
"caps.pluginRemove": "Remove plugin",
|
||||
"caps.pluginConfirmRemove": "Confirm remove",
|
||||
"caps.pluginPlanError": "Install check failed",
|
||||
"caps.pluginPlanReady": "Install plan",
|
||||
"caps.pluginAction": "Action",
|
||||
"caps.pluginPlanStatus": "Plugin operation: {status}",
|
||||
"caps.pluginPlanComplete": "Plugin operation complete",
|
||||
"caps.pluginPlanInstalled": "Plugin installed. Expand it below to see how to use the exported skills, hooks, and MCP servers.",
|
||||
"caps.pluginUsageTitle": "How to use",
|
||||
"caps.pluginUsageEnabledHint": "Enabled plugins load into new sessions. Use /skills to browse skills, invoke /<skill>, or ask naturally; hooks and MCP servers run through the normal session lifecycle.",
|
||||
"caps.pluginUsageDisabledHint": "Enable this plugin before its skills, hooks, or MCP servers can participate in sessions.",
|
||||
"caps.pluginNoCapabilityDetails": "No detailed capability inventory is available for this plugin.",
|
||||
"caps.pluginSkillsTitle": "Skills",
|
||||
"caps.pluginSkillsHint": "Browse with /skills, invoke directly, or ask for the workflow by name.",
|
||||
"caps.pluginHooksTitle": "Hooks",
|
||||
"caps.pluginHooksHint": "Hooks trigger automatically on matching lifecycle events.",
|
||||
"caps.pluginMCPTitle": "MCP servers",
|
||||
"caps.pluginMCPHint": "Tools from these servers are available through the normal MCP/tool flow.",
|
||||
"caps.pluginNoDescription": "No description",
|
||||
"caps.pluginHookNoTarget": "No hook target",
|
||||
"caps.pluginMCPNoTarget": "No launch target",
|
||||
"caps.sources": "Sources",
|
||||
"caps.sourcesSummaryActive": "{active} active",
|
||||
"caps.sourcesSummaryMissing": "{missing} missing",
|
||||
@@ -432,6 +489,9 @@ export const en = {
|
||||
"composer.pastedExpand": "Expand",
|
||||
"composer.pastedRemove": "Remove pasted text",
|
||||
"composer.removeImage": "Remove image",
|
||||
"imageViewer.clickToPreview": "Click to preview",
|
||||
"imageViewer.closePreview": "Close preview",
|
||||
"imageViewer.title": "Image preview",
|
||||
"composer.attachImageFailed": "Image paste failed",
|
||||
"composer.attachFileFailed": "File attach failed",
|
||||
"composer.attachDropFailed": "Dropped file attach failed",
|
||||
@@ -472,6 +532,12 @@ export const en = {
|
||||
"modelSwitcher.noMatches": "No matching models",
|
||||
"status.noModels": "no switchable models",
|
||||
"status.modelSwitchFailed": "Model switch failed: {err}",
|
||||
"status.effortSwitchBusy": "Reasoning effort cannot change yet. Stop the current answer, handle pending prompts, or wait for background jobs to finish.",
|
||||
"status.effortSwitchLeaseHeld": "This session is open in another Reasonix window or still running in the background. Close that window, stop the background run, or open a copy before changing effort.",
|
||||
"status.effortSwitchStarting": "This session is still starting. Try changing reasoning effort again in a moment.",
|
||||
"status.effortSwitchStartupFailed": "This session did not start, so reasoning effort cannot change: {err}",
|
||||
"status.effortSwitchRetry": "The current session changed while switching reasoning effort. Try once more.",
|
||||
"status.effortSwitchFailed": "Reasoning effort switch failed: {err}",
|
||||
"status.tokenModeSwitchFailed": "Token saver switch failed: {err}",
|
||||
"status.effort": "effort {level}",
|
||||
"status.effortTitle": "Reasoning effort",
|
||||
@@ -671,6 +737,18 @@ export const en = {
|
||||
"history.historySessionActions": "History session actions",
|
||||
"history.trashActions": "Trash actions",
|
||||
|
||||
// session recovery
|
||||
"recovery.branch": "Conflict copy",
|
||||
"recovery.badge": "Copy",
|
||||
"recovery.open": "Open conflict copy",
|
||||
"recovery.toast": "A save conflict was detected, so a conflict copy was kept for {title}.",
|
||||
"recovery.failedLease": "The conflict copy is in use by another runtime, so this tab did not switch to it.",
|
||||
"recovery.failedUnavailable": "The conflict copy could not be opened, so this tab stayed on the original session.",
|
||||
"recovery.banner": "This session is a save-conflict copy. Compare it with the original before continuing.",
|
||||
"recovery.reason": "Reason: {reason}",
|
||||
"recovery.digest": "Digest: {digest}",
|
||||
"recovery.parent": "Parent: {parent}",
|
||||
|
||||
// project tree
|
||||
"projectTree.workspaceTitle": "Projects",
|
||||
"projectTree.pinnedTitle": "Pinned",
|
||||
@@ -685,6 +763,7 @@ export const en = {
|
||||
"projectTree.renameProjectWorkbench": "Rename project",
|
||||
"projectTree.projectHistory": "View project history",
|
||||
"projectTree.justNow": "just now",
|
||||
"projectTree.previously": "previously",
|
||||
"projectTree.running": "responding",
|
||||
"projectTree.status.thinking": "thinking",
|
||||
"projectTree.status.streaming": "responding",
|
||||
@@ -844,6 +923,7 @@ export const en = {
|
||||
"settings.tab.capabilities": "MCP & Skills",
|
||||
"settings.tab.mcp": "MCP & Tools",
|
||||
"settings.tab.skills": "Skills",
|
||||
"settings.tab.plugins": "Plugins",
|
||||
"settings.tab.memory": "Memory",
|
||||
"settings.tab.hooks": "Hooks",
|
||||
"settings.tab.shortcuts": "Shortcuts",
|
||||
@@ -859,6 +939,7 @@ export const en = {
|
||||
"settings.tabSub.capabilities": "Servers, tools & skills",
|
||||
"settings.tabSub.mcp": "Servers & tools",
|
||||
"settings.tabSub.skills": "Sources & enablement",
|
||||
"settings.tabSub.plugins": "Packages & diagnostics",
|
||||
"settings.tabSub.memory": "Project & global",
|
||||
"settings.tabSub.hooks": "Shell automation",
|
||||
"settings.tabSub.shortcuts": "Keys & help",
|
||||
@@ -940,6 +1021,10 @@ export const en = {
|
||||
"settings.subagentEffort": "Subagent effort",
|
||||
"settings.subagentEffortDefault": "auto (provider default)",
|
||||
"settings.subagentHint": "Applies to task and runAs=subagent skills unless a tool call or skill-specific config overrides it.",
|
||||
"settings.subagentDepth": "Subagent nesting depth",
|
||||
"settings.subagentDepthHint": "Controls whether a first-layer subagent may dispatch one more reviewer or worker subagent.",
|
||||
"settings.subagentDepthOne": "1 layer",
|
||||
"settings.subagentDepthTwo": "2 layers",
|
||||
"settings.defaultToolApprovalMode": "New session approval",
|
||||
"settings.defaultToolApprovalModeHint": "Saved to your global settings file and used only for new sessions; switching or restoring an existing session will not overwrite its Ask / Auto / Yolo state.",
|
||||
"settings.defaultToolApprovalMode.ask": "Ask",
|
||||
@@ -962,7 +1047,7 @@ export const en = {
|
||||
"settings.coldResumePrune.on": "On",
|
||||
"settings.coldResumePrune.off": "Off",
|
||||
"settings.reasoningLanguage": "Thinking language",
|
||||
"settings.reasoningLanguageHint": "Affects visible reasoning only. Auto follows the conversation language without injecting a language instruction.",
|
||||
"settings.reasoningLanguageHint": "Affects visible reasoning only. Auto anchors clear Chinese prompts; referenced context is ignored.",
|
||||
"settings.reasoningLanguage.auto": "Auto",
|
||||
"settings.reasoningLanguage.zh": "中文",
|
||||
"settings.reasoningLanguage.en": "English",
|
||||
@@ -1069,11 +1154,76 @@ export const en = {
|
||||
"settings.toggleOff": "Off",
|
||||
"settings.botSetup": "Bot entry",
|
||||
"settings.botSetupHint": "Enable the entry, then connect the chat channels you need.",
|
||||
"settings.botConnectPhoneTitle": "Add IM bot",
|
||||
"settings.botConnectPhoneSubtitle": "Manual setup for QQ, QR connection for Feishu, Lark, and WeChat.",
|
||||
"settings.botGateway": "Bot gateway",
|
||||
"settings.botGatewayHint": "Manage the IM bot gateway, model, and runtime parameters from the desktop app.",
|
||||
"settings.botGatewayHint": "Manage the IM bot gateway master switch and connection status from the desktop app.",
|
||||
"settings.botBrowserPreviewWarning": "This is a browser preview. It saves mock settings only and does not write local config or start the QQ/IM bot gateway. Configure it in the desktop app or CLI before testing from QQ.",
|
||||
"settings.botSimpleEyebrow": "Guided setup",
|
||||
"settings.botSimpleTitle": "Mobile access and chat bots",
|
||||
"settings.botSimpleHint": "Scan from a phone to control the current workspace. For longer-lived access from chat apps, pick a Bot Channel and configure it.",
|
||||
"settings.botMobileRemoteEyebrow": "Separate entry",
|
||||
"settings.botMobileRemoteTitle": "Mobile remote control",
|
||||
"settings.botMobileRemoteHint": "Scan or copy a link to open the current workspace control entry from a mobile device. This is configured separately from IM bot channels.",
|
||||
"settings.botMobileRemoteControlApi": "Control link",
|
||||
"settings.botMobileRemoteDisabled": "Enable it to generate the QR code and link",
|
||||
"settings.botMobileRemoteEnableFirst": "Enable to generate QR",
|
||||
"settings.botMobileRemoteReady": "Waiting for phone connection",
|
||||
"settings.botMobileRemoteWaiting": "Connect by phone scan",
|
||||
"settings.botMobileRemoteLoopbackNotice": "The control API is still bound to local loopback, so this is for local preview for now. Cross-device relay can reuse this entry later.",
|
||||
"settings.botMobileRemoteCopyLink": "Copy link",
|
||||
"settings.botMobileRemoteCopied": "Copied",
|
||||
"settings.botMobileRemoteCopyFailed": "Copy failed",
|
||||
"settings.botChannelEntryTitle": "Use Bot Channel",
|
||||
"settings.botChannelEntryHint": "Connect chat bots for longer mobile sessions.",
|
||||
"settings.botManageBots": "Bot management",
|
||||
"settings.botManageBotsHint": "Choose a channel on the left. If it is not configured, scan or add keys; once configured, manage that bot's runtime settings on the right.",
|
||||
"settings.botBackToOverview": "Back to entry",
|
||||
"settings.botChannelOpenConfigured": "Configured. You can continue editing settings.",
|
||||
"settings.botChannelNeedsSetup": "Not configured",
|
||||
"settings.botChannelTabsLabel": "Bot channels",
|
||||
"settings.botChannelSetupTitle": "Configure {provider}",
|
||||
"settings.botChannelSetupHint": "Before setup, only credentials or scan binding are needed. After it succeeds, this becomes the bot's settings page.",
|
||||
"settings.botSetupStatus": "Bot setup status",
|
||||
"settings.botSetupStatusConnected": "{online}/{total} online",
|
||||
"settings.botSetupStatusAccessTrusted": "Trusted list {count}",
|
||||
"settings.botSetupStatusAccessEveryone": "Everyone allowed",
|
||||
"settings.botSetupStatusAccessPerBot": "{configured}/{total} bots configured",
|
||||
"settings.botMode": "Bot settings mode",
|
||||
"settings.botModeSimple": "Simple",
|
||||
"settings.botModeAdvanced": "Advanced",
|
||||
"settings.botShowAdvancedSettings": "Advanced settings",
|
||||
"settings.botHideAdvancedSettings": "Hide advanced settings",
|
||||
"settings.botAdvancedSettingsHint": "Lower-frequency controls: routes, queue details, and bot echo filtering.",
|
||||
"settings.botShowAddBot": "Add channel",
|
||||
"settings.botStepConnectTitle": "Connect bots",
|
||||
"settings.botStepConnectHint": "Manual keys for QQ; scan a QR code for Feishu, Lark, and WeChat.",
|
||||
"settings.botStepConnectEmpty": "Not connected yet",
|
||||
"settings.botSharedAllowlistHint": "Every channel shares the trusted list and pairing settings from step 2. Edit once and it applies everywhere.",
|
||||
"settings.botSharedAllowlistGo": "Edit in step 2",
|
||||
"settings.botGlobalAllowlist": "Legacy global allowlist",
|
||||
"settings.botGlobalAllowlistHint": "Compatibility for older configs; new bots should set access in their own detail panel.",
|
||||
"settings.botAccessShowAllPlatforms": "Show all platforms",
|
||||
"settings.botAccessShowConnectedOnly": "Show connected platforms only",
|
||||
"settings.botListUsers": "Users",
|
||||
"settings.botListGroups": "Groups",
|
||||
"settings.botListApprovers": "Approvers",
|
||||
"settings.botListAdmins": "Admins",
|
||||
"settings.botPlatformFeishuLark": "Feishu / Lark",
|
||||
"settings.botQueueDropLabel": "When the queue is full",
|
||||
"settings.botQueueDropHint": "How overflow messages are handled once the queue hits its cap.",
|
||||
"settings.botPairingDetailHint": "The pairing switch lives in step 2 (Who can use it). Tune only the code lifetime and pending cap here.",
|
||||
"settings.botSimpleAccessTitle": "Who can use it",
|
||||
"settings.botSimpleAccessTrustedSummary": "Trusted list is active with {count} member or group entries.",
|
||||
"settings.botSimpleAccessEveryoneSummary": "Everyone can use it right now. Best for short tests only.",
|
||||
"settings.botAccessTrusted": "Trusted list",
|
||||
"settings.botAccessTrustedHint": "Only listed users or groups can call the bot.",
|
||||
"settings.botAccessEveryone": "Everyone",
|
||||
"settings.botAccessEveryoneHint": "Convenient for quick testing, but not recommended long term.",
|
||||
"settings.botAccessPairing": "Pair private chats",
|
||||
"settings.botAccessPairingHint": "Unknown direct messages get a code and wait for local approval.",
|
||||
"settings.botRoleAccess": "Admins and approvers",
|
||||
"settings.botRoleAccessHint": "Optional. Leave this empty unless remote approval or management is needed.",
|
||||
"settings.botQueueModeSimple": "When a new message arrives",
|
||||
"settings.botQueueModeSimpleHint": "Choose what happens when the bot is already handling a previous message.",
|
||||
"settings.saveBotSettings": "Save bot settings",
|
||||
"settings.botEnableBot": "Enable bot",
|
||||
"settings.botGatewayMasterSwitch": "Master switch",
|
||||
@@ -1084,8 +1234,48 @@ export const en = {
|
||||
"settings.botModelHint": "Leave empty to use the default model.",
|
||||
"settings.botModelDefault": "Use default model",
|
||||
"settings.botRuntime": "Runtime limits",
|
||||
"settings.botRuntimeHint": "Gateway-level limits that control how far each bot task can run.",
|
||||
"settings.botMaxSteps": "Max steps",
|
||||
"settings.botDebounceMs": "Debounce ms",
|
||||
"settings.botQueueCap": "Queue cap",
|
||||
"settings.botQueueMode.steer": "Steer running turn",
|
||||
"settings.botQueueMode.followup": "Follow up",
|
||||
"settings.botQueueMode.collect": "Collect",
|
||||
"settings.botQueueMode.interrupt": "Interrupt",
|
||||
"settings.botQueueDrop.summarize": "Summarize overflow",
|
||||
"settings.botQueueDrop.old": "Drop oldest",
|
||||
"settings.botQueueDrop.new": "Drop newest",
|
||||
"settings.botIgnoreSelfMessages": "Ignore bot echoes",
|
||||
"settings.botIgnoreSelfMessagesHint": "Prevents the gateway from reacting to messages sent by configured bot identities.",
|
||||
"settings.botSelfUserIds": "Bot self IDs",
|
||||
"settings.botSelfUserIdsHint": "Optional user/open IDs for each platform, used to filter bot echo messages.",
|
||||
"settings.botPairing": "Private chat pairing",
|
||||
"settings.botPairingTTL": "TTL minutes",
|
||||
"settings.botPairingMaxPending": "Max pending",
|
||||
"settings.botControlApi": "Local control API",
|
||||
"settings.botControlApiHint": "Loopback HTTP API for status and controlled outbound sends; requires a Bearer token from the named env var.",
|
||||
"settings.botControlAddr": "Control address",
|
||||
"settings.botControlTokenEnv": "Token env",
|
||||
"settings.botGatewayDefaults": "Gateway runtime settings",
|
||||
"settings.botGatewayDefaultsHint": "Tune queueing, pairing, and self-message filtering without editing TOML.",
|
||||
"settings.botRoutes": "Bind chats to projects",
|
||||
"settings.botRoutesHint": "Pin a group, contact, or thread to a workspace, model, or approval mode.",
|
||||
"settings.botRoutesEmpty": "No bindings yet. Add one to bind a chat, user, or thread to a project or model.",
|
||||
"settings.botRouteTitle": "Route {n}",
|
||||
"settings.botAddRoute": "Add route",
|
||||
"settings.botRouteConnection": "Bot source",
|
||||
"settings.botRouteAny": "Any",
|
||||
"settings.botRoutePlatform": "Platform",
|
||||
"settings.botRouteChatType": "Chat type",
|
||||
"settings.botRouteChatType.any": "Any",
|
||||
"settings.botRouteChatType.dm": "DM",
|
||||
"settings.botRouteChatType.group": "Group",
|
||||
"settings.botRouteChatType.guild": "Guild",
|
||||
"settings.botRouteChatType.direct": "Direct",
|
||||
"settings.botRouteChatType.thread": "Thread",
|
||||
"settings.botRouteChatId": "Group/chat ID",
|
||||
"settings.botRouteUserId": "Contact ID",
|
||||
"settings.botRouteThreadId": "Thread ID",
|
||||
"settings.botChannels": "Connect channels",
|
||||
"settings.botChannelsHint": "Pick a channel; QQ uses manual setup, while Feishu, Lark, and WeChat connect by QR.",
|
||||
"settings.botQQHint": "QQ official Bot API v2.",
|
||||
@@ -1140,8 +1330,7 @@ export const en = {
|
||||
"settings.botInstallLarkHint": "Scan to create a Lark PersonalAgent.",
|
||||
"settings.botInstallWeixinHint": "Scan WeChat and save a local token.",
|
||||
"settings.botConnectedBots": "Connected bots",
|
||||
"settings.botConnectedBotsSummary": "{online}/{total} online",
|
||||
"settings.botConnectionsEmpty": "No bot channel is connected yet.",
|
||||
"settings.botSelectBotHint": "Select a bot on the left to configure it.",
|
||||
"settings.botConnectionColumnChannel": "Channel",
|
||||
"settings.botConnectionColumnName": "Bot",
|
||||
"settings.botConnectionColumnRemote": "Remote ID",
|
||||
@@ -1159,16 +1348,17 @@ export const en = {
|
||||
"settings.botConnectionConnected": "Connected",
|
||||
"settings.botConnectionConfigured": "Configured",
|
||||
"settings.botConnectionDisconnected": "Disconnected",
|
||||
"settings.botManage": "Advanced settings",
|
||||
"settings.botManage": "Edit config",
|
||||
"settings.deleteBot": "Delete bot",
|
||||
"settings.deleteBotShort": "Delete",
|
||||
"settings.confirmDeleteBot": "Confirm delete",
|
||||
"settings.deleteBotHint": "Removes this bot from connected bots; saved secrets are not cleared automatically.",
|
||||
"settings.botChannelModel": "Model",
|
||||
"settings.botChannelModelHint": "Leave empty to use the bot default model.",
|
||||
"settings.botChannelModelAuto": "Auto",
|
||||
"settings.botChannelModelHint": "The model this bot uses when handling messages.",
|
||||
"settings.botChannelModelAuto": "Use current desktop model",
|
||||
"settings.botToolApprovalMode": "Tool approval",
|
||||
"settings.botToolApprovalModeHint": "Controls whether this channel asks before running tools; plan confirmation and ask questions still wait for a reply.",
|
||||
"settings.botToolApprovalMode.inherit": "Inherit",
|
||||
"settings.botToolApprovalMode.inherit": "Follow bot",
|
||||
"settings.botToolApprovalMode.ask": "Ask",
|
||||
"settings.botToolApprovalMode.auto": "Auto",
|
||||
"settings.botToolApprovalMode.yolo": "YOLO",
|
||||
@@ -1191,26 +1381,17 @@ export const en = {
|
||||
"settings.botDiagnosticReportFailed": "Failed to send diagnostic report.",
|
||||
"settings.botDiagnosticPrivacy": "Only sanitized connection metadata is included.",
|
||||
"settings.botAccessControl": "Access control",
|
||||
"settings.botAccessControlHint": "Whitelist is the default; switch to everyone for quick testing.",
|
||||
"settings.botAccessMode": "Who can use it",
|
||||
"settings.botAccessAll": "Everyone",
|
||||
"settings.botAccessWhitelist": "Whitelist only",
|
||||
"settings.botEditAllowlist": "Edit allowlist",
|
||||
"settings.botAllowlist": "Allowlist",
|
||||
"settings.botAllowlistHint": "Restrict which users or groups can invoke the bot.",
|
||||
"settings.botAllowlistEnabled": "Enable allowlist",
|
||||
"settings.botAllowAll": "Allow everyone",
|
||||
"settings.botAllowAllWarn": "When enabled, every incoming channel message can invoke the bot.",
|
||||
"settings.botQQAccessRequired": "Add a QQ user/group allowlist, or temporarily allow everyone in Advanced settings, before enabling QQ Bot.",
|
||||
"settings.botAllowlistEntries": "Users and groups",
|
||||
"settings.botQQAccessRequired": "Add QQ users/groups in step 2 (Who can use it), or temporarily allow everyone, before enabling QQ Bot.",
|
||||
"settings.botQQUsers": "QQ users",
|
||||
"settings.botQQGroups": "QQ groups",
|
||||
"settings.botFeishuUsers": "Feishu users",
|
||||
"settings.botFeishuGroups": "Feishu groups",
|
||||
"settings.botFeishuLarkUsers": "Feishu / Lark users",
|
||||
"settings.botFeishuLarkGroups": "Feishu / Lark groups",
|
||||
"settings.botWeixinUsers": "WeChat users",
|
||||
"settings.botWeixinGroups": "WeChat groups",
|
||||
"settings.botListPlaceholder": "One ID per line, or comma-separated",
|
||||
"settings.permissions": "Permissions",
|
||||
"settings.writerMode": "Writer mode",
|
||||
@@ -1222,17 +1403,23 @@ export const en = {
|
||||
"settings.yoloHint": "YOLO-approves approval-gated tool calls (writers, bash). Deny rules still block, and ask questions plus plan approvals still wait for you. Runtime-only — resets on relaunch.",
|
||||
"settings.addRule": "add {list} rule…",
|
||||
"settings.sandboxTitle": "Sandbox & workspace",
|
||||
"settings.sandboxBoundaryHint": "Sandbox write roots limit where approved Bash and file tools can write; they do not auto-approve commands. If Bash sandbox is enforced but no OS backend is available, shell commands are refused; switch Bash sandbox off only for trusted workspaces.",
|
||||
"settings.reloadSessionConfig": "Apply manual config changes",
|
||||
"settings.reloadSessionConfigHint": "Changes made in Settings apply automatically; use this only after manually editing config.toml or reasonix.toml.",
|
||||
"settings.shellInterpreter": "Shell interpreter",
|
||||
"settings.shellAuto": "auto (bash, else PowerShell on Windows)",
|
||||
"settings.shellAuto": "auto (bash first)",
|
||||
"settings.shellBash": "bash",
|
||||
"settings.shellPowershell": "PowerShell (Windows PowerShell)",
|
||||
"settings.shellPwsh": "pwsh (PowerShell 7+)",
|
||||
"settings.bashSandbox": "Bash sandbox",
|
||||
"settings.bashEnforce": "enforce (jail bash)",
|
||||
"settings.bashEnforce": "enforce (jail bash; fail closed)",
|
||||
"settings.bashOff": "off (run unconfined)",
|
||||
"settings.allowNetwork": "Allow network egress from sandboxed bash",
|
||||
"settings.workspaceRoot": "Workspace root",
|
||||
"settings.workspaceDefault": "(default: cwd)",
|
||||
"settings.effectiveWriteRoots": "Effective write roots",
|
||||
"settings.effectiveWriteRootsHint": "Expanded for the active workspace. Settings changes apply automatically; use the button above after manually editing config files.",
|
||||
"settings.noEffectiveWriteRoots": "No active write roots",
|
||||
"settings.appearance": "Appearance",
|
||||
"settings.theme": "Theme",
|
||||
"settings.themeStyle": "Visual style",
|
||||
@@ -1318,6 +1505,9 @@ export const en = {
|
||||
"settings.textSizeXLarge": "Extra large",
|
||||
"settings.textSizeXXLarge": "Huge",
|
||||
"settings.displayZoom": "Display zoom (restart required)",
|
||||
"settings.displayZoomDecrease": "Decrease display zoom",
|
||||
"settings.displayZoomIncrease": "Increase display zoom",
|
||||
"settings.displayZoomReset": "Reset display zoom to 100%",
|
||||
"settings.fontFamily": "Interface font",
|
||||
"settings.fontFamilySystem": "System",
|
||||
"settings.fontFamilyYaHei": "YaHei",
|
||||
@@ -1340,6 +1530,7 @@ export const en = {
|
||||
"settings.pageDesc.bots": "Configure QQ, Feishu, Lark, and WeChat bot channels, including each bot's model and runtime parameters.",
|
||||
"settings.pageDesc.mcp": "Manage MCP server connections and tool discovery.",
|
||||
"settings.pageDesc.skills": "Browse, enable, and manage agent skills and skill sources.",
|
||||
"settings.pageDesc.plugins": "Install and manage plugin packages that can provide skills, hooks, and MCP servers.",
|
||||
"settings.pageDesc.memory": "Manage preferences, project conventions, and instruction files Reasonix keeps referencing.",
|
||||
"settings.pageDesc.hooks": "Configure shell hooks around turns, tool calls, compaction, and session lifecycle.",
|
||||
"settings.pageDesc.shortcuts": "Customize keyboard shortcuts and keep the help sheet in sync.",
|
||||
@@ -1354,7 +1545,7 @@ export const en = {
|
||||
"settings.customProviders": "Custom providers",
|
||||
"settings.customProvidersHint": "OpenAI-compatible or proxy providers you add yourself. These can be edited, deleted, and updated with fetched model lists.",
|
||||
"settings.customProvidersEmpty": "No custom providers yet. Prefer the built-in access above for common services.",
|
||||
"settings.providerAccessEmptyHint": "Click “Add provider” to choose DeepSeek or a custom OpenAI-compatible access.",
|
||||
"settings.providerAccessEmptyHint": "Use Recommended preset to quickly add common providers, usually with only an API key. Use Custom provider for OpenAI-compatible or Anthropic-compatible endpoints.",
|
||||
"settings.builtinProviderBadge": "built-in",
|
||||
"settings.customProviderBadge": "custom",
|
||||
"settings.configureProvider": "Configure",
|
||||
@@ -1366,24 +1557,77 @@ export const en = {
|
||||
"settings.searchModels": "Search models...",
|
||||
"settings.noMatchingModels": "No matching models",
|
||||
"settings.addProvider.chooseTitle": "Add provider",
|
||||
"settings.addProvider.chooseHint": "Choose an official preset, or add a custom OpenAI-compatible access.",
|
||||
"settings.addProvider.officialChoice": "Official provider",
|
||||
"settings.addProvider.officialChoiceHint": "Choose from Reasonix presets for DeepSeek.",
|
||||
"settings.addProvider.chooseHint": "Choose a recommended preset, or add a custom provider manually.",
|
||||
"settings.addProvider.officialChoice": "Recommended preset",
|
||||
"settings.addProvider.officialChoiceHint": "Choose DeepSeek or curated custom-provider presets.",
|
||||
"settings.addProvider.customChoice": "Custom provider",
|
||||
"settings.addProvider.customChoiceHint": "Configure base_url, models, key env, and related parameters manually.",
|
||||
"settings.addProvider.officialTitle": "Add official provider",
|
||||
"settings.addProvider.officialHint": "Official presets use fixed endpoints and defaults; choose the provider and set a key.",
|
||||
"settings.addProvider.officialHint": "Recommended presets fill endpoints, models, vision, and reasoning defaults; usually only the key is needed.",
|
||||
"settings.addProvider.customTitle": "Add custom provider",
|
||||
"settings.addProvider.customHint": "Use this for proxies, aggregators, or other OpenAI-compatible services.",
|
||||
"settings.addProvider.official.deepseek": "DeepSeek Official",
|
||||
"settings.addProvider.official.deepseekDesc": "Official API key access with DeepSeek V4 Flash / Pro.",
|
||||
"settings.addProvider.preset.longcatOpenAIDesc": "LongCat Platform OpenAI-compatible endpoint with LongCat-2.0 default.",
|
||||
"settings.addProvider.preset.longcatAnthropicDesc": "LongCat Platform Anthropic-compatible Messages endpoint using Bearer auth.",
|
||||
"settings.addProvider.preset.kimiCnDesc": "Kimi China API via Moonshot CN endpoint with K2 vision models.",
|
||||
"settings.addProvider.preset.kimiGlobalDesc": "Kimi international API via Moonshot global endpoint with K2 vision models.",
|
||||
"settings.addProvider.preset.kimiCodingPlanDesc": "Kimi Coding Plan via the dedicated Anthropic-compatible endpoint; not a CN/Global variant.",
|
||||
"settings.addProvider.preset.mimoApiDesc": "Xiaomi MiMo direct API for v2.5 pay-as-you-go models.",
|
||||
"settings.addProvider.preset.mimoAnthropicDesc": "Xiaomi MiMo direct Anthropic-compatible endpoint with MiMo v2.5 models.",
|
||||
"settings.addProvider.preset.mimoTokenPlanCnDesc": "Xiaomi MiMo token-plan China endpoint.",
|
||||
"settings.addProvider.preset.mimoTokenPlanCnAnthropicDesc": "Xiaomi MiMo token-plan China Anthropic-compatible endpoint.",
|
||||
"settings.addProvider.preset.mimoTokenPlanSgpDesc": "Xiaomi MiMo token-plan Singapore endpoint.",
|
||||
"settings.addProvider.preset.mimoTokenPlanSgpAnthropicDesc": "Xiaomi MiMo token-plan Singapore Anthropic-compatible endpoint.",
|
||||
"settings.addProvider.preset.mimoTokenPlanAmsDesc": "Xiaomi MiMo token-plan Amsterdam endpoint.",
|
||||
"settings.addProvider.preset.mimoTokenPlanAmsAnthropicDesc": "Xiaomi MiMo token-plan Amsterdam Anthropic-compatible endpoint.",
|
||||
"settings.addProvider.preset.minimaxCnApiDesc": "MiniMax China OpenAI-compatible M-series API endpoint.",
|
||||
"settings.addProvider.preset.minimaxGlobalApiDesc": "MiniMax international OpenAI-compatible M-series API endpoint.",
|
||||
"settings.addProvider.preset.minimaxCnAnthropicDesc": "MiniMax China Anthropic-compatible M-series endpoint.",
|
||||
"settings.addProvider.preset.minimaxGlobalAnthropicDesc": "MiniMax international Anthropic-compatible endpoint using Bearer auth.",
|
||||
"settings.addProvider.preset.glmCnDesc": "Zhipu GLM China API with GLM thinking controls.",
|
||||
"settings.addProvider.preset.zaiGlobalDesc": "Z.AI international GLM API.",
|
||||
"settings.addProvider.preset.glmCodingPlanCnDesc": "Zhipu GLM China coding-plan endpoint with GLM-5.2 default.",
|
||||
"settings.addProvider.preset.glmCodingPlanCnAnthropicDesc": "Zhipu GLM China Anthropic-compatible coding-plan endpoint with GLM-5.2[1m] default.",
|
||||
"settings.addProvider.preset.zaiCodingPlanGlobalDesc": "Z.AI international coding-plan endpoint with GLM-5.2 default.",
|
||||
"settings.addProvider.preset.zaiCodingPlanGlobalAnthropicDesc": "Z.AI international Anthropic-compatible coding-plan endpoint with GLM-5.2[1m] default.",
|
||||
"settings.addProvider.preset.opencodeGoDesc": "OpenCode Go relay with mixed-model capability overrides.",
|
||||
"settings.addProvider.preset.opencodeGoAnthropicDesc": "OpenCode Go Anthropic route for Qwen and MiniMax models.",
|
||||
"settings.addProvider.preset.opencodeZenAnthropicDesc": "OpenCode Zen Anthropic route for Claude and Qwen models.",
|
||||
"settings.addProvider.preset.qwenCnDesc": "Qwen/DashScope China standard API endpoint.",
|
||||
"settings.addProvider.preset.qwenGlobalDesc": "Qwen/DashScope international standard API endpoint.",
|
||||
"settings.addProvider.preset.qwenCodingPlanCnDesc": "Qwen Coding Plan China OpenAI-compatible endpoint with qwen3.7-plus default.",
|
||||
"settings.addProvider.preset.qwenCodingPlanCnAnthropicDesc": "Qwen Coding Plan China Anthropic-compatible endpoint with qwen3.7-plus default.",
|
||||
"settings.addProvider.preset.qwenCodingPlanGlobalDesc": "Qwen Coding Plan international OpenAI-compatible endpoint with qwen3.7-plus default.",
|
||||
"settings.addProvider.preset.qwenCodingPlanGlobalAnthropicDesc": "Qwen Coding Plan international Anthropic-compatible endpoint with qwen3.7-plus default.",
|
||||
"settings.addProvider.preset.stepfunDesc": "StepFun coding-plan OpenAI-compatible endpoint with step-3.7-flash default.",
|
||||
"settings.addProvider.preset.stepfunAnthropicDesc": "StepFun coding-plan Anthropic-compatible endpoint with step-3.7-flash default.",
|
||||
"settings.addProvider.preset.novitaDesc": "NovitaAI multi-model gateway with Kimi, MiniMax, GLM, DeepSeek, and Qwen.",
|
||||
"settings.addProvider.preset.gmiDesc": "GMI Cloud direct multi-model gateway with GLM, DeepSeek, Kimi, Gemini, Claude, and GPT.",
|
||||
"settings.addProvider.preset.vercelAiGatewayDesc": "Vercel AI Gateway through Anthropic-compatible Messages API.",
|
||||
"settings.addProvider.preset.huggingfaceDesc": "HuggingFace Inference Router with Qwen and DeepSeek defaults.",
|
||||
"settings.addProvider.preset.nvidiaDesc": "NVIDIA NIM accelerated inference endpoint with Nemotron defaults.",
|
||||
"settings.addProvider.preset.kilocodeDesc": "Kilo Code gateway with kilo/auto routing defaults.",
|
||||
"settings.addProvider.preset.ollamaCloudDesc": "Hosted Ollama Cloud endpoint with none-to-max reasoning effort.",
|
||||
"settings.addProvider.addedBadge": "Added",
|
||||
"settings.addProvider.modifiedBadge": "Modified",
|
||||
"settings.addProvider.nameConflictBadge": "Name taken",
|
||||
"settings.addProvider.similarExistingBadge": "Similar setup",
|
||||
"settings.addProvider.confirm": "Add provider",
|
||||
"settings.addProvider.alreadyAddedAction": "Already added",
|
||||
"settings.addProvider.nameConflictAction": "Resolve name first",
|
||||
"settings.addProvider.viewPresetProvider": "View config",
|
||||
"settings.addProvider.viewConflictProvider": "View same-name config",
|
||||
"settings.addProvider.resetPreset": "Reset to preset",
|
||||
"settings.addProvider.confirmResetPreset": "Confirm reset",
|
||||
"settings.customProviderName": "Custom provider name",
|
||||
"settings.customProviderNamePlaceholder": "",
|
||||
"settings.providerNamePlaceholder": "Provider name (e.g. deepseek-proxy)",
|
||||
"settings.providerProtocol": "Connection protocol",
|
||||
"settings.providerProtocolOpenAI": "OpenAI-compatible",
|
||||
"settings.providerProtocolOpenAIHint": "For proxies, aggregators, and self-hosted services compatible with the OpenAI API.",
|
||||
"settings.providerProtocolAnthropic": "Anthropic-compatible",
|
||||
"settings.providerProtocolAnthropicHint": "For Claude-style Messages API services such as some coding-plan endpoints.",
|
||||
"settings.providerBaseUrlLabel": "API address",
|
||||
"settings.providerChatUrlLabel": "Chat request URL",
|
||||
"settings.providerChatUrlPlaceholder": "e.g. https://api.openai.com/v1/chat/completions",
|
||||
@@ -1406,6 +1650,12 @@ export const en = {
|
||||
"settings.providerHeaders": "Extra request headers (optional)",
|
||||
"settings.providerHeadersPlaceholder": "HTTP-Referer: https://example.com\nX-Title: Reasonix",
|
||||
"settings.providerHeadersHint": "For gateways such as OpenRouter that require site headers. Use one Header: value per line; the API key still belongs in the key field above.",
|
||||
"settings.providerExtraBody": "Extra request body (optional)",
|
||||
"settings.providerExtraBodyPlaceholder": "{\n \"enable_thinking\": true\n}",
|
||||
"settings.providerExtraBodyHint": "Top-level JSON fields merged into the chat request body. Core fields such as model, messages, tools, stream, and thinking stay controlled by Reasonix.",
|
||||
"settings.providerExtraBodyError": "Enter a JSON object without null values, for example { \"enable_thinking\": true }.",
|
||||
"settings.providerAuthHeader": "Use Authorization: Bearer for this key",
|
||||
"settings.providerAuthHeaderHint": "For Anthropic-compatible gateways such as MiniMax Global or Vercel AI Gateway that do not use x-api-key.",
|
||||
"settings.fetchingModels": "Refreshing…",
|
||||
"settings.fetchModelsSuccess": "Fetched {n} models",
|
||||
"settings.fetchModelsManualFallbackAuth": "The model discovery endpoint rejected the key or permission. Check the API key; if this service does not expose model discovery, enter model IDs manually below.",
|
||||
@@ -1425,10 +1675,16 @@ export const en = {
|
||||
"settings.newProviderDraftName": "new provider",
|
||||
"settings.reasoningProtocol": "Model capability mode",
|
||||
"settings.reasoningProtocol.auto": "Auto-detect (recommended)",
|
||||
"settings.reasoningProtocol.deepseek": "DeepSeek thinking",
|
||||
"settings.reasoningProtocol.openai": "OpenAI reasoning",
|
||||
"settings.reasoningProtocol.deepseek": "DeepSeek thinking (thinking + depth)",
|
||||
"settings.reasoningProtocol.openai": "OpenAI reasoning (reasoning_effort)",
|
||||
"settings.reasoningProtocol.none": "Plain chat (no reasoning fields)",
|
||||
"settings.reasoningProtocolHint": "Auto-detect is best for most gateways. Switch only when a proxy is incompatible or the model documentation requires a specific reasoning shape.",
|
||||
"settings.thinkingMode": "Thinking override",
|
||||
"settings.thinkingMode.auto": "Auto (provider default)",
|
||||
"settings.thinkingMode.enabled": "Enabled",
|
||||
"settings.thinkingMode.disabled": "Disabled",
|
||||
"settings.thinkingMode.adaptive": "Adaptive (self-adjusting)",
|
||||
"settings.thinkingModeHint": "Provider-specific override. Use enabled/disabled for compatible gateways, adaptive only for providers that document it.",
|
||||
"settings.updateKey": "update {env} (saved globally)",
|
||||
"settings.updateKeyAction": "Update key",
|
||||
"settings.keyStatus": "Key",
|
||||
@@ -1543,6 +1799,8 @@ export const en = {
|
||||
"msg.imageAttachment": "Image",
|
||||
"msg.workspaceReference": "Workspace reference",
|
||||
"msg.folderReference": "Folder · Workspace reference",
|
||||
"msg.pastedExpandTooltip": "Expand pasted text",
|
||||
"msg.pastedCollapseTooltip": "Collapse pasted text",
|
||||
"turnActions.summary": "Summary",
|
||||
"turnActions.rewind": "Rewind",
|
||||
"transcript.jumpToBottom": "Jump to bottom",
|
||||
@@ -1624,7 +1882,7 @@ export const en = {
|
||||
"settings.telemetryLabel": "Anonymous usage ping",
|
||||
"settings.telemetryHint": "On launch, send a random install id plus version and OS to count active installs, and — if the app crashed on a previous run — resend that crash report. Never includes conversations, keys, or file contents.",
|
||||
"settings.metricsLabel": "Share aggregate quality metrics",
|
||||
"settings.metricsHint": "On by default. Sends anonymous turn-end counts, settings preference snapshots, and Memory v5 aggregate buckets such as injection on/off, size buckets, IR count buckets, and memory graph size buckets. Includes the random install id used to de-duplicate DAU. Buckets may include normalized custom provider and model names — never conversations, prompts, keys, paths, base URLs, memory text, tool outputs, or file contents.",
|
||||
"settings.metricsHint": "On by default. Sends anonymous turn-end counts, updater error categories, settings preference snapshots, and Memory v5 aggregate buckets such as injection on/off, size buckets, IR count buckets, and memory graph size buckets. Includes the random install id used to de-duplicate DAU. Buckets may include normalized custom provider and model names — never conversations, prompts, keys, paths, base URLs, memory text, tool outputs, or file contents.",
|
||||
"updater.currentVersion": "Current version: {v}",
|
||||
"updater.channelLabel": "Update channel: {channel}",
|
||||
"updater.checkButton": "Check for updates",
|
||||
|
||||
@@ -134,6 +134,7 @@ export const zhTW: Record<DictKey, string> = {
|
||||
"caps.summary": "{connected} 個已連線 · {failed} 個異常 · {skills} 個技能",
|
||||
"caps.connectorsTab": "MCP 伺服器",
|
||||
"caps.skillsTab": "Agent 技能",
|
||||
"caps.pluginsTab": "插件",
|
||||
"caps.servers": "MCP 伺服器",
|
||||
"caps.noServers": "未設定 MCP 伺服器。用 /mcp add 新增。",
|
||||
"caps.failureTitle": "{failed} 個 MCP 啟動異常",
|
||||
@@ -227,6 +228,62 @@ export const zhTW: Record<DictKey, string> = {
|
||||
"caps.trustedReadOnlyTitle": "已記住為之後規劃和唯讀 research 可用的唯讀工具",
|
||||
"caps.searchSkills": "搜尋技能…",
|
||||
"caps.noSkillMatches": "沒有匹配的技能。",
|
||||
"caps.pluginRefresh": "重新整理插件",
|
||||
"caps.pluginInstallTitle": "安裝插件",
|
||||
"caps.pluginInstallHint": "選擇本機插件目錄,或從 Git 倉庫安裝。",
|
||||
"caps.pluginInstallMethod": "插件安裝方式",
|
||||
"caps.pluginInstallLocal": "本機目錄",
|
||||
"caps.pluginInstallGit": "Git 倉庫",
|
||||
"caps.pluginSource": "插件來源",
|
||||
"caps.pluginGitSource": "Git 倉庫地址",
|
||||
"caps.pluginSourcePlaceholder": "git:github.com/obra/superpowers",
|
||||
"caps.pluginChooseLocalFolder": "選擇插件目錄",
|
||||
"caps.pluginLocalFolder": "本機插件目錄",
|
||||
"caps.pluginNoLocalFolder": "尚未選擇目錄",
|
||||
"caps.pluginInstallName": "安裝名稱(可選)",
|
||||
"caps.pluginInstallNamePlaceholder": "安裝名稱(可選)",
|
||||
"caps.pluginLink": "開發模式:連結來源目錄",
|
||||
"caps.pluginLinkHint": "不複製插件,直接使用所選目錄;適合調試插件。移動或刪除該目錄會導致插件失效。",
|
||||
"caps.pluginReplace": "覆蓋同名插件",
|
||||
"caps.pluginReplaceHint": "插件名已存在時,允許用目前來源替換它。",
|
||||
"caps.pluginPreview": "預檢",
|
||||
"caps.pluginInstall": "安裝插件",
|
||||
"caps.noPluginsTitle": "尚未安裝插件",
|
||||
"caps.noPluginsHint": "從 Git 或本機路徑安裝插件包,把技能、hooks 和 MCP 伺服器作為一個統一單元管理。",
|
||||
"caps.installedPlugins": "已安裝插件",
|
||||
"caps.pluginsSummary": "{enabled} 個啟用 · 共 {total} 個 · {issues} 個異常",
|
||||
"caps.pluginCounts": "{skills} 技能 · {hooks} hooks · {mcps} MCP",
|
||||
"caps.pluginNoCapabilities": "未匯出能力",
|
||||
"caps.pluginWarnings": "{count} 個警告",
|
||||
"caps.pluginEnable": "啟用插件",
|
||||
"caps.pluginDisable": "停用插件",
|
||||
"caps.pluginEnabled": "已啟用",
|
||||
"caps.pluginDisabled": "已停用",
|
||||
"caps.pluginVersion": "版本",
|
||||
"caps.pluginRoot": "安裝路徑",
|
||||
"caps.pluginUpdate": "更新",
|
||||
"caps.pluginDoctor": "診斷",
|
||||
"caps.pluginRemove": "移除插件",
|
||||
"caps.pluginConfirmRemove": "確認移除",
|
||||
"caps.pluginPlanError": "安裝檢查失敗",
|
||||
"caps.pluginPlanReady": "安裝計畫",
|
||||
"caps.pluginAction": "操作",
|
||||
"caps.pluginPlanStatus": "插件操作:{status}",
|
||||
"caps.pluginPlanComplete": "插件操作完成",
|
||||
"caps.pluginPlanInstalled": "插件已安裝。展開下方插件,可查看它匯出的 skills、hooks 和 MCP 伺服器的使用方式。",
|
||||
"caps.pluginUsageTitle": "使用方法",
|
||||
"caps.pluginUsageEnabledHint": "啟用的插件會載入到新會話中。可用 /skills 查看技能、用 /<skill> 直接呼叫,或自然描述任務;hooks 和 MCP 伺服器會按正常會話流程自動參與。",
|
||||
"caps.pluginUsageDisabledHint": "啟用此插件後,它的 skills、hooks 或 MCP 伺服器才會參與會話。",
|
||||
"caps.pluginNoCapabilityDetails": "此插件暫無可展示的能力明細。",
|
||||
"caps.pluginSkillsTitle": "Skills",
|
||||
"caps.pluginSkillsHint": "用 /skills 瀏覽,直接呼叫,或在對話中說出工作流名稱。",
|
||||
"caps.pluginHooksTitle": "Hooks",
|
||||
"caps.pluginHooksHint": "Hooks 會在匹配的生命週期事件中自動觸發。",
|
||||
"caps.pluginMCPTitle": "MCP 伺服器",
|
||||
"caps.pluginMCPHint": "這些伺服器提供的工具會透過正常 MCP/工具流程可用。",
|
||||
"caps.pluginNoDescription": "無描述",
|
||||
"caps.pluginHookNoTarget": "未指定 hook 目標",
|
||||
"caps.pluginMCPNoTarget": "未指定啟動目標",
|
||||
"caps.sources": "來源",
|
||||
"caps.sourcesSummaryActive": "{active} 個有效",
|
||||
"caps.sourcesSummaryMissing": "{missing} 個缺失",
|
||||
@@ -309,6 +366,9 @@ export const zhTW: Record<DictKey, string> = {
|
||||
"composer.pastedExpand": "展開",
|
||||
"composer.pastedRemove": "刪除貼上文字",
|
||||
"composer.removeImage": "移除圖片",
|
||||
"imageViewer.clickToPreview": "點擊查看大圖",
|
||||
"imageViewer.closePreview": "關閉預覽",
|
||||
"imageViewer.title": "圖片預覽",
|
||||
"composer.imageInputUnsupported": "目前模型不會直接接收圖片。可切換到支援圖片的模型,或讓 OCR/識圖 MCP 工具讀取附件路徑。",
|
||||
"composer.contextItems": "上下文項目",
|
||||
"composer.workspaceReferences": "工作區引用",
|
||||
@@ -326,6 +386,12 @@ export const zhTW: Record<DictKey, string> = {
|
||||
"modelSwitcher.noMatches": "沒有匹配的模型",
|
||||
"status.noModels": "沒有可切換模型",
|
||||
"status.modelSwitchFailed": "模型切換失敗:{err}",
|
||||
"status.effortSwitchBusy": "暫時不能切換推理力度。請先停止目前回答、處理待確認問題,或等待背景任務結束。",
|
||||
"status.effortSwitchLeaseHeld": "這個會話正在另一個 Reasonix 視窗或背景執行。請關閉那個視窗、停止背景執行,或開啟副本後再切換推理力度。",
|
||||
"status.effortSwitchStarting": "會話還在啟動,稍後再切換推理力度。",
|
||||
"status.effortSwitchStartupFailed": "目前會話啟動失敗,無法切換推理力度:{err}",
|
||||
"status.effortSwitchRetry": "目前會話已變更,請再試一次切換推理力度。",
|
||||
"status.effortSwitchFailed": "推理力度切換失敗:{err}",
|
||||
"status.effort": "effort {level}",
|
||||
"status.effortTitle": "推理力度",
|
||||
"status.effortAutoTitle": "推理力度:auto(模型預設:{def})",
|
||||
@@ -514,6 +580,18 @@ export const zhTW: Record<DictKey, string> = {
|
||||
"history.historySessionActions": "歷史會話操作",
|
||||
"history.trashActions": "回收站操作",
|
||||
|
||||
// 會話恢復
|
||||
"recovery.branch": "衝突副本",
|
||||
"recovery.badge": "副本",
|
||||
"recovery.open": "開啟衝突副本",
|
||||
"recovery.toast": "偵測到儲存衝突,已為 {title} 保留一份衝突副本。",
|
||||
"recovery.failedLease": "衝突副本被另一個執行階段占用,未切換過去。",
|
||||
"recovery.failedUnavailable": "無法開啟衝突副本,目前標籤仍停留在原會話。",
|
||||
"recovery.banner": "目前會話是儲存衝突副本。繼續前建議與原會話對比。",
|
||||
"recovery.reason": "原因:{reason}",
|
||||
"recovery.digest": "摘要:{digest}",
|
||||
"recovery.parent": "父會話:{parent}",
|
||||
|
||||
// 專案樹
|
||||
"projectTree.workspaceTitle": "專案",
|
||||
"projectTree.pinnedTitle": "置頂",
|
||||
@@ -629,6 +707,7 @@ export const zhTW: Record<DictKey, string> = {
|
||||
"settings.tab.capabilities": "MCP 與技能",
|
||||
"settings.tab.mcp": "MCP 與工具",
|
||||
"settings.tab.skills": "技能",
|
||||
"settings.tab.plugins": "插件",
|
||||
"settings.tab.memory": "記憶",
|
||||
"settings.tab.shortcuts": "快捷鍵",
|
||||
"settings.tab.network": "網路",
|
||||
@@ -642,6 +721,7 @@ export const zhTW: Record<DictKey, string> = {
|
||||
"settings.tabSub.capabilities": "伺服器、工具與技能",
|
||||
"settings.tabSub.mcp": "伺服器與工具",
|
||||
"settings.tabSub.skills": "來源與啟用",
|
||||
"settings.tabSub.plugins": "包與診斷",
|
||||
"settings.tabSub.memory": "專案與全域",
|
||||
"settings.tabSub.shortcuts": "按鍵與幫助",
|
||||
"settings.tabSub.network": "代理與連線",
|
||||
@@ -698,6 +778,10 @@ export const zhTW: Record<DictKey, string> = {
|
||||
"settings.subagentEffort": "子代理 effort",
|
||||
"settings.subagentEffortDefault": "auto(模型服務預設)",
|
||||
"settings.subagentHint": "作用於 task 和 runAs=subagent skills;工具呼叫參數或單個 skill 設定會覆蓋它。",
|
||||
"settings.subagentDepth": "子代理巢狀深度",
|
||||
"settings.subagentDepthHint": "控制第一層子代理能否再派發一層 reviewer 或 worker 子代理。",
|
||||
"settings.subagentDepthOne": "1 層",
|
||||
"settings.subagentDepthTwo": "2 層",
|
||||
"settings.defaultToolApprovalMode": "新會話預設審批",
|
||||
"settings.defaultToolApprovalModeHint": "保存在全域設定中,僅用於新建會話;切換或恢復已有會話時不會覆蓋其 Ask / Auto / Yolo 狀態。",
|
||||
"settings.defaultToolApprovalMode.ask": "詢問",
|
||||
@@ -720,7 +804,7 @@ export const zhTW: Record<DictKey, string> = {
|
||||
"settings.coldResumePrune.on": "開",
|
||||
"settings.coldResumePrune.off": "關",
|
||||
"settings.reasoningLanguage": "思考語言",
|
||||
"settings.reasoningLanguageHint": "只影響可見思考過程。自動跟隨對話語言,不額外注入語言指令。",
|
||||
"settings.reasoningLanguageHint": "只影響可見思考過程。自動在原始提問明顯為中文時錨定中文,引用內容不參與判斷。",
|
||||
"settings.reasoningLanguage.auto": "自動",
|
||||
"settings.reasoningLanguage.zh": "中文",
|
||||
"settings.reasoningLanguage.en": "English",
|
||||
@@ -807,12 +891,18 @@ export const zhTW: Record<DictKey, string> = {
|
||||
"settings.yoloHint": "所有工具呼叫(寫檔案、bash)自動執行、不再詢問。deny 規則仍會攔截。僅執行時生效,重啟後失效。",
|
||||
"settings.addRule": "新增 {list} 規則…",
|
||||
"settings.sandboxTitle": "沙箱與工作區",
|
||||
"settings.sandboxBoundaryHint": "沙箱可寫根只限制已核准的 Bash 和檔案工具能寫到哪裡;它不會自動核准命令。若 Bash 沙箱為 enforce 但宿主機沒有 OS 沙箱後端,shell 命令會被拒絕;只在可信工作區把 Bash 沙箱切到 off。",
|
||||
"settings.reloadSessionConfig": "套用手動設定修改",
|
||||
"settings.reloadSessionConfigHint": "設定頁內修改會自動生效;此按鈕用於套用手動編輯 config.toml / reasonix.toml 後的變更。",
|
||||
"settings.bashSandbox": "Bash 沙箱",
|
||||
"settings.bashEnforce": "enforce(隔離 bash)",
|
||||
"settings.bashEnforce": "enforce(隔離 bash;失敗即拒絕)",
|
||||
"settings.bashOff": "off(不受限執行)",
|
||||
"settings.allowNetwork": "允許沙箱內 bash 存取網路",
|
||||
"settings.workspaceRoot": "工作區根目錄",
|
||||
"settings.workspaceDefault": "(預設:當前目錄)",
|
||||
"settings.effectiveWriteRoots": "實際可寫根",
|
||||
"settings.effectiveWriteRootsHint": "按目前工作區展開。設定頁內修改會自動生效;手動編輯設定檔後可用上方按鈕刷新目前會話。",
|
||||
"settings.noEffectiveWriteRoots": "暫無可寫根",
|
||||
"settings.appearance": "外觀",
|
||||
"settings.theme": "主題",
|
||||
"settings.themeStyle": "強調色",
|
||||
@@ -829,6 +919,9 @@ export const zhTW: Record<DictKey, string> = {
|
||||
"settings.textSizeXLarge": "特大",
|
||||
"settings.textSizeXXLarge": "超大",
|
||||
"settings.displayZoom": "顯示縮放(需重啟生效)",
|
||||
"settings.displayZoomDecrease": "縮小顯示縮放",
|
||||
"settings.displayZoomIncrease": "放大顯示縮放",
|
||||
"settings.displayZoomReset": "恢復顯示縮放到 100%",
|
||||
"settings.fontFamily": "介面字型",
|
||||
"settings.fontFamilySystem": "系統預設",
|
||||
"settings.fontFamilyYaHei": "微軟正黑體",
|
||||
@@ -847,6 +940,7 @@ export const zhTW: Record<DictKey, string> = {
|
||||
"settings.pageDesc.providers": "管理模型供應商、API 位址、金鑰與可用模型。",
|
||||
"settings.pageDesc.mcp": "管理 MCP 伺服器連線和工具發現。",
|
||||
"settings.pageDesc.skills": "瀏覽、啟用和管理智慧體技能與來源。",
|
||||
"settings.pageDesc.plugins": "安裝和管理插件包,用統一單元提供技能、hooks 與 MCP 伺服器。",
|
||||
"settings.pageDesc.memory": "管理 Reasonix 會長期參考的偏好、專案約定和指令檔案。",
|
||||
"settings.pageDesc.network": "代理與網路設定。",
|
||||
"settings.pageDesc.permissions": "寫操作模式與細粒度工具權限規則。",
|
||||
@@ -859,7 +953,7 @@ export const zhTW: Record<DictKey, string> = {
|
||||
"settings.customProviders": "自訂供應商",
|
||||
"settings.customProvidersHint": "你自己新增的 OpenAI-compatible 或代理接入,可以編輯、刪除並持久化模型列表。",
|
||||
"settings.customProvidersEmpty": "還沒有自訂供應商。常用服務優先使用上方內建接入。",
|
||||
"settings.providerAccessEmptyHint": "點擊「新增供應商」,選擇 DeepSeek 或自訂 OpenAI-compatible 接入。",
|
||||
"settings.providerAccessEmptyHint": "使用「推薦預設」可快速接入常用供應商,通常只需填寫 API Key;使用「自訂供應商」可手動設定 OpenAI-compatible 或 Anthropic-compatible 端點。",
|
||||
"settings.builtinProviderBadge": "內建",
|
||||
"settings.customProviderBadge": "自訂",
|
||||
"settings.configureProvider": "設定",
|
||||
@@ -871,24 +965,77 @@ export const zhTW: Record<DictKey, string> = {
|
||||
"settings.searchModels": "搜尋模型...",
|
||||
"settings.noMatchingModels": "沒有匹配的模型",
|
||||
"settings.addProvider.chooseTitle": "新增供應商",
|
||||
"settings.addProvider.chooseHint": "選擇官方預設,或新增一個自訂 OpenAI-compatible 接入。",
|
||||
"settings.addProvider.officialChoice": "官方供應商",
|
||||
"settings.addProvider.officialChoiceHint": "從 Reasonix 預設的 DeepSeek 接入中選擇。",
|
||||
"settings.addProvider.chooseHint": "選擇推薦預設,或手動新增一個自訂供應商。",
|
||||
"settings.addProvider.officialChoice": "推薦預設",
|
||||
"settings.addProvider.officialChoiceHint": "選擇 DeepSeek 或已適配的自訂供應商預設。",
|
||||
"settings.addProvider.customChoice": "自訂供應商",
|
||||
"settings.addProvider.customChoiceHint": "按 base_url、模型、金鑰環境變數等參數手動設定。",
|
||||
"settings.addProvider.officialTitle": "新增官方供應商",
|
||||
"settings.addProvider.officialHint": "官方預設會寫入固定端點和預設參數,只需要選擇供應商並設定金鑰。",
|
||||
"settings.addProvider.officialHint": "推薦預設會填好端點、模型、視覺和推理預設值,通常只需要填 API Key。",
|
||||
"settings.addProvider.customTitle": "新增自訂供應商",
|
||||
"settings.addProvider.customHint": "適合代理、聚合平台或其它 OpenAI-compatible 服務。",
|
||||
"settings.addProvider.official.deepseek": "DeepSeek 官方",
|
||||
"settings.addProvider.official.deepseekDesc": "官方 API Key 接入,可用 DeepSeek V4 Flash / Pro。",
|
||||
"settings.addProvider.preset.longcatOpenAIDesc": "LongCat 開放平台 OpenAI-compatible 端點,預設 LongCat-2.0。",
|
||||
"settings.addProvider.preset.longcatAnthropicDesc": "LongCat 開放平台 Anthropic-compatible Messages 端點,使用 Bearer 認證。",
|
||||
"settings.addProvider.preset.kimiCnDesc": "Kimi 中國 API,走 Moonshot CN 端點,預置 K2 視覺模型。",
|
||||
"settings.addProvider.preset.kimiGlobalDesc": "Kimi 國際 API,走 Moonshot global 端點,預置 K2 視覺模型。",
|
||||
"settings.addProvider.preset.kimiCodingPlanDesc": "Kimi Coding Plan,走獨立 Anthropic-compatible 端點,不歸屬 CN/Global。",
|
||||
"settings.addProvider.preset.mimoApiDesc": "小米 MiMo 直連 API,適合 v2.5 按量付費模型。",
|
||||
"settings.addProvider.preset.mimoAnthropicDesc": "小米 MiMo 直連 Anthropic-compatible 端點,自動使用 MiMo v2.5 模型。",
|
||||
"settings.addProvider.preset.mimoTokenPlanCnDesc": "小米 MiMo token plan 中國區端點。",
|
||||
"settings.addProvider.preset.mimoTokenPlanCnAnthropicDesc": "小米 MiMo token plan 中國區 Anthropic-compatible 端點。",
|
||||
"settings.addProvider.preset.mimoTokenPlanSgpDesc": "小米 MiMo token plan 新加坡端點。",
|
||||
"settings.addProvider.preset.mimoTokenPlanSgpAnthropicDesc": "小米 MiMo token plan 新加坡 Anthropic-compatible 端點。",
|
||||
"settings.addProvider.preset.mimoTokenPlanAmsDesc": "小米 MiMo token plan 阿姆斯特丹端點。",
|
||||
"settings.addProvider.preset.mimoTokenPlanAmsAnthropicDesc": "小米 MiMo token plan 阿姆斯特丹 Anthropic-compatible 端點。",
|
||||
"settings.addProvider.preset.minimaxCnApiDesc": "MiniMax 中國 OpenAI-compatible M 系列 API 端點。",
|
||||
"settings.addProvider.preset.minimaxGlobalApiDesc": "MiniMax 國際 OpenAI-compatible M 系列 API 端點。",
|
||||
"settings.addProvider.preset.minimaxCnAnthropicDesc": "MiniMax 中國 Anthropic-compatible M 系列端點。",
|
||||
"settings.addProvider.preset.minimaxGlobalAnthropicDesc": "MiniMax 國際 Anthropic-compatible 端點,自動使用 Bearer 認證。",
|
||||
"settings.addProvider.preset.glmCnDesc": "智譜 GLM 中國 API,預置 GLM thinking 控制。",
|
||||
"settings.addProvider.preset.zaiGlobalDesc": "Z.AI 國際 GLM API。",
|
||||
"settings.addProvider.preset.glmCodingPlanCnDesc": "智譜 GLM 中國 coding plan 端點,預設 GLM-5.2。",
|
||||
"settings.addProvider.preset.glmCodingPlanCnAnthropicDesc": "智譜 GLM 中國 Anthropic-compatible coding plan 端點,預設 GLM-5.2[1m]。",
|
||||
"settings.addProvider.preset.zaiCodingPlanGlobalDesc": "Z.AI 國際 coding plan 端點,預設 GLM-5.2。",
|
||||
"settings.addProvider.preset.zaiCodingPlanGlobalAnthropicDesc": "Z.AI 國際 Anthropic-compatible coding plan 端點,預設 GLM-5.2[1m]。",
|
||||
"settings.addProvider.preset.opencodeGoDesc": "OpenCode Go 混合模型閘道,內建每模型能力覆蓋。",
|
||||
"settings.addProvider.preset.opencodeGoAnthropicDesc": "OpenCode Go Anthropic 線路,預置 Qwen 與 MiniMax 模型。",
|
||||
"settings.addProvider.preset.opencodeZenAnthropicDesc": "OpenCode Zen Anthropic 線路,預置 Claude 與 Qwen 模型。",
|
||||
"settings.addProvider.preset.qwenCnDesc": "Qwen/DashScope 中國標準 API 端點。",
|
||||
"settings.addProvider.preset.qwenGlobalDesc": "Qwen/DashScope 國際標準 API 端點。",
|
||||
"settings.addProvider.preset.qwenCodingPlanCnDesc": "Qwen Coding Plan 中國 OpenAI-compatible 端點,預設 qwen3.7-plus。",
|
||||
"settings.addProvider.preset.qwenCodingPlanCnAnthropicDesc": "Qwen Coding Plan 中國 Anthropic-compatible 端點,預設 qwen3.7-plus。",
|
||||
"settings.addProvider.preset.qwenCodingPlanGlobalDesc": "Qwen Coding Plan 國際 OpenAI-compatible 端點,預設 qwen3.7-plus。",
|
||||
"settings.addProvider.preset.qwenCodingPlanGlobalAnthropicDesc": "Qwen Coding Plan 國際 Anthropic-compatible 端點,預設 qwen3.7-plus。",
|
||||
"settings.addProvider.preset.stepfunDesc": "階躍星辰 StepFun coding plan OpenAI-compatible 端點,預設 step-3.7-flash。",
|
||||
"settings.addProvider.preset.stepfunAnthropicDesc": "階躍星辰 StepFun coding plan Anthropic-compatible 端點,預設 step-3.7-flash。",
|
||||
"settings.addProvider.preset.novitaDesc": "NovitaAI 多模型閘道,預置 Kimi、MiniMax、GLM、DeepSeek、Qwen。",
|
||||
"settings.addProvider.preset.gmiDesc": "GMI Cloud 多模型直連閘道,預置 GLM、DeepSeek、Kimi、Gemini、Claude、GPT。",
|
||||
"settings.addProvider.preset.vercelAiGatewayDesc": "Vercel AI Gateway,走 Anthropic-compatible Messages API。",
|
||||
"settings.addProvider.preset.huggingfaceDesc": "HuggingFace Inference Router,預置 Qwen 與 DeepSeek 模型。",
|
||||
"settings.addProvider.preset.nvidiaDesc": "NVIDIA NIM 加速推理端點,預置 Nemotron 模型。",
|
||||
"settings.addProvider.preset.kilocodeDesc": "Kilo Code 閘道,預置 kilo/auto 自動路由模型。",
|
||||
"settings.addProvider.preset.ollamaCloudDesc": "Ollama Cloud 託管端點,支援 none 到 max 的 reasoning effort。",
|
||||
"settings.addProvider.addedBadge": "已新增",
|
||||
"settings.addProvider.modifiedBadge": "已修改",
|
||||
"settings.addProvider.nameConflictBadge": "同名占用",
|
||||
"settings.addProvider.similarExistingBadge": "有相似設定",
|
||||
"settings.addProvider.confirm": "新增供應商",
|
||||
"settings.addProvider.alreadyAddedAction": "已新增",
|
||||
"settings.addProvider.nameConflictAction": "先處理同名設定",
|
||||
"settings.addProvider.viewPresetProvider": "查看設定",
|
||||
"settings.addProvider.viewConflictProvider": "查看同名設定",
|
||||
"settings.addProvider.resetPreset": "重設為預設",
|
||||
"settings.addProvider.confirmResetPreset": "確認重設",
|
||||
"settings.customProviderName": "自訂供應商名稱",
|
||||
"settings.customProviderNamePlaceholder": "",
|
||||
"settings.providerNamePlaceholder": "供應商名稱(如 deepseek-proxy)",
|
||||
"settings.providerProtocol": "接入協定",
|
||||
"settings.providerProtocolOpenAI": "OpenAI-compatible",
|
||||
"settings.providerProtocolOpenAIHint": "適用於相容 OpenAI API 的代理、聚合平台和自建服務。",
|
||||
"settings.providerProtocolAnthropic": "Anthropic-compatible",
|
||||
"settings.providerProtocolAnthropicHint": "適用於 Claude Messages API 風格的服務,例如部分 coding plan 端點。",
|
||||
"settings.providerBaseUrlLabel": "API 位址",
|
||||
"settings.providerChatUrlLabel": "完整請求 URL",
|
||||
"settings.providerChatUrlPlaceholder": "例如 https://api.openai.com/v1/chat/completions",
|
||||
@@ -911,6 +1058,12 @@ export const zhTW: Record<DictKey, string> = {
|
||||
"settings.providerHeaders": "額外請求標頭(可選)",
|
||||
"settings.providerHeadersPlaceholder": "HTTP-Referer: https://example.com\nX-Title: Reasonix",
|
||||
"settings.providerHeadersHint": "用於 OpenRouter 等要求站點來源的中轉站。一行一個 Header: value;API Key 仍透過上方金鑰保存。",
|
||||
"settings.providerExtraBody": "額外請求體(可選)",
|
||||
"settings.providerExtraBodyPlaceholder": "{\n \"enable_thinking\": true\n}",
|
||||
"settings.providerExtraBodyHint": "會合併到聊天請求體頂層 JSON。model、messages、tools、stream、thinking 等核心欄位仍由 Reasonix 控制。",
|
||||
"settings.providerExtraBodyError": "請輸入不含 null 值的 JSON 物件,例如 { \"enable_thinking\": true }。",
|
||||
"settings.providerAuthHeader": "使用 Authorization: Bearer 發送金鑰",
|
||||
"settings.providerAuthHeaderHint": "適用於 MiniMax Global、Vercel AI Gateway 等不使用 x-api-key 的 Anthropic-compatible 閘道。",
|
||||
"settings.fetchingModels": "重新整理中…",
|
||||
"settings.fetchModelsSuccess": "已取得 {n} 個模型",
|
||||
"settings.fetchModelsManualFallbackAuth": "模型發現介面拒絕了金鑰或權限。請檢查 API Key;如果該服務不開放模型發現,也可以在下方手動填寫模型 ID。",
|
||||
@@ -928,10 +1081,16 @@ export const zhTW: Record<DictKey, string> = {
|
||||
"settings.newProviderDraftName": "新供應商",
|
||||
"settings.reasoningProtocol": "模型能力模式",
|
||||
"settings.reasoningProtocol.auto": "自動識別(推薦)",
|
||||
"settings.reasoningProtocol.deepseek": "DeepSeek 思考",
|
||||
"settings.reasoningProtocol.openai": "OpenAI reasoning",
|
||||
"settings.reasoningProtocol.deepseek": "DeepSeek 思考(thinking + 深度)",
|
||||
"settings.reasoningProtocol.openai": "OpenAI reasoning(reasoning_effort)",
|
||||
"settings.reasoningProtocol.none": "普通聊天(不傳送思考參數)",
|
||||
"settings.reasoningProtocolHint": "預設自動識別。只有代理不相容,或你確定模型需要特定 reasoning 格式時再切換。",
|
||||
"settings.thinkingMode": "Thinking 覆蓋",
|
||||
"settings.thinkingMode.auto": "Auto(使用服務預設)",
|
||||
"settings.thinkingMode.enabled": "Enabled(開啟)",
|
||||
"settings.thinkingMode.disabled": "Disabled(關閉)",
|
||||
"settings.thinkingMode.adaptive": "Adaptive(自適應)",
|
||||
"settings.thinkingModeHint": "服務商專用覆蓋項。相容閘道可用 enabled/disabled;只有服務文件明確支援時才使用 adaptive。",
|
||||
"settings.updateKey": "更新 {env}(全域儲存)",
|
||||
"settings.updateKeyAction": "更新金鑰",
|
||||
"settings.keyStatus": "金鑰",
|
||||
@@ -985,6 +1144,8 @@ export const zhTW: Record<DictKey, string> = {
|
||||
"msg.imageAttachment": "圖片",
|
||||
"msg.workspaceReference": "工作區引用",
|
||||
"msg.folderReference": "資料夾 · 工作區引用",
|
||||
"msg.pastedExpandTooltip": "展開貼上文字",
|
||||
"msg.pastedCollapseTooltip": "收起貼上文字",
|
||||
"turnActions.summary": "總結",
|
||||
"turnActions.rewind": "回溯",
|
||||
"transcript.jumpToBottom": "跳到底部",
|
||||
@@ -1336,6 +1497,7 @@ export const zhTW: Record<DictKey, string> = {
|
||||
"clearContext.done": "已清空當前上下文",
|
||||
"clearContext.failed": "清空當前上下文失敗",
|
||||
"projectTree.justNow": "剛剛",
|
||||
"projectTree.previously": "之前",
|
||||
"projectTree.running": "輸出中",
|
||||
"projectTree.status.thinking": "思考中",
|
||||
"projectTree.status.streaming": "輸出中",
|
||||
@@ -1451,11 +1613,76 @@ export const zhTW: Record<DictKey, string> = {
|
||||
"settings.toggleOff": "關閉",
|
||||
"settings.botSetup": "機器人入口",
|
||||
"settings.botSetupHint": "先開啟入口,再選擇要連線的聊天頻道。",
|
||||
"settings.botConnectPhoneTitle": "新增 IM Bot",
|
||||
"settings.botConnectPhoneSubtitle": "QQ 手動配置,飛書、Lark 和微信掃碼接入。",
|
||||
"settings.botGateway": "機器人閘道器",
|
||||
"settings.botGatewayHint": "桌面端統一管理 IM bot 的總開關、模型和執行參數。",
|
||||
"settings.botGatewayHint": "桌面端統一管理 IM bot 的總開關和連線狀態。",
|
||||
"settings.botBrowserPreviewWarning": "目前是瀏覽器預覽,只會儲存 mock 設定,不會寫入本機配置或啟動 QQ/IM Bot gateway。請在桌面應用或 CLI 中配置後再到 QQ 用戶端測試。",
|
||||
"settings.botSimpleEyebrow": "傻瓜式設定",
|
||||
"settings.botSimpleTitle": "行動端與聊天機器人",
|
||||
"settings.botSimpleHint": "先用手機掃碼遠端控制目前工作區;需要長時間從聊天工具存取時,再選擇一個 Bot Channel 配置。",
|
||||
"settings.botMobileRemoteEyebrow": "獨立入口",
|
||||
"settings.botMobileRemoteTitle": "手機遠端控制",
|
||||
"settings.botMobileRemoteHint": "掃碼或複製連結,從行動端打開目前工作區的控制入口;它和 IM Bot 頻道管理分開配置。",
|
||||
"settings.botMobileRemoteControlApi": "控制連結",
|
||||
"settings.botMobileRemoteDisabled": "開啟後生成二維碼和連結",
|
||||
"settings.botMobileRemoteEnableFirst": "開啟後生成二維碼",
|
||||
"settings.botMobileRemoteReady": "等待手機連線",
|
||||
"settings.botMobileRemoteWaiting": "手機掃碼連線",
|
||||
"settings.botMobileRemoteLoopbackNotice": "目前控制 API 仍是本機 loopback,只適合本機預覽;跨裝置 relay 接入後會沿用這個入口。",
|
||||
"settings.botMobileRemoteCopyLink": "複製連結",
|
||||
"settings.botMobileRemoteCopied": "已複製",
|
||||
"settings.botMobileRemoteCopyFailed": "複製失敗",
|
||||
"settings.botChannelEntryTitle": "使用 Bot Channel",
|
||||
"settings.botChannelEntryHint": "連接聊天 Bot,適合更長時間的行動端存取。",
|
||||
"settings.botManageBots": "機器人管理",
|
||||
"settings.botManageBotsHint": "左側選擇頻道;未配置時掃碼或填寫金鑰,配置後在右側管理這個機器人的執行設定。",
|
||||
"settings.botBackToOverview": "返回入口",
|
||||
"settings.botChannelOpenConfigured": "已配置,可繼續修改設定。",
|
||||
"settings.botChannelNeedsSetup": "未配置",
|
||||
"settings.botChannelTabsLabel": "機器人頻道",
|
||||
"settings.botChannelSetupTitle": "配置 {provider}",
|
||||
"settings.botChannelSetupHint": "未配置前只需要完成憑據或掃碼綁定;成功後會自動切換為具體設定。",
|
||||
"settings.botSetupStatus": "機器人設定狀態",
|
||||
"settings.botSetupStatusConnected": "{online}/{total} 在線",
|
||||
"settings.botSetupStatusAccessTrusted": "受信任名單 {count}",
|
||||
"settings.botSetupStatusAccessEveryone": "所有人可用",
|
||||
"settings.botSetupStatusAccessPerBot": "{configured}/{total} 個 Bot 已配置",
|
||||
"settings.botMode": "機器人設定模式",
|
||||
"settings.botModeSimple": "普通",
|
||||
"settings.botModeAdvanced": "高階",
|
||||
"settings.botShowAdvancedSettings": "高階設定",
|
||||
"settings.botHideAdvancedSettings": "收起高階設定",
|
||||
"settings.botAdvancedSettingsHint": "低頻能力:路由、佇列細項和自身訊息過濾。",
|
||||
"settings.botShowAddBot": "新增頻道",
|
||||
"settings.botStepConnectTitle": "連接機器人",
|
||||
"settings.botStepConnectHint": "QQ 手動填密鑰;飛書、Lark、微信掃碼即可接入。",
|
||||
"settings.botStepConnectEmpty": "還沒有連接",
|
||||
"settings.botSharedAllowlistHint": "所有渠道共用第 2 步「誰可以使用」的名單與配對設定,改一處即可全部生效。",
|
||||
"settings.botSharedAllowlistGo": "前往第 2 步編輯",
|
||||
"settings.botGlobalAllowlist": "舊全域白名單",
|
||||
"settings.botGlobalAllowlistHint": "相容舊配置;新 Bot 建議在各自詳情裡設定誰可以使用。",
|
||||
"settings.botAccessShowAllPlatforms": "顯示全部平台",
|
||||
"settings.botAccessShowConnectedOnly": "只看已連接平台",
|
||||
"settings.botListUsers": "使用者",
|
||||
"settings.botListGroups": "群組",
|
||||
"settings.botListApprovers": "審批人",
|
||||
"settings.botListAdmins": "管理員",
|
||||
"settings.botPlatformFeishuLark": "飛書 / Lark",
|
||||
"settings.botQueueDropLabel": "佇列滿時",
|
||||
"settings.botQueueDropHint": "佇列達到上限後,如何處理放不下的訊息。",
|
||||
"settings.botPairingDetailHint": "配對開關在第 2 步「誰可以使用」裡;這裡只調整配對碼有效期和待批數量。",
|
||||
"settings.botSimpleAccessTitle": "誰可以使用",
|
||||
"settings.botSimpleAccessTrustedSummary": "目前使用受信任名單,已填寫 {count} 條成員或群組。",
|
||||
"settings.botSimpleAccessEveryoneSummary": "目前允許所有人使用,適合短時間測試。",
|
||||
"settings.botAccessTrusted": "受信任名單",
|
||||
"settings.botAccessTrustedHint": "只允許填寫過的使用者或群組呼叫機器人。",
|
||||
"settings.botAccessEveryone": "所有人可用",
|
||||
"settings.botAccessEveryoneHint": "臨時測試方便,但不建議長期開啟。",
|
||||
"settings.botAccessPairing": "私聊先配對",
|
||||
"settings.botAccessPairingHint": "陌生私聊先發配對碼,本機批准後再放行。",
|
||||
"settings.botRoleAccess": "管理員與審批人",
|
||||
"settings.botRoleAccessHint": "可選。普通使用者不用填;需要遠端審批或管理時再配置。",
|
||||
"settings.botQueueModeSimple": "新訊息怎麼處理",
|
||||
"settings.botQueueModeSimpleHint": "當機器人還在處理上一條訊息時,選擇下一條訊息的處理方式。",
|
||||
"settings.saveBotSettings": "儲存機器人設定",
|
||||
"settings.botEnableBot": "啟用機器人",
|
||||
"settings.botGatewayMasterSwitch": "總開關",
|
||||
@@ -1466,8 +1693,48 @@ export const zhTW: Record<DictKey, string> = {
|
||||
"settings.botModelHint": "留空時使用預設模型。",
|
||||
"settings.botModelDefault": "使用預設模型",
|
||||
"settings.botRuntime": "執行限制",
|
||||
"settings.botRuntimeHint": "閘道器行程級限制,控制每次機器人任務最多走多遠。",
|
||||
"settings.botMaxSteps": "最大步數",
|
||||
"settings.botDebounceMs": "合併視窗 ms",
|
||||
"settings.botQueueCap": "佇列上限",
|
||||
"settings.botQueueMode.steer": "引導目前回合",
|
||||
"settings.botQueueMode.followup": "追加跟進",
|
||||
"settings.botQueueMode.collect": "收集等待",
|
||||
"settings.botQueueMode.interrupt": "打斷目前",
|
||||
"settings.botQueueDrop.summarize": "彙總溢出",
|
||||
"settings.botQueueDrop.old": "丟棄最舊",
|
||||
"settings.botQueueDrop.new": "丟棄最新",
|
||||
"settings.botIgnoreSelfMessages": "忽略自身回聲",
|
||||
"settings.botIgnoreSelfMessagesHint": "避免 gateway 再次處理由已配置 Bot 身份發出的訊息。",
|
||||
"settings.botSelfUserIds": "Bot 自身 ID",
|
||||
"settings.botSelfUserIdsHint": "可選填寫各平台 Bot 自己的 user/open ID,用於過濾回聲訊息。",
|
||||
"settings.botPairing": "私聊配對",
|
||||
"settings.botPairingTTL": "有效分鐘",
|
||||
"settings.botPairingMaxPending": "待批上限",
|
||||
"settings.botControlApi": "本機控制 API",
|
||||
"settings.botControlApiHint": "僅 loopback 的 HTTP API,可查狀態和受控傳送;需要從指定環境變數讀取 Bearer token。",
|
||||
"settings.botControlAddr": "控制地址",
|
||||
"settings.botControlTokenEnv": "Token 變數",
|
||||
"settings.botGatewayDefaults": "閘道器執行設定",
|
||||
"settings.botGatewayDefaultsHint": "不用編輯 TOML,也能配置排隊、配對和自身訊息過濾。",
|
||||
"settings.botRoutes": "聊天綁定到專案",
|
||||
"settings.botRoutesHint": "把某個群組、聯絡人或執行緒固定到指定工作目錄、模型或審批方式。",
|
||||
"settings.botRoutesEmpty": "還沒有綁定規則。新增一條規則,把某個聊天、使用者或執行緒綁定到專案或模型。",
|
||||
"settings.botRouteTitle": "路由 {n}",
|
||||
"settings.botAddRoute": "新增路由",
|
||||
"settings.botRouteConnection": "來自哪個 Bot",
|
||||
"settings.botRouteAny": "任意",
|
||||
"settings.botRoutePlatform": "平台",
|
||||
"settings.botRouteChatType": "聊天類型",
|
||||
"settings.botRouteChatType.any": "任意",
|
||||
"settings.botRouteChatType.dm": "私聊",
|
||||
"settings.botRouteChatType.group": "群聊",
|
||||
"settings.botRouteChatType.guild": "頻道",
|
||||
"settings.botRouteChatType.direct": "Direct",
|
||||
"settings.botRouteChatType.thread": "Thread",
|
||||
"settings.botRouteChatId": "群組/聊天 ID",
|
||||
"settings.botRouteUserId": "聯絡人 ID",
|
||||
"settings.botRouteThreadId": "執行緒 ID",
|
||||
"settings.botChannels": "連線頻道",
|
||||
"settings.botChannelsHint": "選擇頻道;QQ 手動配置,飛書、Lark 和微信掃碼連線。",
|
||||
"settings.botQQHint": "QQ 官方 Bot API v2。",
|
||||
@@ -1522,8 +1789,7 @@ export const zhTW: Record<DictKey, string> = {
|
||||
"settings.botInstallLarkHint": "掃碼建立 Lark PersonalAgent。",
|
||||
"settings.botInstallWeixinHint": "微信掃碼登入並儲存本地 token。",
|
||||
"settings.botConnectedBots": "已連線 Bot",
|
||||
"settings.botConnectedBotsSummary": "{online}/{total} 線上",
|
||||
"settings.botConnectionsEmpty": "還沒有連線的機器人頻道。",
|
||||
"settings.botSelectBotHint": "選擇左側 Bot 進入配置。",
|
||||
"settings.botConnectionColumnChannel": "頻道",
|
||||
"settings.botConnectionColumnName": "機器人",
|
||||
"settings.botConnectionColumnRemote": "遠端 ID",
|
||||
@@ -1541,16 +1807,17 @@ export const zhTW: Record<DictKey, string> = {
|
||||
"settings.botConnectionConnected": "已連線",
|
||||
"settings.botConnectionConfigured": "已配置",
|
||||
"settings.botConnectionDisconnected": "未連線",
|
||||
"settings.botManage": "高階設定",
|
||||
"settings.botManage": "修改配置",
|
||||
"settings.deleteBot": "刪除機器人",
|
||||
"settings.deleteBotShort": "刪除",
|
||||
"settings.confirmDeleteBot": "確認刪除",
|
||||
"settings.deleteBotHint": "從已連線 Bot 中移除這個機器人;不會自動清除已儲存的金鑰。",
|
||||
"settings.botChannelModel": "模型",
|
||||
"settings.botChannelModelHint": "留空時使用 Bot 預設模型。",
|
||||
"settings.botChannelModelAuto": "自動",
|
||||
"settings.botChannelModelHint": "這個機器人處理訊息時使用的模型。",
|
||||
"settings.botChannelModelAuto": "使用桌面目前模型",
|
||||
"settings.botToolApprovalMode": "工具審批",
|
||||
"settings.botToolApprovalModeHint": "控制該頻道是否在工具執行前請求確認;計劃確認和 ask 問題仍會等待回覆。",
|
||||
"settings.botToolApprovalMode.inherit": "跟隨預設",
|
||||
"settings.botToolApprovalMode.inherit": "跟隨機器人",
|
||||
"settings.botToolApprovalMode.ask": "詢問",
|
||||
"settings.botToolApprovalMode.auto": "自動",
|
||||
"settings.botToolApprovalMode.yolo": "YOLO",
|
||||
@@ -1573,29 +1840,20 @@ export const zhTW: Record<DictKey, string> = {
|
||||
"settings.botDiagnosticReportFailed": "傳送診斷報告失敗。",
|
||||
"settings.botDiagnosticPrivacy": "僅包含已脫敏的連線元資訊。",
|
||||
"settings.botAccessControl": "存取控制",
|
||||
"settings.botAccessControlHint": "預設使用白名單;臨時測試可以改為允許所有人。",
|
||||
"settings.botAccessMode": "誰可以使用",
|
||||
"settings.botAccessAll": "所有人",
|
||||
"settings.botAccessWhitelist": "僅白名單",
|
||||
"settings.botEditAllowlist": "編輯白名單",
|
||||
"settings.botAllowlist": "白名單",
|
||||
"settings.botAllowlistHint": "限制哪些使用者或群可以呼叫機器人。",
|
||||
"settings.botAllowlistEnabled": "啟用白名單",
|
||||
"settings.botAllowAll": "允許所有人",
|
||||
"settings.botAllowAllWarn": "開啟後所有進入頻道的訊息都可以呼叫機器人。",
|
||||
"settings.botQQAccessRequired": "啟用 QQ Bot 前,請先在高階設定裡新增 QQ 使用者/群白名單,或臨時改為允許所有人。",
|
||||
"settings.botAllowlistEntries": "使用者與群",
|
||||
"settings.botQQAccessRequired": "啟用 QQ Bot 前,請先在第 2 步「誰可以使用」裡新增 QQ 使用者/群組,或臨時改為所有人可用。",
|
||||
"settings.botQQUsers": "QQ 使用者",
|
||||
"settings.botQQGroups": "QQ群",
|
||||
"settings.botFeishuUsers": "飛書使用者",
|
||||
"settings.botFeishuGroups": "飛書群",
|
||||
"settings.botFeishuLarkUsers": "飛書 / Lark 使用者",
|
||||
"settings.botFeishuLarkGroups": "飛書 / Lark 群",
|
||||
"settings.botWeixinUsers": "微信使用者",
|
||||
"settings.botWeixinGroups": "微信群",
|
||||
"settings.botListPlaceholder": "每行一個 ID,也可用逗號分隔",
|
||||
"settings.shellInterpreter": "Shell 直譯器",
|
||||
"settings.shellAuto": "自動(優先 bash,Windows 無 bash 時用 PowerShell)",
|
||||
"settings.shellAuto": "自動(bash 優先)",
|
||||
"settings.shellBash": "bash",
|
||||
"settings.shellPowershell": "PowerShell(Windows PowerShell)",
|
||||
"settings.shellPwsh": "pwsh(PowerShell 7+)",
|
||||
@@ -1745,7 +2003,7 @@ export const zhTW: Record<DictKey, string> = {
|
||||
"settings.telemetryLabel": "匿名啟動統計",
|
||||
"settings.telemetryHint": "啟動時傳送隨機安裝 ID、版本號和作業系統用於統計活躍安裝量;若上次執行發生崩潰,則在本次啟動補發該崩潰報告。絕不包含對話、金鑰或檔案內容。",
|
||||
"settings.metricsLabel": "共享聚合品質指標",
|
||||
"settings.metricsHint": "預設開啟。傳送匿名的輪次結束統計、設定偏好快照,以及 Memory v5 聚合桶,例如是否注入、大小桶、IR 數量桶和記憶圖規模桶。包含用於 DAU 去重的隨機安裝 ID。Bucket 可能包含正規化後的自訂 Provider 名和模型名——絕不包含對話、提示詞、金鑰、路徑、base URL、記憶正文、工具輸出或檔案內容。",
|
||||
"settings.metricsHint": "預設開啟。傳送匿名的輪次結束統計、更新器錯誤類別、設定偏好快照,以及 Memory v5 聚合桶,例如是否注入、大小桶、IR 數量桶和記憶圖規模桶。包含用於 DAU 去重的隨機安裝 ID。Bucket 可能包含正規化後的自訂 Provider 名和模型名——絕不包含對話、提示詞、金鑰、路徑、base URL、記憶正文、工具輸出或檔案內容。",
|
||||
"context.windowTitle": "上下文視窗",
|
||||
"context.windowStatusHealthy": "上下文充足",
|
||||
"context.windowStatusWatch": "即將壓縮",
|
||||
|
||||
@@ -206,6 +206,7 @@ export const zh: Record<DictKey, string> = {
|
||||
"caps.mcpSummary": "{connected} 个已连接 · {failed} 个异常 · {tools} 个工具",
|
||||
"caps.connectorsTab": "MCP 服务器",
|
||||
"caps.skillsTab": "Agent 技能",
|
||||
"caps.pluginsTab": "插件",
|
||||
"caps.servers": "MCP 服务器",
|
||||
"caps.noServers": "未配置 MCP 服务器。用 /mcp add 添加。",
|
||||
"caps.availableServers": "可用 MCP",
|
||||
@@ -312,6 +313,62 @@ export const zh: Record<DictKey, string> = {
|
||||
"caps.trustedReadOnlyTitle": "已记住为之后规划和只读 research 可用的只读工具",
|
||||
"caps.searchSkills": "搜索技能…",
|
||||
"caps.noSkillMatches": "没有匹配的技能。",
|
||||
"caps.pluginRefresh": "刷新插件",
|
||||
"caps.pluginInstallTitle": "安装插件",
|
||||
"caps.pluginInstallHint": "选择本地插件目录,或从 Git 仓库安装。",
|
||||
"caps.pluginInstallMethod": "插件安装方式",
|
||||
"caps.pluginInstallLocal": "本地目录",
|
||||
"caps.pluginInstallGit": "Git 仓库",
|
||||
"caps.pluginSource": "插件来源",
|
||||
"caps.pluginGitSource": "Git 仓库地址",
|
||||
"caps.pluginSourcePlaceholder": "git:github.com/obra/superpowers",
|
||||
"caps.pluginChooseLocalFolder": "选择插件目录",
|
||||
"caps.pluginLocalFolder": "本地插件目录",
|
||||
"caps.pluginNoLocalFolder": "尚未选择目录",
|
||||
"caps.pluginInstallName": "安装名称(可选)",
|
||||
"caps.pluginInstallNamePlaceholder": "安装名称(可选)",
|
||||
"caps.pluginLink": "开发模式:链接源目录",
|
||||
"caps.pluginLinkHint": "不复制插件,直接使用所选目录;适合调试插件。移动或删除该目录会导致插件失效。",
|
||||
"caps.pluginReplace": "覆盖同名插件",
|
||||
"caps.pluginReplaceHint": "插件名已存在时,允许用当前来源替换它。",
|
||||
"caps.pluginPreview": "预检",
|
||||
"caps.pluginInstall": "安装插件",
|
||||
"caps.noPluginsTitle": "尚未安装插件",
|
||||
"caps.noPluginsHint": "从 Git 或本地路径安装插件包,把技能、hooks 和 MCP 服务器作为一个统一单元管理。",
|
||||
"caps.installedPlugins": "已安装插件",
|
||||
"caps.pluginsSummary": "{enabled} 个启用 · 共 {total} 个 · {issues} 个异常",
|
||||
"caps.pluginCounts": "{skills} 技能 · {hooks} hooks · {mcps} MCP",
|
||||
"caps.pluginNoCapabilities": "未导出能力",
|
||||
"caps.pluginWarnings": "{count} 个告警",
|
||||
"caps.pluginEnable": "启用插件",
|
||||
"caps.pluginDisable": "停用插件",
|
||||
"caps.pluginEnabled": "已启用",
|
||||
"caps.pluginDisabled": "已停用",
|
||||
"caps.pluginVersion": "版本",
|
||||
"caps.pluginRoot": "安装路径",
|
||||
"caps.pluginUpdate": "更新",
|
||||
"caps.pluginDoctor": "诊断",
|
||||
"caps.pluginRemove": "移除插件",
|
||||
"caps.pluginConfirmRemove": "确认移除",
|
||||
"caps.pluginPlanError": "安装检查失败",
|
||||
"caps.pluginPlanReady": "安装计划",
|
||||
"caps.pluginAction": "操作",
|
||||
"caps.pluginPlanStatus": "插件操作:{status}",
|
||||
"caps.pluginPlanComplete": "插件操作完成",
|
||||
"caps.pluginPlanInstalled": "插件已安装。展开下方插件,可查看它导出的 skills、hooks 和 MCP 服务器的使用方式。",
|
||||
"caps.pluginUsageTitle": "使用方法",
|
||||
"caps.pluginUsageEnabledHint": "启用的插件会加载到新会话中。可用 /skills 查看技能、用 /<skill> 直接调用,或自然描述任务;hooks 和 MCP 服务器会按正常会话流程自动参与。",
|
||||
"caps.pluginUsageDisabledHint": "启用此插件后,它的 skills、hooks 或 MCP 服务器才会参与会话。",
|
||||
"caps.pluginNoCapabilityDetails": "此插件暂无可展示的能力明细。",
|
||||
"caps.pluginSkillsTitle": "Skills",
|
||||
"caps.pluginSkillsHint": "用 /skills 浏览,直接调用,或在对话中说出工作流名称。",
|
||||
"caps.pluginHooksTitle": "Hooks",
|
||||
"caps.pluginHooksHint": "Hooks 会在匹配的生命周期事件中自动触发。",
|
||||
"caps.pluginMCPTitle": "MCP 服务器",
|
||||
"caps.pluginMCPHint": "这些服务器提供的工具会通过正常 MCP/工具流程可用。",
|
||||
"caps.pluginNoDescription": "无描述",
|
||||
"caps.pluginHookNoTarget": "未指定 hook 目标",
|
||||
"caps.pluginMCPNoTarget": "未指定启动目标",
|
||||
"caps.sources": "来源",
|
||||
"caps.sourcesSummaryActive": "{active} 个有效",
|
||||
"caps.sourcesSummaryMissing": "{missing} 个缺失",
|
||||
@@ -433,6 +490,9 @@ export const zh: Record<DictKey, string> = {
|
||||
"composer.pastedExpand": "展开",
|
||||
"composer.pastedRemove": "删除粘贴文本",
|
||||
"composer.removeImage": "移除图片",
|
||||
"imageViewer.clickToPreview": "点击查看大图",
|
||||
"imageViewer.closePreview": "关闭预览",
|
||||
"imageViewer.title": "图片预览",
|
||||
"composer.attachImageFailed": "图片粘贴失败",
|
||||
"composer.attachFileFailed": "文件附加失败",
|
||||
"composer.attachDropFailed": "拖放文件附加失败",
|
||||
@@ -473,6 +533,12 @@ export const zh: Record<DictKey, string> = {
|
||||
"modelSwitcher.noMatches": "没有匹配的模型",
|
||||
"status.noModels": "没有可切换模型",
|
||||
"status.modelSwitchFailed": "模型切换失败:{err}",
|
||||
"status.effortSwitchBusy": "暂时不能切换推理力度。请先停止当前回答、处理待确认问题,或等待后台任务结束。",
|
||||
"status.effortSwitchLeaseHeld": "这个会话正在另一个 Reasonix 窗口或后台运行。请关闭那个窗口、停止后台运行,或打开副本后再切换推理力度。",
|
||||
"status.effortSwitchStarting": "会话还在启动,稍后再切换推理力度。",
|
||||
"status.effortSwitchStartupFailed": "当前会话启动失败,无法切换推理力度:{err}",
|
||||
"status.effortSwitchRetry": "当前会话已变化,请再试一次切换推理力度。",
|
||||
"status.effortSwitchFailed": "推理力度切换失败:{err}",
|
||||
"status.tokenModeSwitchFailed": "省 token 模式切换失败:{err}",
|
||||
"status.effort": "effort {level}",
|
||||
"status.effortTitle": "推理力度",
|
||||
@@ -672,6 +738,18 @@ export const zh: Record<DictKey, string> = {
|
||||
"history.historySessionActions": "历史会话操作",
|
||||
"history.trashActions": "回收站操作",
|
||||
|
||||
// 会话恢复
|
||||
"recovery.branch": "冲突副本",
|
||||
"recovery.badge": "副本",
|
||||
"recovery.open": "打开冲突副本",
|
||||
"recovery.toast": "检测到保存冲突,已为 {title} 保留一份冲突副本。",
|
||||
"recovery.failedLease": "冲突副本被另一个运行时占用,未切换过去。",
|
||||
"recovery.failedUnavailable": "无法打开冲突副本,当前标签仍停留在原会话。",
|
||||
"recovery.banner": "当前会话是保存冲突副本。继续前建议与原会话对比。",
|
||||
"recovery.reason": "原因:{reason}",
|
||||
"recovery.digest": "摘要:{digest}",
|
||||
"recovery.parent": "父会话:{parent}",
|
||||
|
||||
// 项目树
|
||||
"projectTree.workspaceTitle": "项目",
|
||||
"projectTree.pinnedTitle": "置顶",
|
||||
@@ -686,6 +764,7 @@ export const zh: Record<DictKey, string> = {
|
||||
"projectTree.renameProjectWorkbench": "重命名项目",
|
||||
"projectTree.projectHistory": "查看项目历史",
|
||||
"projectTree.justNow": "刚刚",
|
||||
"projectTree.previously": "之前",
|
||||
"projectTree.running": "输出中",
|
||||
"projectTree.status.thinking": "思考中",
|
||||
"projectTree.status.streaming": "输出中",
|
||||
@@ -846,6 +925,7 @@ export const zh: Record<DictKey, string> = {
|
||||
"settings.tab.capabilities": "MCP 与技能",
|
||||
"settings.tab.mcp": "MCP 与工具",
|
||||
"settings.tab.skills": "技能",
|
||||
"settings.tab.plugins": "插件",
|
||||
"settings.tab.memory": "记忆",
|
||||
"settings.tab.hooks": "Hooks",
|
||||
"settings.tab.shortcuts": "快捷键",
|
||||
@@ -861,6 +941,7 @@ export const zh: Record<DictKey, string> = {
|
||||
"settings.tabSub.capabilities": "服务器、工具与技能",
|
||||
"settings.tabSub.mcp": "服务器与工具",
|
||||
"settings.tabSub.skills": "来源与启用",
|
||||
"settings.tabSub.plugins": "包与诊断",
|
||||
"settings.tabSub.memory": "项目与全局",
|
||||
"settings.tabSub.hooks": "Shell 自动化",
|
||||
"settings.tabSub.shortcuts": "按键与帮助",
|
||||
@@ -942,6 +1023,10 @@ export const zh: Record<DictKey, string> = {
|
||||
"settings.subagentEffort": "子代理 effort",
|
||||
"settings.subagentEffortDefault": "auto(模型服务默认)",
|
||||
"settings.subagentHint": "作用于 task 和 runAs=subagent skills;工具调用参数或单个 skill 配置会覆盖它。",
|
||||
"settings.subagentDepth": "子代理嵌套深度",
|
||||
"settings.subagentDepthHint": "控制第一层子代理能否再派发一层 reviewer 或 worker 子代理。",
|
||||
"settings.subagentDepthOne": "1 层",
|
||||
"settings.subagentDepthTwo": "2 层",
|
||||
"settings.defaultToolApprovalMode": "新会话默认审批",
|
||||
"settings.defaultToolApprovalModeHint": "保存在全局设置中,仅用于新建会话;切换或恢复已有会话时不会覆盖其 Ask / Auto / Yolo 状态。",
|
||||
"settings.defaultToolApprovalMode.ask": "询问",
|
||||
@@ -964,7 +1049,7 @@ export const zh: Record<DictKey, string> = {
|
||||
"settings.coldResumePrune.on": "开",
|
||||
"settings.coldResumePrune.off": "关",
|
||||
"settings.reasoningLanguage": "思考语言",
|
||||
"settings.reasoningLanguageHint": "只影响可见思考过程。自动跟随对话语言,不额外注入语言指令。",
|
||||
"settings.reasoningLanguageHint": "只影响可见思考过程。自动在原始提问明显为中文时锚定中文,引用内容不参与判断。",
|
||||
"settings.reasoningLanguage.auto": "自动",
|
||||
"settings.reasoningLanguage.zh": "中文",
|
||||
"settings.reasoningLanguage.en": "English",
|
||||
@@ -1071,11 +1156,76 @@ export const zh: Record<DictKey, string> = {
|
||||
"settings.toggleOff": "关闭",
|
||||
"settings.botSetup": "机器人入口",
|
||||
"settings.botSetupHint": "先开启入口,再选择要连接的聊天渠道。",
|
||||
"settings.botConnectPhoneTitle": "添加 IM Bot",
|
||||
"settings.botConnectPhoneSubtitle": "QQ 手动配置,飞书、Lark 和微信扫码接入。",
|
||||
"settings.botGateway": "机器人网关",
|
||||
"settings.botGatewayHint": "桌面端统一管理 IM bot 的总开关、模型和运行参数。",
|
||||
"settings.botGatewayHint": "桌面端统一管理 IM bot 的总开关和连接状态。",
|
||||
"settings.botBrowserPreviewWarning": "当前是浏览器预览,只会保存 mock 设置,不会写入本机配置或启动 QQ/IM Bot gateway。请在桌面应用或 CLI 中配置后再到 QQ 客户端测试。",
|
||||
"settings.botSimpleEyebrow": "傻瓜式配置",
|
||||
"settings.botSimpleTitle": "移动端与聊天机器人",
|
||||
"settings.botSimpleHint": "先用手机扫码远程控制当前工作区;需要长期从聊天工具访问时,再选择一个 Bot Channel 配置。",
|
||||
"settings.botMobileRemoteEyebrow": "独立入口",
|
||||
"settings.botMobileRemoteTitle": "手机远程控制",
|
||||
"settings.botMobileRemoteHint": "扫码或复制链接,从移动端打开当前工作区的控制入口;它和 IM Bot 渠道管理分开配置。",
|
||||
"settings.botMobileRemoteControlApi": "控制链接",
|
||||
"settings.botMobileRemoteDisabled": "开启后生成二维码和链接",
|
||||
"settings.botMobileRemoteEnableFirst": "开启后生成二维码",
|
||||
"settings.botMobileRemoteReady": "等待手机连接",
|
||||
"settings.botMobileRemoteWaiting": "手机扫码连接",
|
||||
"settings.botMobileRemoteLoopbackNotice": "当前控制 API 仍是本机 loopback,只适合本机预览;跨设备 relay 接入后会沿用这个入口。",
|
||||
"settings.botMobileRemoteCopyLink": "复制链接",
|
||||
"settings.botMobileRemoteCopied": "已复制",
|
||||
"settings.botMobileRemoteCopyFailed": "复制失败",
|
||||
"settings.botChannelEntryTitle": "使用 Bot Channel",
|
||||
"settings.botChannelEntryHint": "连接聊天 Bot,适合更长时间的移动端访问。",
|
||||
"settings.botManageBots": "机器人管理",
|
||||
"settings.botManageBotsHint": "左侧选择渠道;未配置时扫码或填写密钥,配置后在右侧管理这个机器人的运行设置。",
|
||||
"settings.botBackToOverview": "返回入口",
|
||||
"settings.botChannelOpenConfigured": "已配置,可继续修改设置。",
|
||||
"settings.botChannelNeedsSetup": "未配置",
|
||||
"settings.botChannelTabsLabel": "机器人渠道",
|
||||
"settings.botChannelSetupTitle": "配置 {provider}",
|
||||
"settings.botChannelSetupHint": "未配置前只需要完成凭据或扫码绑定;成功后会自动切换为具体设置。",
|
||||
"settings.botSetupStatus": "机器人设置状态",
|
||||
"settings.botSetupStatusConnected": "{online}/{total} 在线",
|
||||
"settings.botSetupStatusAccessTrusted": "受信任名单 {count}",
|
||||
"settings.botSetupStatusAccessEveryone": "所有人可用",
|
||||
"settings.botSetupStatusAccessPerBot": "{configured}/{total} 个 Bot 已配置",
|
||||
"settings.botMode": "机器人设置模式",
|
||||
"settings.botModeSimple": "普通",
|
||||
"settings.botModeAdvanced": "高级",
|
||||
"settings.botShowAdvancedSettings": "高级设置",
|
||||
"settings.botHideAdvancedSettings": "收起高级设置",
|
||||
"settings.botAdvancedSettingsHint": "低频能力:路由、队列细项和自身消息过滤。",
|
||||
"settings.botShowAddBot": "新增渠道",
|
||||
"settings.botStepConnectTitle": "连接机器人",
|
||||
"settings.botStepConnectHint": "QQ 手动填密钥;飞书、Lark、微信扫码即可接入。",
|
||||
"settings.botStepConnectEmpty": "还没有连接",
|
||||
"settings.botSharedAllowlistHint": "所有渠道共用第 2 步「谁可以使用」的名单与配对设置,改一处即可全部生效。",
|
||||
"settings.botSharedAllowlistGo": "前往第 2 步编辑",
|
||||
"settings.botGlobalAllowlist": "旧全局白名单",
|
||||
"settings.botGlobalAllowlistHint": "兼容旧配置;新 Bot 推荐在各自详情里设置谁可以使用。",
|
||||
"settings.botAccessShowAllPlatforms": "显示全部平台",
|
||||
"settings.botAccessShowConnectedOnly": "只看已连接平台",
|
||||
"settings.botListUsers": "用户",
|
||||
"settings.botListGroups": "群",
|
||||
"settings.botListApprovers": "审批人",
|
||||
"settings.botListAdmins": "管理员",
|
||||
"settings.botPlatformFeishuLark": "飞书 / Lark",
|
||||
"settings.botQueueDropLabel": "队列满时",
|
||||
"settings.botQueueDropHint": "队列达到上限后,如何处理放不下的消息。",
|
||||
"settings.botPairingDetailHint": "配对开关在第 2 步「谁可以使用」里;这里只调整配对码有效期和待批数量。",
|
||||
"settings.botSimpleAccessTitle": "谁可以使用",
|
||||
"settings.botSimpleAccessTrustedSummary": "当前使用受信任名单,已填写 {count} 条成员或群。",
|
||||
"settings.botSimpleAccessEveryoneSummary": "当前允许所有人使用,适合短时间测试。",
|
||||
"settings.botAccessTrusted": "受信任名单",
|
||||
"settings.botAccessTrustedHint": "只允许填写过的用户或群调用机器人。",
|
||||
"settings.botAccessEveryone": "所有人可用",
|
||||
"settings.botAccessEveryoneHint": "临时测试方便,但不建议长期打开。",
|
||||
"settings.botAccessPairing": "私聊先配对",
|
||||
"settings.botAccessPairingHint": "陌生私聊先发配对码,本机批准后再放行。",
|
||||
"settings.botRoleAccess": "管理员与审批人",
|
||||
"settings.botRoleAccessHint": "可选。普通用户不用填;需要远程审批或管理时再配置。",
|
||||
"settings.botQueueModeSimple": "新消息怎么处理",
|
||||
"settings.botQueueModeSimpleHint": "当机器人还在处理上一条消息时,选择下一条消息的处理方式。",
|
||||
"settings.saveBotSettings": "保存机器人设置",
|
||||
"settings.botEnableBot": "启用机器人",
|
||||
"settings.botGatewayMasterSwitch": "总开关",
|
||||
@@ -1086,8 +1236,48 @@ export const zh: Record<DictKey, string> = {
|
||||
"settings.botModelHint": "留空时使用默认模型。",
|
||||
"settings.botModelDefault": "使用默认模型",
|
||||
"settings.botRuntime": "运行限制",
|
||||
"settings.botRuntimeHint": "网关进程级限制,控制每次机器人任务最多走多远。",
|
||||
"settings.botMaxSteps": "最大步数",
|
||||
"settings.botDebounceMs": "合并窗口 ms",
|
||||
"settings.botQueueCap": "队列上限",
|
||||
"settings.botQueueMode.steer": "引导当前回合",
|
||||
"settings.botQueueMode.followup": "追加跟进",
|
||||
"settings.botQueueMode.collect": "收集等待",
|
||||
"settings.botQueueMode.interrupt": "打断当前",
|
||||
"settings.botQueueDrop.summarize": "汇总溢出",
|
||||
"settings.botQueueDrop.old": "丢弃最旧",
|
||||
"settings.botQueueDrop.new": "丢弃最新",
|
||||
"settings.botIgnoreSelfMessages": "忽略自身回声",
|
||||
"settings.botIgnoreSelfMessagesHint": "避免 gateway 再次处理由已配置 Bot 身份发出的消息。",
|
||||
"settings.botSelfUserIds": "Bot 自身 ID",
|
||||
"settings.botSelfUserIdsHint": "可选填写各平台 Bot 自己的 user/open ID,用于过滤回声消息。",
|
||||
"settings.botPairing": "私聊配对",
|
||||
"settings.botPairingTTL": "有效分钟",
|
||||
"settings.botPairingMaxPending": "待批上限",
|
||||
"settings.botControlApi": "本机控制 API",
|
||||
"settings.botControlApiHint": "仅 loopback 的 HTTP API,可查状态和受控发送;需要从指定环境变量读取 Bearer token。",
|
||||
"settings.botControlAddr": "控制地址",
|
||||
"settings.botControlTokenEnv": "Token 变量",
|
||||
"settings.botGatewayDefaults": "网关运行设置",
|
||||
"settings.botGatewayDefaultsHint": "不用编辑 TOML,也能配置排队、配对和自身消息过滤。",
|
||||
"settings.botRoutes": "聊天绑定到项目",
|
||||
"settings.botRoutesHint": "把某个群、联系人或线程固定到指定工作目录、模型或审批方式。",
|
||||
"settings.botRoutesEmpty": "还没有绑定规则。添加一条规则,把某个聊天、用户或线程绑定到项目或模型。",
|
||||
"settings.botRouteTitle": "路由 {n}",
|
||||
"settings.botAddRoute": "添加路由",
|
||||
"settings.botRouteConnection": "来自哪个 Bot",
|
||||
"settings.botRouteAny": "任意",
|
||||
"settings.botRoutePlatform": "平台",
|
||||
"settings.botRouteChatType": "聊天类型",
|
||||
"settings.botRouteChatType.any": "任意",
|
||||
"settings.botRouteChatType.dm": "私聊",
|
||||
"settings.botRouteChatType.group": "群聊",
|
||||
"settings.botRouteChatType.guild": "频道",
|
||||
"settings.botRouteChatType.direct": "Direct",
|
||||
"settings.botRouteChatType.thread": "Thread",
|
||||
"settings.botRouteChatId": "群/聊天 ID",
|
||||
"settings.botRouteUserId": "联系人 ID",
|
||||
"settings.botRouteThreadId": "线程 ID",
|
||||
"settings.botChannels": "连接渠道",
|
||||
"settings.botChannelsHint": "选择渠道;QQ 手动配置,飞书、Lark 和微信扫码连接。",
|
||||
"settings.botQQHint": "QQ 官方 Bot API v2。",
|
||||
@@ -1142,8 +1332,7 @@ export const zh: Record<DictKey, string> = {
|
||||
"settings.botInstallLarkHint": "扫码创建 Lark PersonalAgent。",
|
||||
"settings.botInstallWeixinHint": "微信扫码登录并保存本地 token。",
|
||||
"settings.botConnectedBots": "已连接 Bot",
|
||||
"settings.botConnectedBotsSummary": "{online}/{total} 在线",
|
||||
"settings.botConnectionsEmpty": "还没有连接的机器人渠道。",
|
||||
"settings.botSelectBotHint": "选择左侧 Bot 进入配置。",
|
||||
"settings.botConnectionColumnChannel": "渠道",
|
||||
"settings.botConnectionColumnName": "机器人",
|
||||
"settings.botConnectionColumnRemote": "远端 ID",
|
||||
@@ -1161,16 +1350,17 @@ export const zh: Record<DictKey, string> = {
|
||||
"settings.botConnectionConnected": "已连接",
|
||||
"settings.botConnectionConfigured": "已配置",
|
||||
"settings.botConnectionDisconnected": "未连接",
|
||||
"settings.botManage": "高级设置",
|
||||
"settings.botManage": "修改配置",
|
||||
"settings.deleteBot": "删除机器人",
|
||||
"settings.deleteBotShort": "删除",
|
||||
"settings.confirmDeleteBot": "确认删除",
|
||||
"settings.deleteBotHint": "从已连接 Bot 中移除这个机器人;不会自动清除已保存的密钥。",
|
||||
"settings.botChannelModel": "模型",
|
||||
"settings.botChannelModelHint": "留空时使用 Bot 默认模型。",
|
||||
"settings.botChannelModelAuto": "自动",
|
||||
"settings.botChannelModelHint": "这个机器人处理消息时使用的模型。",
|
||||
"settings.botChannelModelAuto": "使用桌面当前模型",
|
||||
"settings.botToolApprovalMode": "工具审批",
|
||||
"settings.botToolApprovalModeHint": "控制该渠道是否在工具执行前请求确认;计划确认和 ask 问题仍会等待回复。",
|
||||
"settings.botToolApprovalMode.inherit": "跟随默认",
|
||||
"settings.botToolApprovalMode.inherit": "跟随机器人",
|
||||
"settings.botToolApprovalMode.ask": "询问",
|
||||
"settings.botToolApprovalMode.auto": "自动",
|
||||
"settings.botToolApprovalMode.yolo": "YOLO",
|
||||
@@ -1193,26 +1383,17 @@ export const zh: Record<DictKey, string> = {
|
||||
"settings.botDiagnosticReportFailed": "发送诊断报告失败。",
|
||||
"settings.botDiagnosticPrivacy": "仅包含已脱敏的连接元信息。",
|
||||
"settings.botAccessControl": "访问控制",
|
||||
"settings.botAccessControlHint": "默认使用白名单;临时测试可以改为允许所有人。",
|
||||
"settings.botAccessMode": "谁可以使用",
|
||||
"settings.botAccessAll": "所有人",
|
||||
"settings.botAccessWhitelist": "仅白名单",
|
||||
"settings.botEditAllowlist": "编辑白名单",
|
||||
"settings.botAllowlist": "白名单",
|
||||
"settings.botAllowlistHint": "限制哪些用户或群可以调用机器人。",
|
||||
"settings.botAllowlistEnabled": "启用白名单",
|
||||
"settings.botAllowAll": "允许所有人",
|
||||
"settings.botAllowAllWarn": "开启后所有进入渠道的消息都可以调用机器人。",
|
||||
"settings.botQQAccessRequired": "启用 QQ Bot 前,请先在高级设置里添加 QQ 用户/群白名单,或临时改为允许所有人。",
|
||||
"settings.botAllowlistEntries": "用户与群",
|
||||
"settings.botQQAccessRequired": "启用 QQ Bot 前,请先在第 2 步「谁可以使用」里添加 QQ 用户/群,或临时改为所有人可用。",
|
||||
"settings.botQQUsers": "QQ 用户",
|
||||
"settings.botQQGroups": "QQ群",
|
||||
"settings.botFeishuUsers": "飞书用户",
|
||||
"settings.botFeishuGroups": "飞书群",
|
||||
"settings.botFeishuLarkUsers": "飞书 / Lark 用户",
|
||||
"settings.botFeishuLarkGroups": "飞书 / Lark 群",
|
||||
"settings.botWeixinUsers": "微信用户",
|
||||
"settings.botWeixinGroups": "微信群",
|
||||
"settings.botListPlaceholder": "每行一个 ID,也可用逗号分隔",
|
||||
"settings.permissions": "权限",
|
||||
"settings.writerMode": "写操作模式",
|
||||
@@ -1224,17 +1405,23 @@ export const zh: Record<DictKey, string> = {
|
||||
"settings.yoloHint": "Yolo批准会跳过所有工具权限审批(写文件、bash)。deny 规则仍会拦截,ask 问题和计划确认仍会等待你选择。仅运行时生效,重启后失效。",
|
||||
"settings.addRule": "添加 {list} 规则…",
|
||||
"settings.sandboxTitle": "沙箱与工作区",
|
||||
"settings.sandboxBoundaryHint": "沙箱可写根只限制已批准的 Bash 和文件工具能写到哪里;它不会自动批准命令。若 Bash 沙箱为 enforce 但宿主机没有 OS 沙箱后端,shell 命令会被拒绝;只在可信工作区把 Bash 沙箱切到 off。",
|
||||
"settings.reloadSessionConfig": "应用手动配置修改",
|
||||
"settings.reloadSessionConfigHint": "设置页内修改会自动生效;此按钮用于应用手动编辑 config.toml / reasonix.toml 后的变更。",
|
||||
"settings.shellInterpreter": "Shell 解释器",
|
||||
"settings.shellAuto": "自动(优先 bash,Windows 无 bash 时用 PowerShell)",
|
||||
"settings.shellAuto": "自动(bash 优先)",
|
||||
"settings.shellBash": "bash",
|
||||
"settings.shellPowershell": "PowerShell(Windows PowerShell)",
|
||||
"settings.shellPwsh": "pwsh(PowerShell 7+)",
|
||||
"settings.bashSandbox": "Bash 沙箱",
|
||||
"settings.bashEnforce": "enforce(隔离 bash)",
|
||||
"settings.bashEnforce": "enforce(隔离 bash;失败即拒绝)",
|
||||
"settings.bashOff": "off(不受限运行)",
|
||||
"settings.allowNetwork": "允许沙箱内 bash 访问网络",
|
||||
"settings.workspaceRoot": "工作区根目录",
|
||||
"settings.workspaceDefault": "(默认:当前目录)",
|
||||
"settings.effectiveWriteRoots": "实际可写根",
|
||||
"settings.effectiveWriteRootsHint": "按当前工作区展开。设置页内修改会自动生效;手动编辑配置文件后可用上方按钮刷新当前会话。",
|
||||
"settings.noEffectiveWriteRoots": "暂无可写根",
|
||||
"settings.appearance": "外观",
|
||||
"settings.theme": "主题",
|
||||
"settings.themeStyle": "视觉风格",
|
||||
@@ -1320,6 +1507,9 @@ export const zh: Record<DictKey, string> = {
|
||||
"settings.textSizeXLarge": "特大",
|
||||
"settings.textSizeXXLarge": "超大",
|
||||
"settings.displayZoom": "显示缩放(需重启生效)",
|
||||
"settings.displayZoomDecrease": "缩小显示缩放",
|
||||
"settings.displayZoomIncrease": "放大显示缩放",
|
||||
"settings.displayZoomReset": "恢复显示缩放到 100%",
|
||||
"settings.fontFamily": "界面字体",
|
||||
"settings.fontFamilySystem": "系统默认",
|
||||
"settings.fontFamilyYaHei": "微软雅黑",
|
||||
@@ -1342,6 +1532,7 @@ export const zh: Record<DictKey, string> = {
|
||||
"settings.pageDesc.bots": "配置 QQ、飞书、Lark 和微信机器人渠道,管理每个 Bot 的模型和运行参数。",
|
||||
"settings.pageDesc.mcp": "管理 MCP 服务器连接和工具发现。",
|
||||
"settings.pageDesc.skills": "浏览、启用和管理智能体技能与来源。",
|
||||
"settings.pageDesc.plugins": "安装和管理插件包,用统一单元提供技能、hooks 与 MCP 服务器。",
|
||||
"settings.pageDesc.memory": "管理 Reasonix 会长期参考的偏好、项目约定和指令文件。",
|
||||
"settings.pageDesc.hooks": "配置围绕对话、工具调用、压缩和会话生命周期执行的 shell hooks。",
|
||||
"settings.pageDesc.shortcuts": "自定义键盘快捷键,并让帮助表保持同步。",
|
||||
@@ -1356,7 +1547,7 @@ export const zh: Record<DictKey, string> = {
|
||||
"settings.customProviders": "自定义供应商",
|
||||
"settings.customProvidersHint": "你自己添加的 OpenAI-compatible 或代理接入,可以编辑、删除并持久化模型列表。",
|
||||
"settings.customProvidersEmpty": "还没有自定义供应商。常用服务优先使用上方内置接入。",
|
||||
"settings.providerAccessEmptyHint": "点击“添加供应商”,选择 DeepSeek 或自定义 OpenAI-compatible 接入。",
|
||||
"settings.providerAccessEmptyHint": "使用“推荐预设”可快速接入常用供应商,通常只需填写 API Key;使用“自定义供应商”可手动配置 OpenAI-compatible 或 Anthropic-compatible 端点。",
|
||||
"settings.builtinProviderBadge": "内置",
|
||||
"settings.customProviderBadge": "自定义",
|
||||
"settings.configureProvider": "配置",
|
||||
@@ -1368,24 +1559,77 @@ export const zh: Record<DictKey, string> = {
|
||||
"settings.searchModels": "搜索模型...",
|
||||
"settings.noMatchingModels": "没有匹配的模型",
|
||||
"settings.addProvider.chooseTitle": "添加供应商",
|
||||
"settings.addProvider.chooseHint": "选择官方预设,或添加一个自定义 OpenAI-compatible 接入。",
|
||||
"settings.addProvider.officialChoice": "官方供应商",
|
||||
"settings.addProvider.officialChoiceHint": "从 Reasonix 预设的 DeepSeek 接入中选择。",
|
||||
"settings.addProvider.chooseHint": "选择推荐预设,或手动添加一个自定义供应商。",
|
||||
"settings.addProvider.officialChoice": "推荐预设",
|
||||
"settings.addProvider.officialChoiceHint": "选择 DeepSeek 或已适配的自定义供应商预设。",
|
||||
"settings.addProvider.customChoice": "自定义供应商",
|
||||
"settings.addProvider.customChoiceHint": "按 base_url、模型、密钥环境变量等参数手动配置。",
|
||||
"settings.addProvider.officialTitle": "添加官方供应商",
|
||||
"settings.addProvider.officialHint": "官方预设会写入固定端点和默认参数,只需要选择供应商并配置密钥。",
|
||||
"settings.addProvider.officialHint": "推荐预设会填好端点、模型、视觉和推理默认值,通常只需要填 API Key。",
|
||||
"settings.addProvider.customTitle": "添加自定义供应商",
|
||||
"settings.addProvider.customHint": "适合代理、聚合平台或其它 OpenAI-compatible 服务。",
|
||||
"settings.addProvider.official.deepseek": "DeepSeek 官方",
|
||||
"settings.addProvider.official.deepseekDesc": "官方 API Key 接入,可用 DeepSeek V4 Flash / Pro。",
|
||||
"settings.addProvider.preset.longcatOpenAIDesc": "LongCat 开放平台 OpenAI-compatible 端点,默认 LongCat-2.0。",
|
||||
"settings.addProvider.preset.longcatAnthropicDesc": "LongCat 开放平台 Anthropic-compatible Messages 端点,使用 Bearer 认证。",
|
||||
"settings.addProvider.preset.kimiCnDesc": "Kimi 国内 API,走 Moonshot CN 端点,预置 K2 视觉模型。",
|
||||
"settings.addProvider.preset.kimiGlobalDesc": "Kimi 国际 API,走 Moonshot global 端点,预置 K2 视觉模型。",
|
||||
"settings.addProvider.preset.kimiCodingPlanDesc": "Kimi Coding Plan,走独立 Anthropic-compatible 端点,不归属 CN/Global。",
|
||||
"settings.addProvider.preset.mimoApiDesc": "小米 MiMo 直连 API,适合 v2.5 按量付费模型。",
|
||||
"settings.addProvider.preset.mimoAnthropicDesc": "小米 MiMo 直连 Anthropic-compatible 端点,自动使用 MiMo v2.5 模型。",
|
||||
"settings.addProvider.preset.mimoTokenPlanCnDesc": "小米 MiMo token plan 中国区端点。",
|
||||
"settings.addProvider.preset.mimoTokenPlanCnAnthropicDesc": "小米 MiMo token plan 中国区 Anthropic-compatible 端点。",
|
||||
"settings.addProvider.preset.mimoTokenPlanSgpDesc": "小米 MiMo token plan 新加坡端点。",
|
||||
"settings.addProvider.preset.mimoTokenPlanSgpAnthropicDesc": "小米 MiMo token plan 新加坡 Anthropic-compatible 端点。",
|
||||
"settings.addProvider.preset.mimoTokenPlanAmsDesc": "小米 MiMo token plan 阿姆斯特丹端点。",
|
||||
"settings.addProvider.preset.mimoTokenPlanAmsAnthropicDesc": "小米 MiMo token plan 阿姆斯特丹 Anthropic-compatible 端点。",
|
||||
"settings.addProvider.preset.minimaxCnApiDesc": "MiniMax 国内 OpenAI-compatible M 系列 API 端点。",
|
||||
"settings.addProvider.preset.minimaxGlobalApiDesc": "MiniMax 国际 OpenAI-compatible M 系列 API 端点。",
|
||||
"settings.addProvider.preset.minimaxCnAnthropicDesc": "MiniMax 国内 Anthropic-compatible M 系列端点。",
|
||||
"settings.addProvider.preset.minimaxGlobalAnthropicDesc": "MiniMax 国际 Anthropic-compatible 端点,自动使用 Bearer 认证。",
|
||||
"settings.addProvider.preset.glmCnDesc": "智谱 GLM 国内 API,预置 GLM thinking 控制。",
|
||||
"settings.addProvider.preset.zaiGlobalDesc": "Z.AI 国际 GLM API。",
|
||||
"settings.addProvider.preset.glmCodingPlanCnDesc": "智谱 GLM 国内 coding plan 端点,默认 GLM-5.2。",
|
||||
"settings.addProvider.preset.glmCodingPlanCnAnthropicDesc": "智谱 GLM 国内 Anthropic-compatible coding plan 端点,默认 GLM-5.2[1m]。",
|
||||
"settings.addProvider.preset.zaiCodingPlanGlobalDesc": "Z.AI 国际 coding plan 端点,默认 GLM-5.2。",
|
||||
"settings.addProvider.preset.zaiCodingPlanGlobalAnthropicDesc": "Z.AI 国际 Anthropic-compatible coding plan 端点,默认 GLM-5.2[1m]。",
|
||||
"settings.addProvider.preset.opencodeGoDesc": "OpenCode Go 混合模型网关,内置每模型能力覆盖。",
|
||||
"settings.addProvider.preset.opencodeGoAnthropicDesc": "OpenCode Go Anthropic 线路,预置 Qwen 与 MiniMax 模型。",
|
||||
"settings.addProvider.preset.opencodeZenAnthropicDesc": "OpenCode Zen Anthropic 线路,预置 Claude 与 Qwen 模型。",
|
||||
"settings.addProvider.preset.qwenCnDesc": "Qwen/DashScope 国内标准 API 端点。",
|
||||
"settings.addProvider.preset.qwenGlobalDesc": "Qwen/DashScope 国际标准 API 端点。",
|
||||
"settings.addProvider.preset.qwenCodingPlanCnDesc": "Qwen Coding Plan 国内 OpenAI-compatible 端点,默认 qwen3.7-plus。",
|
||||
"settings.addProvider.preset.qwenCodingPlanCnAnthropicDesc": "Qwen Coding Plan 国内 Anthropic-compatible 端点,默认 qwen3.7-plus。",
|
||||
"settings.addProvider.preset.qwenCodingPlanGlobalDesc": "Qwen Coding Plan 国际 OpenAI-compatible 端点,默认 qwen3.7-plus。",
|
||||
"settings.addProvider.preset.qwenCodingPlanGlobalAnthropicDesc": "Qwen Coding Plan 国际 Anthropic-compatible 端点,默认 qwen3.7-plus。",
|
||||
"settings.addProvider.preset.stepfunDesc": "阶跃星辰 StepFun coding plan OpenAI-compatible 端点,默认 step-3.7-flash。",
|
||||
"settings.addProvider.preset.stepfunAnthropicDesc": "阶跃星辰 StepFun coding plan Anthropic-compatible 端点,默认 step-3.7-flash。",
|
||||
"settings.addProvider.preset.novitaDesc": "NovitaAI 多模型网关,预置 Kimi、MiniMax、GLM、DeepSeek、Qwen。",
|
||||
"settings.addProvider.preset.gmiDesc": "GMI Cloud 多模型直连网关,预置 GLM、DeepSeek、Kimi、Gemini、Claude、GPT。",
|
||||
"settings.addProvider.preset.vercelAiGatewayDesc": "Vercel AI Gateway,走 Anthropic-compatible Messages API。",
|
||||
"settings.addProvider.preset.huggingfaceDesc": "HuggingFace Inference Router,预置 Qwen 与 DeepSeek 模型。",
|
||||
"settings.addProvider.preset.nvidiaDesc": "NVIDIA NIM 加速推理端点,预置 Nemotron 模型。",
|
||||
"settings.addProvider.preset.kilocodeDesc": "Kilo Code 网关,预置 kilo/auto 自动路由模型。",
|
||||
"settings.addProvider.preset.ollamaCloudDesc": "Ollama Cloud 托管端点,支持 none 到 max 的 reasoning effort。",
|
||||
"settings.addProvider.addedBadge": "已添加",
|
||||
"settings.addProvider.modifiedBadge": "已修改",
|
||||
"settings.addProvider.nameConflictBadge": "同名占用",
|
||||
"settings.addProvider.similarExistingBadge": "有相似配置",
|
||||
"settings.addProvider.confirm": "添加供应商",
|
||||
"settings.addProvider.alreadyAddedAction": "已添加",
|
||||
"settings.addProvider.nameConflictAction": "先处理同名配置",
|
||||
"settings.addProvider.viewPresetProvider": "查看配置",
|
||||
"settings.addProvider.viewConflictProvider": "查看同名配置",
|
||||
"settings.addProvider.resetPreset": "重置为预设",
|
||||
"settings.addProvider.confirmResetPreset": "确认重置",
|
||||
"settings.customProviderName": "自定义供应商名称",
|
||||
"settings.customProviderNamePlaceholder": "",
|
||||
"settings.providerNamePlaceholder": "供应商名称(如 deepseek-proxy)",
|
||||
"settings.providerProtocol": "接入协议",
|
||||
"settings.providerProtocolOpenAI": "OpenAI-compatible",
|
||||
"settings.providerProtocolOpenAIHint": "适用于兼容 OpenAI API 的代理、聚合平台和自建服务。",
|
||||
"settings.providerProtocolAnthropic": "Anthropic-compatible",
|
||||
"settings.providerProtocolAnthropicHint": "适用于 Claude Messages API 风格的服务,例如部分 coding plan 端点。",
|
||||
"settings.providerBaseUrlLabel": "API 地址",
|
||||
"settings.providerChatUrlLabel": "完整请求 URL",
|
||||
"settings.providerChatUrlPlaceholder": "例如 https://api.openai.com/v1/chat/completions",
|
||||
@@ -1408,6 +1652,12 @@ export const zh: Record<DictKey, string> = {
|
||||
"settings.providerHeaders": "额外请求头(可选)",
|
||||
"settings.providerHeadersPlaceholder": "HTTP-Referer: https://example.com\nX-Title: Reasonix",
|
||||
"settings.providerHeadersHint": "用于 OpenRouter 等要求站点来源的中转站。一行一个 Header: value;API Key 仍通过上方密钥保存。",
|
||||
"settings.providerExtraBody": "额外请求体(可选)",
|
||||
"settings.providerExtraBodyPlaceholder": "{\n \"enable_thinking\": true\n}",
|
||||
"settings.providerExtraBodyHint": "会合并到聊天请求体顶层 JSON。model、messages、tools、stream、thinking 等核心字段仍由 Reasonix 控制。",
|
||||
"settings.providerExtraBodyError": "请输入不含 null 值的 JSON 对象,例如 { \"enable_thinking\": true }。",
|
||||
"settings.providerAuthHeader": "使用 Authorization: Bearer 发送密钥",
|
||||
"settings.providerAuthHeaderHint": "适用于 MiniMax Global、Vercel AI Gateway 等不使用 x-api-key 的 Anthropic-compatible 网关。",
|
||||
"settings.fetchingModels": "刷新中…",
|
||||
"settings.fetchModelsSuccess": "已获取 {n} 个模型",
|
||||
"settings.fetchModelsManualFallbackAuth": "模型发现接口拒绝了密钥或权限。请检查 API Key;如果该服务不开放模型发现,也可以在下方手动填写模型 ID。",
|
||||
@@ -1427,10 +1677,16 @@ export const zh: Record<DictKey, string> = {
|
||||
"settings.newProviderDraftName": "新供应商",
|
||||
"settings.reasoningProtocol": "模型能力模式",
|
||||
"settings.reasoningProtocol.auto": "自动识别(推荐)",
|
||||
"settings.reasoningProtocol.deepseek": "DeepSeek 思考",
|
||||
"settings.reasoningProtocol.openai": "OpenAI reasoning",
|
||||
"settings.reasoningProtocol.deepseek": "DeepSeek 思考(thinking + 深度)",
|
||||
"settings.reasoningProtocol.openai": "OpenAI reasoning(reasoning_effort)",
|
||||
"settings.reasoningProtocol.none": "普通聊天(不发送思考参数)",
|
||||
"settings.reasoningProtocolHint": "默认自动识别。只有代理不兼容,或你确定模型需要特定 reasoning 格式时再切换。",
|
||||
"settings.thinkingMode": "Thinking 覆盖",
|
||||
"settings.thinkingMode.auto": "Auto(使用服务默认)",
|
||||
"settings.thinkingMode.enabled": "Enabled(开启)",
|
||||
"settings.thinkingMode.disabled": "Disabled(关闭)",
|
||||
"settings.thinkingMode.adaptive": "Adaptive(自适应)",
|
||||
"settings.thinkingModeHint": "服务商专用覆盖项。兼容网关可用 enabled/disabled;只有服务文档明确支持时才使用 adaptive。",
|
||||
"settings.updateKey": "更新 {env}(全局保存)",
|
||||
"settings.updateKeyAction": "更新密钥",
|
||||
"settings.keyStatus": "密钥",
|
||||
@@ -1545,6 +1801,8 @@ export const zh: Record<DictKey, string> = {
|
||||
"msg.imageAttachment": "图片",
|
||||
"msg.workspaceReference": "工作区引用",
|
||||
"msg.folderReference": "文件夹 · 工作区引用",
|
||||
"msg.pastedExpandTooltip": "展开粘贴文本",
|
||||
"msg.pastedCollapseTooltip": "收起粘贴文本",
|
||||
"turnActions.summary": "总结",
|
||||
"turnActions.rewind": "回溯",
|
||||
"transcript.jumpToBottom": "跳到底部",
|
||||
@@ -1626,7 +1884,7 @@ export const zh: Record<DictKey, string> = {
|
||||
"settings.telemetryLabel": "匿名启动统计",
|
||||
"settings.telemetryHint": "启动时发送随机安装 ID、版本号和操作系统用于统计活跃安装量;若上次运行发生崩溃,则在本次启动补发该崩溃报告。绝不包含对话、密钥或文件内容。",
|
||||
"settings.metricsLabel": "共享聚合质量指标",
|
||||
"settings.metricsHint": "默认开启。发送匿名的轮次结束统计、设置偏好快照,以及 Memory v5 聚合桶,例如是否注入、大小桶、IR 数量桶和记忆图规模桶。包含用于 DAU 去重的随机安装 ID。Bucket 可能包含归一化后的自定义 Provider 名和模型名——绝不包含对话、提示词、密钥、路径、base URL、记忆正文、工具输出或文件内容。",
|
||||
"settings.metricsHint": "默认开启。发送匿名的轮次结束统计、更新器错误类别、设置偏好快照,以及 Memory v5 聚合桶,例如是否注入、大小桶、IR 数量桶和记忆图规模桶。包含用于 DAU 去重的随机安装 ID。Bucket 可能包含归一化后的自定义 Provider 名和模型名——绝不包含对话、提示词、密钥、路径、base URL、记忆正文、工具输出或文件内容。",
|
||||
"updater.currentVersion": "当前版本:{v}",
|
||||
"updater.channelLabel": "更新渠道:{channel}",
|
||||
"updater.checkButton": "检查更新",
|
||||
|
||||
+1923
-169
File diff suppressed because it is too large.
Load diff
@@ -25,7 +25,9 @@ require (
|
||||
require (
|
||||
git.sr.ht/~jackmordaunt/go-toast/v2 v2.0.3 // indirect
|
||||
github.com/BurntSushi/toml v1.6.0 // indirect
|
||||
github.com/aymanbagabas/go-udiff v0.4.1 // indirect
|
||||
github.com/bep/debounce v1.2.1 // indirect
|
||||
github.com/bmatcuk/doublestar/v4 v4.10.0 // indirect
|
||||
github.com/clipperhouse/uax29/v2 v2.7.0 // indirect
|
||||
github.com/danieljoos/wincred v1.2.3 // indirect
|
||||
github.com/go-ole/go-ole v1.3.0 // indirect
|
||||
@@ -33,6 +35,7 @@ require (
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/gorilla/websocket v1.5.3 // indirect
|
||||
github.com/jchv/go-winloader v0.0.0-20250406163304-c1995be93bd1 // indirect
|
||||
github.com/joho/godotenv v1.5.1 // indirect
|
||||
github.com/labstack/echo/v4 v4.15.2 // indirect
|
||||
github.com/labstack/gommon v0.5.0 // indirect
|
||||
github.com/larksuite/oapi-sdk-go/v3 v3.9.5 // indirect
|
||||
@@ -42,6 +45,7 @@ require (
|
||||
github.com/leaanthony/u v1.1.1 // indirect
|
||||
github.com/mattn/go-colorable v0.1.15 // indirect
|
||||
github.com/mattn/go-isatty v0.0.22 // indirect
|
||||
github.com/mattn/go-pointer v0.0.1 // indirect
|
||||
github.com/mattn/go-runewidth v0.0.24 // indirect
|
||||
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect
|
||||
github.com/pkg/errors v0.9.1 // indirect
|
||||
@@ -49,6 +53,11 @@ require (
|
||||
github.com/sabhiram/go-gitignore v0.0.0-20210923224102-525f6e181f06 // indirect
|
||||
github.com/samber/lo v1.53.0 // indirect
|
||||
github.com/tkrajina/go-reflector v0.5.8 // indirect
|
||||
github.com/tree-sitter/go-tree-sitter v0.25.0 // indirect
|
||||
github.com/tree-sitter/tree-sitter-javascript v0.25.0 // indirect
|
||||
github.com/tree-sitter/tree-sitter-python v0.25.0 // indirect
|
||||
github.com/tree-sitter/tree-sitter-rust v0.24.2 // indirect
|
||||
github.com/tree-sitter/tree-sitter-typescript v0.23.2 // indirect
|
||||
github.com/valyala/bytebufferpool v1.0.0 // indirect
|
||||
github.com/valyala/fasttemplate v1.2.2 // indirect
|
||||
github.com/wailsapp/go-webview2 v1.0.23 // indirect
|
||||
@@ -57,6 +66,8 @@ require (
|
||||
golang.org/x/crypto v0.53.0 // indirect
|
||||
golang.org/x/image v0.43.0 // indirect
|
||||
golang.org/x/net v0.56.0 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
mvdan.cc/sh/v3 v3.13.1 // indirect
|
||||
)
|
||||
|
||||
replace reasonix => ../
|
||||
@@ -7,8 +7,12 @@ git.sr.ht/~jackmordaunt/go-toast/v2 v2.0.3 h1:N3IGoHHp9pb6mj1cbXbuaSXV/UMKwmbKLf
|
||||
git.sr.ht/~jackmordaunt/go-toast/v2 v2.0.3/go.mod h1:QtOLZGz8olr4qH2vWK0QH0w0O4T9fEIjMuWpKUsH7nc=
|
||||
github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk=
|
||||
github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
|
||||
github.com/aymanbagabas/go-udiff v0.4.1 h1:OEIrQ8maEeDBXQDoGCbbTTXYJMYRCRO1fnodZ12Gv5o=
|
||||
github.com/aymanbagabas/go-udiff v0.4.1/go.mod h1:0L9PGwj20lrtmEMeyw4WKJ/TMyDtvAoK9bf2u/mNo3w=
|
||||
github.com/bep/debounce v1.2.1 h1:v67fRdBA9UQu2NhLFXrSg0Brw7CexQekrBwDMM8bzeY=
|
||||
github.com/bep/debounce v1.2.1/go.mod h1:H8yggRPQKLUhUoqrJC1bO2xNya7vanpDl7xR3ISbCJ0=
|
||||
github.com/bmatcuk/doublestar/v4 v4.10.0 h1:zU9WiOla1YA122oLM6i4EXvGW62DvKZVxIe6TYWexEs=
|
||||
github.com/bmatcuk/doublestar/v4 v4.10.0/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
|
||||
github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk=
|
||||
github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM=
|
||||
github.com/danieljoos/wincred v1.2.3 h1:v7dZC2x32Ut3nEfRH+vhoZGvN72+dQ/snVXo/vMFLdQ=
|
||||
@@ -18,10 +22,14 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE=
|
||||
github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78=
|
||||
github.com/go-quicktest/qt v1.101.0 h1:O1K29Txy5P2OK0dGo59b7b0LR6wKfIhttaAhHUyn7eI=
|
||||
github.com/go-quicktest/qt v1.101.0/go.mod h1:14Bz/f7NwaXPtdYEgzsx46kqSxVwTbzVZsDC26tQJow=
|
||||
github.com/godbus/dbus/v5 v5.2.2 h1:TUR3TgtSVDmjiXOgAAyaZbYmIeP3DPkld3jgKGV8mXQ=
|
||||
github.com/godbus/dbus/v5 v5.2.2/go.mod h1:3AAv2+hPq5rdnr5txxxRwiGjPXamgoIHgz9FPBfOp3c=
|
||||
github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
|
||||
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||
@@ -29,8 +37,14 @@ github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aN
|
||||
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||
github.com/jchv/go-winloader v0.0.0-20250406163304-c1995be93bd1 h1:njuLRcjAuMKr7kI3D85AXWkw6/+v9PwtV6M6o11sWHQ=
|
||||
github.com/jchv/go-winloader v0.0.0-20250406163304-c1995be93bd1/go.mod h1:alcuEEnZsY1WQsagKhZDsoPCRoOijYqhZvPwLG0kzVs=
|
||||
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
|
||||
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
|
||||
github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8=
|
||||
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
|
||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/labstack/echo/v4 v4.15.2 h1:nnh2sCzGCVYnU+wCisMPiYapEg/QVo/gcI9ePKg5/T4=
|
||||
github.com/labstack/echo/v4 v4.15.2/go.mod h1:Xzp1Ns1RA2c9fY7nSgUJkpkUZGNbEIVHZbtbOMPktBI=
|
||||
github.com/labstack/gommon v0.5.0 h1:6VSQ2NOzsnEJ5W6+84E0RbcaDDmgB6NIAzWCczTEe6c=
|
||||
@@ -54,6 +68,8 @@ github.com/mattn/go-colorable v0.1.15 h1:+u9SLTRGnXv73cEsnsmoZBom+dMU88B2M0aDcWy
|
||||
github.com/mattn/go-colorable v0.1.15/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
|
||||
github.com/mattn/go-isatty v0.0.22 h1:j8l17JJ9i6VGPUFUYoTUKPSgKe/83EYU2zBC7YNKMw4=
|
||||
github.com/mattn/go-isatty v0.0.22/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4=
|
||||
github.com/mattn/go-pointer v0.0.1 h1:n+XhsuGeVO6MEAp7xyEukFINEa+Quek5psIR/ylA6o0=
|
||||
github.com/mattn/go-pointer v0.0.1/go.mod h1:2zXcozF6qYGgmsG+SeTZz3oAbFLdD3OWqnUbNvJZAlc=
|
||||
github.com/mattn/go-runewidth v0.0.24 h1:cpokDiIn0MGnhdHwuWnJBITySJ20QyNGnY2kR/ay2DU=
|
||||
github.com/mattn/go-runewidth v0.0.24/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
|
||||
github.com/minio/selfupdate v0.6.0 h1:i76PgT0K5xO9+hjzKcacQtO7+MjJ4JKA8Ak8XQ9DDwU=
|
||||
@@ -67,6 +83,8 @@ github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZN
|
||||
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
|
||||
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
|
||||
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
|
||||
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
|
||||
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
|
||||
github.com/sabhiram/go-gitignore v0.0.0-20210923224102-525f6e181f06 h1:OkMGxebDjyw0ULyrTYWeN0UNCCkmCWfjPnIA2W6oviI=
|
||||
github.com/sabhiram/go-gitignore v0.0.0-20210923224102-525f6e181f06/go.mod h1:+ePHsJ1keEjQtpvf9HHw0f4ZeJ0TLRsxhunSI2hYJSs=
|
||||
github.com/samber/lo v1.53.0 h1:t975lj2py4kJPQ6haz1QMgtId2gtmfktACxIXArw3HM=
|
||||
@@ -79,6 +97,34 @@ github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/tkrajina/go-reflector v0.5.8 h1:yPADHrwmUbMq4RGEyaOUpz2H90sRsETNVpjzo3DLVQQ=
|
||||
github.com/tkrajina/go-reflector v0.5.8/go.mod h1:ECbqLgccecY5kPmPmXg1MrHW585yMcDkVl6IvJe64T4=
|
||||
github.com/tree-sitter/go-tree-sitter v0.25.0 h1:sx6kcg8raRFCvc9BnXglke6axya12krCJF5xJ2sftRU=
|
||||
github.com/tree-sitter/go-tree-sitter v0.25.0/go.mod h1:r77ig7BikoZhHrrsjAnv8RqGti5rtSyvDHPzgTPsUuU=
|
||||
github.com/tree-sitter/tree-sitter-c v0.23.4 h1:nBPH3FV07DzAD7p0GfNvXM+Y7pNIoPenQWBpvM++t4c=
|
||||
github.com/tree-sitter/tree-sitter-c v0.23.4/go.mod h1:MkI5dOiIpeN94LNjeCp8ljXN/953JCwAby4bClMr6bw=
|
||||
github.com/tree-sitter/tree-sitter-cpp v0.23.4 h1:LaWZsiqQKvR65yHgKmnaqA+uz6tlDJTJFCyFIeZU/8w=
|
||||
github.com/tree-sitter/tree-sitter-cpp v0.23.4/go.mod h1:doqNW64BriC7WBCQ1klf0KmJpdEvfxyXtoEybnBo6v8=
|
||||
github.com/tree-sitter/tree-sitter-embedded-template v0.23.2 h1:nFkkH6Sbe56EXLmZBqHHcamTpmz3TId97I16EnGy4rg=
|
||||
github.com/tree-sitter/tree-sitter-embedded-template v0.23.2/go.mod h1:HNPOhN0qF3hWluYLdxWs5WbzP/iE4aaRVPMsdxuzIaQ=
|
||||
github.com/tree-sitter/tree-sitter-go v0.23.4 h1:yt5KMGnTHS+86pJmLIAZMWxukr8W7Ae1STPvQUuNROA=
|
||||
github.com/tree-sitter/tree-sitter-go v0.23.4/go.mod h1:Jrx8QqYN0v7npv1fJRH1AznddllYiCMUChtVjxPK040=
|
||||
github.com/tree-sitter/tree-sitter-html v0.23.2 h1:1UYDV+Yd05GGRhVnTcbP58GkKLSHHZwVaN+lBZV11Lc=
|
||||
github.com/tree-sitter/tree-sitter-html v0.23.2/go.mod h1:gpUv/dG3Xl/eebqgeYeFMt+JLOY9cgFinb/Nw08a9og=
|
||||
github.com/tree-sitter/tree-sitter-java v0.23.5 h1:J9YeMGMwXYlKSP3K4Us8CitC6hjtMjqpeOf2GGo6tig=
|
||||
github.com/tree-sitter/tree-sitter-java v0.23.5/go.mod h1:NRKlI8+EznxA7t1Yt3xtraPk1Wzqh3GAIC46wxvc320=
|
||||
github.com/tree-sitter/tree-sitter-javascript v0.25.0 h1:ZkWETb66/w8cc13yhfnNuHOLDQWl3BnKlH6f9AdR88c=
|
||||
github.com/tree-sitter/tree-sitter-javascript v0.25.0/go.mod h1:lmGD1EJdCA+v0S1u2fFgepMg/opzSg/4pgFym2FPGAs=
|
||||
github.com/tree-sitter/tree-sitter-json v0.24.8 h1:tV5rMkihgtiOe14a9LHfDY5kzTl5GNUYe6carZBn0fQ=
|
||||
github.com/tree-sitter/tree-sitter-json v0.24.8/go.mod h1:F351KK0KGvCaYbZ5zxwx/gWWvZhIDl0eMtn+1r+gQbo=
|
||||
github.com/tree-sitter/tree-sitter-php v0.23.11 h1:iHewsLNDmznh8kgGyfWfujsZxIz1YGbSd2ZTEM0ZiP8=
|
||||
github.com/tree-sitter/tree-sitter-php v0.23.11/go.mod h1:T/kbfi+UcCywQfUNAJnGTN/fMSUjnwPXA8k4yoIks74=
|
||||
github.com/tree-sitter/tree-sitter-python v0.25.0 h1:O6XD9v8U1LOcRc3cNj9nM7XufrtEBezE6VrpRrHZDf0=
|
||||
github.com/tree-sitter/tree-sitter-python v0.25.0/go.mod h1:cpdthSy/Yoa28aJFBscFHlGiU+cnSiSh1kuDVtI8YeM=
|
||||
github.com/tree-sitter/tree-sitter-ruby v0.23.1 h1:T/NKHUA+iVbHM440hFx+lzVOzS4dV6z8Qw8ai+72bYo=
|
||||
github.com/tree-sitter/tree-sitter-ruby v0.23.1/go.mod h1:kUS4kCCQloFcdX6sdpr8p6r2rogbM6ZjTox5ZOQy8cA=
|
||||
github.com/tree-sitter/tree-sitter-rust v0.24.2 h1:NL4nF67ib21RMzzfvkmXlVwe45vvhW10DVyO+D0z/W0=
|
||||
github.com/tree-sitter/tree-sitter-rust v0.24.2/go.mod h1:hfeGWic9BAfgTrc7Xf6FaOAguCFJRo3RBbs7QJ6D7MI=
|
||||
github.com/tree-sitter/tree-sitter-typescript v0.23.2 h1:/Odvphn18PniVixb9e97X0DbNVsU6Qocv9mfkyzdXwU=
|
||||
github.com/tree-sitter/tree-sitter-typescript v0.23.2/go.mod h1:zjzMXT/Ulffel2xfOcAkQQkiAkmgnbtPGlFQw/5X4xA=
|
||||
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
|
||||
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
|
||||
github.com/valyala/fasttemplate v1.2.2 h1:lxLXG0uE3Qnshl9QyaK6XJxMXlQZELvChBOCmQD0Loo=
|
||||
@@ -146,7 +192,10 @@ golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8T
|
||||
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
mvdan.cc/sh/v3 v3.13.1 h1:DP3TfgZhDkT7lerUdnp6PTGKyxxzz6T+cOlY/xEvfWk=
|
||||
mvdan.cc/sh/v3 v3.13.1/go.mod h1:lXJ8SexMvEVcHCoDvAGLZgFJ9Wsm2sulmoNEXGhYZD0=
|
||||
@@ -0,0 +1,172 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
mainThreadHeartbeatInterval = time.Second
|
||||
mainThreadHangThreshold = 12 * time.Second
|
||||
mainThreadHangCheckInterval = 2 * time.Second
|
||||
mainThreadSleepSkip = 30 * time.Second
|
||||
)
|
||||
|
||||
var (
|
||||
mainThreadClockBase = time.Now()
|
||||
mainThreadLastHeartbeatElapsed atomic.Int64
|
||||
mainThreadLastHeartbeatWall atomic.Int64
|
||||
mainThreadHangReported atomic.Bool
|
||||
)
|
||||
|
||||
func recordMainThreadHeartbeat(t time.Time) {
|
||||
elapsed := t.Sub(mainThreadClockBase)
|
||||
if elapsed < 0 {
|
||||
elapsed = 0
|
||||
}
|
||||
mainThreadLastHeartbeatElapsed.Store(int64(elapsed))
|
||||
mainThreadLastHeartbeatWall.Store(t.UnixNano())
|
||||
}
|
||||
|
||||
func mainThreadHeartbeatAge(now time.Time) (time.Duration, time.Time, bool) {
|
||||
lastElapsed := time.Duration(mainThreadLastHeartbeatElapsed.Load())
|
||||
lastWall := mainThreadLastHeartbeatWall.Load()
|
||||
if lastWall <= 0 {
|
||||
return 0, time.Time{}, false
|
||||
}
|
||||
age := now.Sub(mainThreadClockBase) - lastElapsed
|
||||
if age < 0 {
|
||||
age = 0
|
||||
}
|
||||
return age, time.Unix(0, lastWall), true
|
||||
}
|
||||
|
||||
func (a *App) startMainThreadWatchdog() {
|
||||
if !mainThreadWatchdogSupported() {
|
||||
return
|
||||
}
|
||||
a.hangWatchdogMu.Lock()
|
||||
if a.hangWatchdogCancel != nil {
|
||||
a.hangWatchdogMu.Unlock()
|
||||
return
|
||||
}
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
a.hangWatchdogCancel = cancel
|
||||
mainThreadHangReported.Store(false)
|
||||
recordMainThreadHeartbeat(time.Now())
|
||||
startNativeMainThreadHeartbeat(uint64(mainThreadHeartbeatInterval / time.Millisecond))
|
||||
a.hangWatchdogMu.Unlock()
|
||||
|
||||
a.goSafe("mainThreadHangWatchdog", func() {
|
||||
a.watchMainThreadHeartbeat(ctx)
|
||||
})
|
||||
}
|
||||
|
||||
func (a *App) stopMainThreadWatchdog() {
|
||||
if !mainThreadWatchdogSupported() {
|
||||
return
|
||||
}
|
||||
a.hangWatchdogMu.Lock()
|
||||
cancel := a.hangWatchdogCancel
|
||||
a.hangWatchdogCancel = nil
|
||||
a.hangWatchdogMu.Unlock()
|
||||
if cancel != nil {
|
||||
cancel()
|
||||
}
|
||||
stopNativeMainThreadHeartbeat()
|
||||
}
|
||||
|
||||
func (a *App) watchMainThreadHeartbeat(ctx context.Context) {
|
||||
ticker := time.NewTicker(mainThreadHangCheckInterval)
|
||||
defer ticker.Stop()
|
||||
lastCheck := time.Now()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case now := <-ticker.C:
|
||||
if now.Sub(lastCheck) > mainThreadSleepSkip {
|
||||
lastCheck = now
|
||||
continue
|
||||
}
|
||||
lastCheck = now
|
||||
age, last, ok := mainThreadHeartbeatAge(now)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if age < mainThreadHangThreshold {
|
||||
continue
|
||||
}
|
||||
if mainThreadHangReported.CompareAndSwap(false, true) {
|
||||
a.recordMainThreadHang(age, last, now)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (a *App) recordMainThreadHang(age time.Duration, lastHeartbeat, observedAt time.Time) {
|
||||
report := mainThreadHangReport(age, lastHeartbeat, observedAt)
|
||||
wrote := writePendingReport(report, false)
|
||||
if m := a.metrics.Load(); m != nil {
|
||||
m.inc("desktop_hang", "main_thread")
|
||||
m.inc("desktop_hang_age", hangAgeBucket(age))
|
||||
m.persist()
|
||||
}
|
||||
slog.Warn("desktop: mac main thread heartbeat stalled",
|
||||
"age", age.Round(time.Millisecond).String(),
|
||||
"lastHeartbeat", lastHeartbeat.Format(time.RFC3339),
|
||||
"pendingReport", wrote,
|
||||
)
|
||||
}
|
||||
|
||||
func mainThreadHangReport(age time.Duration, lastHeartbeat, observedAt time.Time) crashReport {
|
||||
age = age.Round(time.Second)
|
||||
message := fmt.Sprintf(`[mac.main_thread.hang]
|
||||
|
||||
Reasonix detected that the macOS main-thread heartbeat stopped for %s.
|
||||
|
||||
--- watchdog context ---
|
||||
last heartbeat: %s
|
||||
observed at: %s
|
||||
threshold: %s
|
||||
bucket: %s
|
||||
|
||||
--- native runtime context ---
|
||||
%s`,
|
||||
age,
|
||||
lastHeartbeat.UTC().Format(time.RFC3339),
|
||||
observedAt.UTC().Format(time.RFC3339),
|
||||
mainThreadHangThreshold,
|
||||
hangAgeBucket(age),
|
||||
nativeResourceContext(),
|
||||
)
|
||||
report := baseCrashReport("performance")
|
||||
report.SchemaVersion = 2
|
||||
report.Source = "native.watchdog"
|
||||
report.Label = "mac.main_thread.hang"
|
||||
report.ErrorType = "MacMainThreadHang"
|
||||
report.ErrorMessage = sanitizeCrashText("macOS main thread heartbeat stopped; AppKit/Wails run loop may be blocked.", maxCrashFieldBytes)
|
||||
report.TopFrame = "mac.main_thread.heartbeat"
|
||||
report.OccurredAt = observedAt.UTC().Format(time.RFC3339)
|
||||
report.Message = sanitizeCrashText(message, maxCrashDetailBytes)
|
||||
return report
|
||||
}
|
||||
|
||||
func hangAgeBucket(age time.Duration) string {
|
||||
seconds := age.Seconds()
|
||||
switch {
|
||||
case seconds < 15:
|
||||
return "s_10_15"
|
||||
case seconds < 30:
|
||||
return "s_15_30"
|
||||
case seconds < 60:
|
||||
return "s_30_60"
|
||||
case seconds < 300:
|
||||
return "m_1_5"
|
||||
default:
|
||||
return "m_5_plus"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
//go:build darwin
|
||||
|
||||
package main
|
||||
|
||||
/*
|
||||
#include <stdint.h>
|
||||
#include <dispatch/dispatch.h>
|
||||
|
||||
extern void reasonixDesktopMainHeartbeat(void);
|
||||
|
||||
static dispatch_source_t reasonix_main_heartbeat_timer;
|
||||
|
||||
static void reasonix_main_heartbeat_handler(void *ctx) {
|
||||
reasonixDesktopMainHeartbeat();
|
||||
}
|
||||
|
||||
static void reasonix_start_main_heartbeat(uint64_t interval_ms) {
|
||||
if (reasonix_main_heartbeat_timer != NULL) {
|
||||
return;
|
||||
}
|
||||
reasonix_main_heartbeat_timer = dispatch_source_create(DISPATCH_SOURCE_TYPE_TIMER, 0, 0, dispatch_get_main_queue());
|
||||
dispatch_set_context(reasonix_main_heartbeat_timer, NULL);
|
||||
dispatch_source_set_event_handler_f(reasonix_main_heartbeat_timer, reasonix_main_heartbeat_handler);
|
||||
dispatch_source_set_timer(reasonix_main_heartbeat_timer, dispatch_time(DISPATCH_TIME_NOW, 0), interval_ms * NSEC_PER_MSEC, 100 * NSEC_PER_MSEC);
|
||||
dispatch_resume(reasonix_main_heartbeat_timer);
|
||||
}
|
||||
|
||||
static void reasonix_stop_main_heartbeat(void) {
|
||||
if (reasonix_main_heartbeat_timer == NULL) {
|
||||
return;
|
||||
}
|
||||
dispatch_source_cancel(reasonix_main_heartbeat_timer);
|
||||
reasonix_main_heartbeat_timer = NULL;
|
||||
}
|
||||
*/
|
||||
import "C"
|
||||
|
||||
import "time"
|
||||
|
||||
func mainThreadWatchdogSupported() bool {
|
||||
return true
|
||||
}
|
||||
|
||||
func startNativeMainThreadHeartbeat(intervalMS uint64) {
|
||||
C.reasonix_start_main_heartbeat(C.uint64_t(intervalMS))
|
||||
}
|
||||
|
||||
func stopNativeMainThreadHeartbeat() {
|
||||
C.reasonix_stop_main_heartbeat()
|
||||
}
|
||||
|
||||
//export reasonixDesktopMainHeartbeat
|
||||
func reasonixDesktopMainHeartbeat() {
|
||||
recordMainThreadHeartbeat(time.Now())
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
//go:build !darwin
|
||||
|
||||
package main
|
||||
|
||||
func mainThreadWatchdogSupported() bool {
|
||||
return false
|
||||
}
|
||||
|
||||
func startNativeMainThreadHeartbeat(uint64) {}
|
||||
|
||||
func stopNativeMainThreadHeartbeat() {}
|
||||
@@ -0,0 +1,99 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"reasonix/internal/config"
|
||||
)
|
||||
|
||||
func TestHangAgeBucket(t *testing.T) {
|
||||
cases := []struct {
|
||||
age time.Duration
|
||||
want string
|
||||
}{
|
||||
{12 * time.Second, "s_10_15"},
|
||||
{16 * time.Second, "s_15_30"},
|
||||
{45 * time.Second, "s_30_60"},
|
||||
{2 * time.Minute, "m_1_5"},
|
||||
{10 * time.Minute, "m_5_plus"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := hangAgeBucket(c.age); got != c.want {
|
||||
t.Errorf("hangAgeBucket(%s) = %q, want %q", c.age, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMainThreadHangReportIsStructuredPerformanceReport(t *testing.T) {
|
||||
last := time.Date(2026, 7, 4, 9, 19, 11, 0, time.UTC)
|
||||
observed := last.Add(16 * time.Second)
|
||||
|
||||
r := mainThreadHangReport(16*time.Second, last, observed)
|
||||
|
||||
if r.Kind != "performance" || r.Source != "native.watchdog" || r.Label != "mac.main_thread.hang" {
|
||||
t.Fatalf("unexpected report identity: %+v", r)
|
||||
}
|
||||
if r.SchemaVersion != 2 || r.ErrorType != "MacMainThreadHang" || r.TopFrame == "" || r.OccurredAt == "" {
|
||||
t.Fatalf("structured fields missing: %+v", r)
|
||||
}
|
||||
for _, want := range []string{"Reasonix detected", "last heartbeat:", "bucket: s_15_30", "goroutines:"} {
|
||||
if !strings.Contains(r.Message, want) {
|
||||
t.Fatalf("hang report message missing %q:\n%s", want, r.Message)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMainThreadHeartbeatAgeIgnoresWallClockJump(t *testing.T) {
|
||||
oldBase := mainThreadClockBase
|
||||
oldElapsed := mainThreadLastHeartbeatElapsed.Load()
|
||||
oldWall := mainThreadLastHeartbeatWall.Load()
|
||||
t.Cleanup(func() {
|
||||
mainThreadClockBase = oldBase
|
||||
mainThreadLastHeartbeatElapsed.Store(oldElapsed)
|
||||
mainThreadLastHeartbeatWall.Store(oldWall)
|
||||
})
|
||||
|
||||
base := time.Now()
|
||||
mainThreadClockBase = base
|
||||
mainThreadLastHeartbeatElapsed.Store(int64(time.Second))
|
||||
mainThreadLastHeartbeatWall.Store(base.Add(-time.Hour).UnixNano())
|
||||
|
||||
age, _, ok := mainThreadHeartbeatAge(base.Add(2 * time.Second))
|
||||
if !ok {
|
||||
t.Fatal("expected heartbeat age")
|
||||
}
|
||||
if age != time.Second {
|
||||
t.Fatalf("age = %s, want monotonic elapsed 1s despite wall-clock jump", age)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecordMainThreadHangWritesPendingReportAndMetrics(t *testing.T) {
|
||||
t.Cleanup(func() {
|
||||
os.Remove(pendingCrashPath())
|
||||
os.Remove(filepath.Join(config.MemoryUserDir(), metricsPendingFile))
|
||||
})
|
||||
app := NewApp()
|
||||
app.metrics.Store(newMetricsAggregator(config.MemoryUserDir()))
|
||||
|
||||
last := time.Now().Add(-20 * time.Second)
|
||||
app.recordMainThreadHang(20*time.Second, last, time.Now())
|
||||
|
||||
r, ok := readPending(t)
|
||||
if !ok {
|
||||
t.Fatal("expected pending hang report")
|
||||
}
|
||||
if r.Kind != "performance" || r.Source != "native.watchdog" || r.Label != "mac.main_thread.hang" {
|
||||
t.Fatalf("pending report = %+v", r)
|
||||
}
|
||||
c := readCounters(filepath.Join(config.MemoryUserDir(), metricsPendingFile))
|
||||
if got := c["desktop_hang"]["main_thread"]; got != 1 {
|
||||
t.Fatalf("desktop_hang/main_thread = %d, want 1", got)
|
||||
}
|
||||
if got := c["desktop_hang_age"]["s_15_30"]; got != 1 {
|
||||
t.Fatalf("desktop_hang_age/s_15_30 = %d, want 1", got)
|
||||
}
|
||||
}
|
||||
@@ -88,6 +88,25 @@ func TestHistoryMessagesDoNotReplayMemoryCompilerContract(t *testing.T) {
|
||||
assertNoHistoryMemoryContract(t, got[0].Content)
|
||||
}
|
||||
|
||||
func TestHistoryMessagesStripActiveGoalFromVisibleUserContent(t *testing.T) {
|
||||
raw := "<active-goal>\nship the approval redesign\n</active-goal>\n\ncontinue implementation"
|
||||
msgs := []provider.Message{
|
||||
{Role: provider.RoleUser, Content: raw},
|
||||
{Role: provider.RoleAssistant, Content: "done"},
|
||||
}
|
||||
|
||||
got := historyMessages(msgs, control.StripComposePrefixes)
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("history length = %d, want 2: %+v", len(got), got)
|
||||
}
|
||||
if got[0].Content != "continue implementation" {
|
||||
t.Fatalf("visible user content = %q, want active-goal stripped", got[0].Content)
|
||||
}
|
||||
if strings.Contains(got[0].Content, "<active-goal>") || strings.Contains(got[0].Content, "ship the approval redesign") {
|
||||
t.Fatalf("active-goal leaked into visible history content: %+v", got[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestHistoryMessagesCarryCheckpointTurnsAcrossHiddenSyntheticUsers(t *testing.T) {
|
||||
msgs := []provider.Message{
|
||||
{Role: provider.RoleSystem, Content: "sys"},
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"reasonix/internal/config"
|
||||
"reasonix/internal/event"
|
||||
"reasonix/internal/notify"
|
||||
)
|
||||
|
||||
type desktopRecordSink struct {
|
||||
events []event.Kind
|
||||
}
|
||||
|
||||
func (s *desktopRecordSink) Emit(e event.Event) {
|
||||
s.events = append(s.events, e.Kind)
|
||||
}
|
||||
|
||||
type desktopRecordSender struct {
|
||||
messages []notify.Message
|
||||
}
|
||||
|
||||
func (s *desktopRecordSender) Send(m notify.Message) error {
|
||||
s.messages = append(s.messages, m)
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestDesktopControllerSinkSendsConfiguredSystemNotifications(t *testing.T) {
|
||||
inner := &desktopRecordSink{}
|
||||
sender := &desktopRecordSender{}
|
||||
app := &App{notificationSender: sender}
|
||||
sink := app.desktopControllerSink(inner, config.NotificationsConfig{
|
||||
Enabled: true,
|
||||
TurnDone: true,
|
||||
ApprovalRequest: true,
|
||||
AskRequest: true,
|
||||
})
|
||||
|
||||
sink.Emit(event.Event{Kind: event.TurnDone})
|
||||
|
||||
if len(inner.events) != 1 || inner.events[0] != event.TurnDone {
|
||||
t.Fatalf("forwarded events = %+v, want [TurnDone]", inner.events)
|
||||
}
|
||||
if len(sender.messages) != 1 {
|
||||
t.Fatalf("notifications = %d, want 1", len(sender.messages))
|
||||
}
|
||||
if sender.messages[0].Body != "Turn finished" {
|
||||
t.Fatalf("notification body = %q, want Turn finished", sender.messages[0].Body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDesktopControllerSinkSkipsSystemNotificationsWhenDisabled(t *testing.T) {
|
||||
inner := &desktopRecordSink{}
|
||||
sender := &desktopRecordSender{}
|
||||
app := &App{notificationSender: sender}
|
||||
sink := app.desktopControllerSink(inner, config.NotificationsConfig{
|
||||
Enabled: false,
|
||||
TurnDone: true,
|
||||
})
|
||||
|
||||
sink.Emit(event.Event{Kind: event.TurnDone})
|
||||
|
||||
if len(inner.events) != 1 || inner.events[0] != event.TurnDone {
|
||||
t.Fatalf("forwarded events = %+v, want [TurnDone]", inner.events)
|
||||
}
|
||||
if len(sender.messages) != 0 {
|
||||
t.Fatalf("notifications = %d, want 0", len(sender.messages))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,246 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"reasonix/internal/config"
|
||||
"reasonix/internal/installsource"
|
||||
"reasonix/internal/pluginpkg"
|
||||
)
|
||||
|
||||
type PluginView struct {
|
||||
Name string `json:"name"`
|
||||
Version string `json:"version,omitempty"`
|
||||
Description string `json:"description,omitempty"`
|
||||
Source string `json:"source,omitempty"`
|
||||
Root string `json:"root"`
|
||||
ManifestKind string `json:"manifestKind,omitempty"`
|
||||
Enabled bool `json:"enabled"`
|
||||
Skills int `json:"skills"`
|
||||
Hooks int `json:"hooks"`
|
||||
MCPServers int `json:"mcpServers"`
|
||||
SkillDetails []PluginSkillView `json:"skillDetails,omitempty"`
|
||||
HookDetails []PluginHookView `json:"hookDetails,omitempty"`
|
||||
MCPServerDetails []PluginMCPServerView `json:"mcpServerDetails,omitempty"`
|
||||
Warnings []string `json:"warnings,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
type PluginInstallOptions struct {
|
||||
DryRun bool `json:"dryRun,omitempty"`
|
||||
Link bool `json:"link,omitempty"`
|
||||
Replace bool `json:"replace,omitempty"`
|
||||
Name string `json:"name,omitempty"`
|
||||
}
|
||||
|
||||
type PluginSkillView struct {
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description,omitempty"`
|
||||
Path string `json:"path,omitempty"`
|
||||
Invocation string `json:"invocation,omitempty"`
|
||||
RunAs string `json:"runAs,omitempty"`
|
||||
}
|
||||
|
||||
type PluginHookView struct {
|
||||
Event string `json:"event"`
|
||||
Match string `json:"match,omitempty"`
|
||||
Command string `json:"command,omitempty"`
|
||||
ContextFile string `json:"contextFile,omitempty"`
|
||||
Description string `json:"description,omitempty"`
|
||||
}
|
||||
|
||||
type PluginMCPServerView struct {
|
||||
Name string `json:"name"`
|
||||
Transport string `json:"transport,omitempty"`
|
||||
Command string `json:"command,omitempty"`
|
||||
URL string `json:"url,omitempty"`
|
||||
}
|
||||
|
||||
func (a *App) Plugins() []PluginView {
|
||||
st, err := pluginpkg.LoadState(config.ReasonixHomeDir())
|
||||
if err != nil {
|
||||
return []PluginView{{Error: err.Error()}}
|
||||
}
|
||||
out := make([]PluginView, 0, len(st.Plugins))
|
||||
for _, p := range st.Plugins {
|
||||
view := PluginView{
|
||||
Name: p.Name,
|
||||
Version: p.Version,
|
||||
Description: p.Description,
|
||||
Source: p.Source,
|
||||
Root: pluginpkg.ResolveRoot(config.ReasonixHomeDir(), p.Root),
|
||||
ManifestKind: p.ManifestKind,
|
||||
Enabled: p.Enabled,
|
||||
}
|
||||
if pkg, warnings, err := pluginpkg.ParseDir(view.Root); err == nil {
|
||||
applyPluginPackageDetails(&view, pkg, warnings)
|
||||
} else {
|
||||
view.Error = err.Error()
|
||||
}
|
||||
out = append(out, view)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func applyPluginPackageDetails(view *PluginView, pkg pluginpkg.Package, warnings []string) {
|
||||
view.Skills, view.Hooks, view.MCPServers = pkg.CapabilityCounts()
|
||||
view.Warnings = warnings
|
||||
inv := pkg.Inventory()
|
||||
view.SkillDetails = make([]PluginSkillView, 0, len(inv.Skills))
|
||||
for _, sk := range inv.Skills {
|
||||
view.SkillDetails = append(view.SkillDetails, PluginSkillView{
|
||||
Name: sk.Name,
|
||||
Description: sk.Description,
|
||||
Path: sk.Path,
|
||||
Invocation: sk.Invocation,
|
||||
RunAs: sk.RunAs,
|
||||
})
|
||||
}
|
||||
view.HookDetails = make([]PluginHookView, 0, len(inv.Hooks))
|
||||
for _, hook := range inv.Hooks {
|
||||
view.HookDetails = append(view.HookDetails, PluginHookView{
|
||||
Event: hook.Event,
|
||||
Match: hook.Match,
|
||||
Command: hook.Command,
|
||||
ContextFile: hook.ContextFile,
|
||||
Description: hook.Description,
|
||||
})
|
||||
}
|
||||
view.MCPServerDetails = make([]PluginMCPServerView, 0, len(inv.MCPServers))
|
||||
for _, server := range inv.MCPServers {
|
||||
view.MCPServerDetails = append(view.MCPServerDetails, PluginMCPServerView{
|
||||
Name: server.Name,
|
||||
Transport: server.Transport,
|
||||
Command: server.Command,
|
||||
URL: server.URL,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func (a *App) PlanPluginInstall(source string, opts PluginInstallOptions) (string, error) {
|
||||
opts.DryRun = true
|
||||
return a.runPluginInstallSource(source, opts, false)
|
||||
}
|
||||
|
||||
func (a *App) InstallPlugin(source string, opts PluginInstallOptions) (string, error) {
|
||||
if err := a.ensureActiveTabRebuildAllowed("plugins"); err != nil {
|
||||
return "", err
|
||||
}
|
||||
out, err := a.runPluginInstallSource(source, opts, true)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
a.invalidateSkillRootsCache()
|
||||
if rebuildErr := a.rebuild(); rebuildErr != nil {
|
||||
if _, ok := a.deferredRebuildWarning("plugins", rebuildErr); ok {
|
||||
return out, nil
|
||||
}
|
||||
return out, rebuildErr
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (a *App) RemovePlugin(name string) error {
|
||||
if err := a.ensureActiveTabRebuildAllowed("plugins"); err != nil {
|
||||
return err
|
||||
}
|
||||
raw, _ := json.Marshal(map[string]any{"op": "uninstall", "kind": "plugin", "name": strings.TrimSpace(name), "scope": "global"})
|
||||
tl := installsource.NewTool(installsource.Options{
|
||||
ProjectRoot: a.activeWorkspaceRoot(),
|
||||
OnDisconnect: func(serverName string) bool {
|
||||
tab := a.activeTab()
|
||||
if tab == nil || tab.Ctrl == nil {
|
||||
return false
|
||||
}
|
||||
removed, _ := tab.Ctrl.RemoveMCPServer(serverName)
|
||||
return removed
|
||||
},
|
||||
})
|
||||
if _, err := tl.Execute(context.Background(), raw); err != nil {
|
||||
return err
|
||||
}
|
||||
a.invalidateSkillRootsCache()
|
||||
if err := a.rebuild(); err != nil {
|
||||
if _, ok := a.deferredRebuildWarning("plugins", err); ok {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *App) SetPluginEnabled(name string, enabled bool) error {
|
||||
if err := a.ensureActiveTabRebuildAllowed("plugins"); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := pluginpkg.SetEnabled(config.ReasonixHomeDir(), strings.TrimSpace(name), enabled); err != nil {
|
||||
return err
|
||||
}
|
||||
a.invalidateSkillRootsCache()
|
||||
if err := a.rebuild(); err != nil {
|
||||
if _, ok := a.deferredRebuildWarning("plugins", err); ok {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *App) UpdatePlugin(name string) (string, error) {
|
||||
name = strings.TrimSpace(name)
|
||||
for _, p := range a.Plugins() {
|
||||
if p.Name == name {
|
||||
if strings.TrimSpace(p.Source) == "" {
|
||||
return "", fmt.Errorf("plugin %q has no recorded source", name)
|
||||
}
|
||||
return a.InstallPlugin(p.Source, PluginInstallOptions{Name: name, Replace: true})
|
||||
}
|
||||
}
|
||||
return "", fmt.Errorf("plugin %q is not installed", name)
|
||||
}
|
||||
|
||||
func (a *App) PluginDoctor(name string) PluginView {
|
||||
name = strings.TrimSpace(name)
|
||||
for _, p := range a.Plugins() {
|
||||
if p.Name != name {
|
||||
continue
|
||||
}
|
||||
if p.Error != "" {
|
||||
return p
|
||||
}
|
||||
if p.Root == "" {
|
||||
p.Error = "missing plugin root"
|
||||
return p
|
||||
}
|
||||
if _, err := os.Stat(p.Root); err != nil {
|
||||
p.Error = err.Error()
|
||||
return p
|
||||
}
|
||||
return p
|
||||
}
|
||||
return PluginView{Name: name, Error: "plugin is not installed"}
|
||||
}
|
||||
|
||||
func (a *App) runPluginInstallSource(source string, opts PluginInstallOptions, apply bool) (string, error) {
|
||||
mode := "copy"
|
||||
if opts.Link {
|
||||
mode = "link"
|
||||
}
|
||||
body := map[string]any{
|
||||
"source": strings.TrimSpace(source),
|
||||
"kind": "plugin",
|
||||
"mode": mode,
|
||||
"replace": opts.Replace,
|
||||
"apply": apply && !opts.DryRun,
|
||||
}
|
||||
if strings.TrimSpace(opts.Name) != "" {
|
||||
body["name"] = strings.TrimSpace(opts.Name)
|
||||
}
|
||||
raw, _ := json.Marshal(body)
|
||||
tl := installsource.NewTool(installsource.Options{ProjectRoot: a.activeWorkspaceRoot()})
|
||||
return tl.Execute(context.Background(), raw)
|
||||
}
|
||||
@@ -0,0 +1,480 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"reasonix/internal/agent"
|
||||
"reasonix/internal/config"
|
||||
"reasonix/internal/event"
|
||||
)
|
||||
|
||||
// TestSessionLeaseHelpersConcurrentAccess hammers the sessionLeaseMu helpers
|
||||
// (ensure/take/adopt/release/key) from concurrent goroutines. Run with -race:
|
||||
// any residual raw access to tab.sessionLease shows up as a data race, and the
|
||||
// final acquire asserts no lease leaked through an interleaving.
|
||||
func TestSessionLeaseHelpersConcurrentAccess(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
dir := config.SessionDir()
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
t.Fatalf("mkdir sessions: %v", err)
|
||||
}
|
||||
path := filepath.Join(dir, "lease-helper-hammer.jsonl")
|
||||
key := sessionRuntimeKey(path)
|
||||
tabA := &WorkspaceTab{ID: "a"}
|
||||
tabB := &WorkspaceTab{ID: "b"}
|
||||
|
||||
const iterations = 300
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(3)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
for i := 0; i < iterations; i++ {
|
||||
_ = tabA.ensureSessionLease(path)
|
||||
_ = tabA.sessionLeaseRuntimeKey()
|
||||
}
|
||||
}()
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
for i := 0; i < iterations; i++ {
|
||||
// The applyRuntimeTab transfer shape: move A's lease to B and back.
|
||||
tabB.adoptSessionLease(tabA.takeSessionLease())
|
||||
tabA.adoptSessionLease(tabB.takeSessionLease())
|
||||
}
|
||||
}()
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
for i := 0; i < iterations; i++ {
|
||||
tabA.releaseSessionLease()
|
||||
}
|
||||
}()
|
||||
wg.Wait()
|
||||
|
||||
tabA.releaseSessionLease()
|
||||
tabB.releaseSessionLease()
|
||||
lease, err := agent.TryAcquireSessionLease(key)
|
||||
if err != nil {
|
||||
t.Fatalf("lease leaked through concurrent helper interleavings: %v", err)
|
||||
}
|
||||
lease.Release()
|
||||
}
|
||||
|
||||
// TestDetachRuntimeForReplacementTransfersLease asserts the detach clone takes
|
||||
// lease ownership through the locked helpers and the visible tab keeps none.
|
||||
func TestDetachRuntimeForReplacementTransfersLease(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
dir := config.SessionDir()
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
t.Fatalf("mkdir sessions: %v", err)
|
||||
}
|
||||
path := filepath.Join(dir, "detach-transfer.jsonl")
|
||||
key := sessionRuntimeKey(path)
|
||||
tab := &WorkspaceTab{ID: "tab", Scope: "global", SessionPath: path}
|
||||
app := &App{tabs: map[string]*WorkspaceTab{"tab": tab}}
|
||||
if err := tab.ensureSessionLease(path); err != nil {
|
||||
t.Fatalf("ensureSessionLease: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
tab.releaseSessionLease()
|
||||
for _, d := range app.detachedSessions {
|
||||
d.releaseSessionLease()
|
||||
}
|
||||
})
|
||||
|
||||
if !app.detachRuntimeForReplacement(tab) {
|
||||
t.Fatal("detachRuntimeForReplacement failed for a live tab")
|
||||
}
|
||||
detached := app.detachedSessions[key]
|
||||
if detached == nil {
|
||||
t.Fatal("detached runtime was not registered")
|
||||
}
|
||||
if got := detached.sessionLeaseRuntimeKey(); got != key {
|
||||
t.Fatalf("detached clone lease key = %q, want %q", got, key)
|
||||
}
|
||||
if got := tab.sessionLeaseRuntimeKey(); got != "" {
|
||||
t.Fatalf("visible tab still holds lease key %q after detach", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDetachRuntimeForReplacementSkipsRemovedTab: a tab that DeleteSession /
|
||||
// CloseTab already unlinked must not be re-published into detachedSessions
|
||||
// (the "session resurrects" class, #4384).
|
||||
func TestDetachRuntimeForReplacementSkipsRemovedTab(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
dir := config.SessionDir()
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
t.Fatalf("mkdir sessions: %v", err)
|
||||
}
|
||||
path := filepath.Join(dir, "detach-removed.jsonl")
|
||||
|
||||
removed := &WorkspaceTab{ID: "removed", SessionPath: path, removed: true}
|
||||
app := &App{tabs: map[string]*WorkspaceTab{"removed": removed}}
|
||||
if app.detachRuntimeForReplacement(removed) {
|
||||
t.Fatal("removed tab was detached into the background registry")
|
||||
}
|
||||
if len(app.detachedSessions) != 0 {
|
||||
t.Fatal("removed tab left an entry in detachedSessions")
|
||||
}
|
||||
|
||||
orphan := &WorkspaceTab{ID: "orphan", SessionPath: path}
|
||||
if app.detachRuntimeForReplacement(orphan) {
|
||||
t.Fatal("tab absent from a.tabs was detached into the background registry")
|
||||
}
|
||||
if len(app.detachedSessions) != 0 {
|
||||
t.Fatal("orphan tab left an entry in detachedSessions")
|
||||
}
|
||||
}
|
||||
|
||||
// TestTabEventSinkEmitConcurrentRebind: Emit keeps running on the controller
|
||||
// goroutine while detach/reattach rebinds the sink's tab routing. Run with
|
||||
// -race; before setBinding existed the tabID write raced every Emit.
|
||||
func TestTabEventSinkEmitConcurrentRebind(t *testing.T) {
|
||||
sink := &tabEventSink{tabID: "before"}
|
||||
const iterations = 500
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(2)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
for i := 0; i < iterations; i++ {
|
||||
sink.Emit(event.Event{Kind: event.Notice, Text: "hammer"})
|
||||
}
|
||||
}()
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
for i := 0; i < iterations; i++ {
|
||||
sink.setBinding(fmt.Sprintf("tab-%d", i%2), nil)
|
||||
sink.clearContext()
|
||||
}
|
||||
}()
|
||||
wg.Wait()
|
||||
if tabID, _ := sink.binding(); tabID == "" {
|
||||
t.Fatal("sink lost its tab binding")
|
||||
}
|
||||
}
|
||||
|
||||
// TestTabBuildSupersededByRebindGeneration: a session rebind bumps
|
||||
// buildGeneration to strand any in-flight async build, so its swap (and every
|
||||
// mid-build field write) is rejected; synchronous rebuilds pass generation 0
|
||||
// and rely on runtimeRebuildMu instead.
|
||||
func TestTabBuildSupersededByRebindGeneration(t *testing.T) {
|
||||
tab := &WorkspaceTab{ID: "tab"}
|
||||
app := &App{tabs: map[string]*WorkspaceTab{"tab": tab}}
|
||||
app.mu.Lock()
|
||||
tab.buildGeneration = 3
|
||||
app.mu.Unlock()
|
||||
if app.tabBuildSuperseded(tab, 3) {
|
||||
t.Fatal("build with the current generation must not be superseded")
|
||||
}
|
||||
app.mu.Lock()
|
||||
tab.buildGeneration++ // the rebind-side invalidation
|
||||
app.mu.Unlock()
|
||||
if !app.tabBuildSuperseded(tab, 3) {
|
||||
t.Fatal("stale-generation build must be superseded after a rebind bump")
|
||||
}
|
||||
if app.tabBuildSuperseded(tab, 0) {
|
||||
t.Fatal("synchronous rebuild (generation 0) must not be superseded by generation bumps")
|
||||
}
|
||||
app.mu.Lock()
|
||||
tab.removed = true
|
||||
app.mu.Unlock()
|
||||
if !app.tabBuildSuperseded(tab, 0) {
|
||||
t.Fatal("removed tab must supersede every build, including synchronous ones")
|
||||
}
|
||||
}
|
||||
|
||||
// TestReleaseSessionLeaseForKeyOnlyMatchesOwnKey: superseded builds may only
|
||||
// release the lease bound to their own path; a mismatched key (the rebind's
|
||||
// replacement session) must be left untouched.
|
||||
func TestReleaseSessionLeaseForKeyOnlyMatchesOwnKey(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
dir := config.SessionDir()
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
t.Fatalf("mkdir sessions: %v", err)
|
||||
}
|
||||
pathA := filepath.Join(dir, "lease-key-a.jsonl")
|
||||
pathB := filepath.Join(dir, "lease-key-b.jsonl")
|
||||
tab := &WorkspaceTab{ID: "tab"}
|
||||
t.Cleanup(tab.releaseSessionLease)
|
||||
if err := tab.ensureSessionLease(pathB); err != nil {
|
||||
t.Fatalf("ensureSessionLease: %v", err)
|
||||
}
|
||||
|
||||
tab.releaseSessionLeaseForKey(sessionRuntimeKey(pathA))
|
||||
if _, err := agent.TryAcquireSessionLease(sessionRuntimeKey(pathB)); err == nil {
|
||||
t.Fatal("mismatched key released the tab's live lease")
|
||||
}
|
||||
|
||||
tab.releaseSessionLeaseForKey(sessionRuntimeKey(pathB))
|
||||
lease, err := agent.TryAcquireSessionLease(sessionRuntimeKey(pathB))
|
||||
if err != nil {
|
||||
t.Fatalf("matching key did not release the lease: %v", err)
|
||||
}
|
||||
lease.Release()
|
||||
}
|
||||
|
||||
// TestAbandonSupersededBuildPreservesNewBuildOwnership reproduces the rebind
|
||||
// interleaving from the #5968 review: a stale async build cleans up after a
|
||||
// rebind's replacement build already published its own SharedHostKey and
|
||||
// session lease on the same live tab. The stale build must drop only its own
|
||||
// host reference and lease, never the tab's.
|
||||
func TestAbandonSupersededBuildPreservesNewBuildOwnership(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
dir := config.SessionDir()
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
t.Fatalf("mkdir sessions: %v", err)
|
||||
}
|
||||
oldPath := filepath.Join(dir, "superseded-old.jsonl")
|
||||
newPath := filepath.Join(dir, "superseded-new.jsonl")
|
||||
newKey := sessionRuntimeKey(newPath)
|
||||
|
||||
app := &App{tabs: map[string]*WorkspaceTab{}}
|
||||
// The stale build and the replacement build each hold one reference on
|
||||
// the same workspace-root host (the common same-root rebind).
|
||||
app.acquireSharedHost("root")
|
||||
app.acquireSharedHost("root")
|
||||
|
||||
tab := &WorkspaceTab{ID: "tab", SharedHostKey: "root"}
|
||||
app.tabs["tab"] = tab
|
||||
t.Cleanup(tab.releaseSessionLease)
|
||||
if err := tab.ensureSessionLease(newPath); err != nil {
|
||||
t.Fatalf("ensureSessionLease(new): %v", err)
|
||||
}
|
||||
|
||||
// Stale build abandons with ITS key material: the old lease path and the
|
||||
// root key it acquired.
|
||||
app.abandonSupersededBuild(tab, nil, "root", sessionRuntimeKey(oldPath))
|
||||
|
||||
app.mu.RLock()
|
||||
hostKey := tab.SharedHostKey
|
||||
app.mu.RUnlock()
|
||||
if hostKey != "root" {
|
||||
t.Fatalf("stale build cleared the tab's SharedHostKey: %q", hostKey)
|
||||
}
|
||||
app.sharedHostsMu.Lock()
|
||||
entry := app.sharedHosts["root"]
|
||||
refs := 0
|
||||
if entry != nil {
|
||||
refs = entry.refs
|
||||
}
|
||||
app.sharedHostsMu.Unlock()
|
||||
if refs != 1 {
|
||||
t.Fatalf("shared host refs = %d after stale-build cleanup, want 1 (the live build's reference)", refs)
|
||||
}
|
||||
if got := tab.sessionLeaseRuntimeKey(); got != newKey {
|
||||
t.Fatalf("stale build released the replacement build's lease: key = %q, want %q", got, newKey)
|
||||
}
|
||||
|
||||
// Removal shape: the abandoned build's own key still on the tab is the
|
||||
// last reference and must be released.
|
||||
app.abandonSupersededBuild(tab, nil, "", newKey)
|
||||
lease, err := agent.TryAcquireSessionLease(newKey)
|
||||
if err != nil {
|
||||
t.Fatalf("matching-key abandon did not release the lease: %v", err)
|
||||
}
|
||||
lease.Release()
|
||||
}
|
||||
|
||||
// TestRebindInvalidatesInFlightAsyncBuildBeforeSnapshot reproduces the #5968
|
||||
// review scenario: an async startTabControllerBuild is stalled right after
|
||||
// binding its session lease (holding its controller, lease, and shared-host
|
||||
// reference), a rebind to a different session starts meanwhile, and the
|
||||
// stalled build then tries to finish. The rebind must bump the generation
|
||||
// BEFORE snapshotting tab.Ctrl, so the stale build can only fall into its
|
||||
// superseded branches: the rebound controller must survive un-overwritten,
|
||||
// the stale build's lease and host reference must be released, and the
|
||||
// replacement build's ownership must stay intact.
|
||||
func TestRebindInvalidatesInFlightAsyncBuildBeforeSnapshot(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
root := globalTabWorkspaceRoot()
|
||||
dir := desktopSessionDir(root)
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
t.Fatalf("mkdir session dir: %v", err)
|
||||
}
|
||||
oldPath := filepath.Join(dir, "rebind-race-old.jsonl")
|
||||
newPath := filepath.Join(dir, "rebind-race-new.jsonl")
|
||||
writeHistoryTestSession(t, oldPath, "old prompt")
|
||||
writeHistoryTestSession(t, newPath, "new prompt")
|
||||
oldKey := sessionRuntimeKey(oldPath)
|
||||
newKey := sessionRuntimeKey(newPath)
|
||||
|
||||
app := NewApp()
|
||||
app.readyHook = func() {}
|
||||
tab := &WorkspaceTab{
|
||||
ID: "tab",
|
||||
Scope: "global",
|
||||
WorkspaceRoot: root,
|
||||
SessionPath: oldPath,
|
||||
sink: &tabEventSink{tabID: "tab", app: app},
|
||||
disabledMCP: map[string]ServerView{},
|
||||
}
|
||||
app.tabs[tab.ID] = tab
|
||||
app.tabOrder = []string{tab.ID}
|
||||
app.activeTabID = tab.ID
|
||||
t.Cleanup(func() {
|
||||
app.mu.RLock()
|
||||
ctrl := tab.Ctrl
|
||||
app.mu.RUnlock()
|
||||
if ctrl != nil {
|
||||
ctrl.Close()
|
||||
}
|
||||
tab.releaseSessionLease()
|
||||
})
|
||||
|
||||
// Stall the async build inside its lease bind: at that point it already
|
||||
// holds its controller, its lease, and its shared-host reference.
|
||||
stalled := make(chan struct{})
|
||||
releaseHook := make(chan struct{})
|
||||
var once sync.Once
|
||||
sessionLeaseAcquireHookForTest = func() {
|
||||
once.Do(func() {
|
||||
close(stalled)
|
||||
<-releaseHook
|
||||
})
|
||||
}
|
||||
t.Cleanup(func() { sessionLeaseAcquireHookForTest = nil })
|
||||
t.Cleanup(func() {
|
||||
select {
|
||||
case <-releaseHook:
|
||||
default:
|
||||
close(releaseHook)
|
||||
}
|
||||
})
|
||||
|
||||
// startTabControllerBuild only backgrounds the build when a Wails context
|
||||
// exists; expand its goroutine branch by hand so a.ctx can stay nil (the
|
||||
// nil-ctx emit guards are what every other build test relies on too).
|
||||
buildCtx, cancel := context.WithCancel(context.Background())
|
||||
app.mu.Lock()
|
||||
tab.buildGeneration++
|
||||
generation := tab.buildGeneration
|
||||
tab.buildCancel = cancel
|
||||
app.mu.Unlock()
|
||||
buildDone := make(chan struct{})
|
||||
go func() {
|
||||
defer close(buildDone)
|
||||
app.buildTabControllerWithContext(tab, loadedTabSession{}, buildCtx, generation, cancel)
|
||||
}()
|
||||
select {
|
||||
case <-stalled:
|
||||
case <-time.After(15 * time.Second):
|
||||
t.Fatal("async build did not reach the lease bind")
|
||||
}
|
||||
|
||||
loaded, err := agent.LoadSession(newPath)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadSession(new): %v", err)
|
||||
}
|
||||
rebindErr := make(chan error, 1)
|
||||
go func() {
|
||||
rebindErr <- app.rebindTabToLoadedSessionPath(tab, newPath, loaded)
|
||||
}()
|
||||
|
||||
// Wait until the rebind's validation section has invalidated the async
|
||||
// build (startTabControllerBuild set generation 1; the rebind bumps to 2)
|
||||
// while the async build is still stalled pre-swap.
|
||||
deadline := time.Now().Add(15 * time.Second)
|
||||
for {
|
||||
app.mu.RLock()
|
||||
generation := tab.buildGeneration
|
||||
app.mu.RUnlock()
|
||||
if generation >= 2 {
|
||||
break
|
||||
}
|
||||
if time.Now().After(deadline) {
|
||||
t.Fatal("rebind did not bump the build generation")
|
||||
}
|
||||
time.Sleep(time.Millisecond)
|
||||
}
|
||||
|
||||
close(releaseHook)
|
||||
select {
|
||||
case err := <-rebindErr:
|
||||
if err != nil {
|
||||
t.Fatalf("rebindTabToLoadedSessionPath: %v", err)
|
||||
}
|
||||
case <-time.After(30 * time.Second):
|
||||
t.Fatal("rebind did not finish after releasing the stalled build")
|
||||
}
|
||||
select {
|
||||
case <-buildDone:
|
||||
case <-time.After(30 * time.Second):
|
||||
t.Fatal("stale async build did not finish its superseded cleanup")
|
||||
}
|
||||
|
||||
app.mu.RLock()
|
||||
builtCtrl := tab.Ctrl
|
||||
sessionPath := tab.SessionPath
|
||||
app.mu.RUnlock()
|
||||
if builtCtrl == nil {
|
||||
t.Fatal("rebind left the tab without a controller")
|
||||
}
|
||||
if got := builtCtrl.SessionPath(); got != newPath {
|
||||
t.Fatalf("stale async build overwrote the rebound controller: session path = %q, want %q", got, newPath)
|
||||
}
|
||||
if sessionPath != newPath {
|
||||
t.Fatalf("tab.SessionPath = %q, want %q", sessionPath, newPath)
|
||||
}
|
||||
if got := tab.sessionLeaseRuntimeKey(); got != newKey {
|
||||
t.Fatalf("tab lease key = %q, want the rebound session %q", got, newKey)
|
||||
}
|
||||
// The stale build's lease must be gone (released by its superseded
|
||||
// cleanup or replaced by the rebound build's adopt) — the old session
|
||||
// must be acquirable again.
|
||||
lease, err := agent.TryAcquireSessionLease(oldKey)
|
||||
if err != nil {
|
||||
t.Fatalf("stale build leaked its session lease: %v", err)
|
||||
}
|
||||
lease.Release()
|
||||
// Exactly one shared-host reference may remain: the rebound build's. The
|
||||
// stale build must have released its own.
|
||||
app.sharedHostsMu.Lock()
|
||||
refs := 0
|
||||
for _, entry := range app.sharedHosts {
|
||||
refs += entry.refs
|
||||
}
|
||||
app.sharedHostsMu.Unlock()
|
||||
if refs != 1 {
|
||||
t.Fatalf("shared host refs = %d after stale-build cleanup, want 1 (the rebound build's)", refs)
|
||||
}
|
||||
}
|
||||
|
||||
// TestMetaForTabConcurrentWithBuildSwap polls MetaForTab (the frontend's boot
|
||||
// probe) while a fake build goroutine flips Ready/Label/StartupErr/model under
|
||||
// a.mu — the write pattern of buildTabControllerWithContext. Run with -race.
|
||||
func TestMetaForTabConcurrentWithBuildSwap(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
tab := &WorkspaceTab{ID: "tab", Scope: "project", WorkspaceRoot: t.TempDir()}
|
||||
app := &App{
|
||||
tabs: map[string]*WorkspaceTab{"tab": tab},
|
||||
tabOrder: []string{"tab"},
|
||||
activeTabID: "tab",
|
||||
}
|
||||
|
||||
const iterations = 100
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
defer close(done)
|
||||
for i := 0; i < iterations; i++ {
|
||||
app.mu.Lock()
|
||||
tab.Ready = !tab.Ready
|
||||
tab.Label = fmt.Sprintf("model-%d", i)
|
||||
tab.StartupErr = ""
|
||||
tab.model = fmt.Sprintf("provider/m%d", i)
|
||||
tab.goal = fmt.Sprintf("goal-%d", i)
|
||||
tab.tokenMode = "full"
|
||||
app.mu.Unlock()
|
||||
}
|
||||
}()
|
||||
for i := 0; i < iterations; i++ {
|
||||
meta := app.MetaForTab("tab")
|
||||
if meta.EventChannel == "" {
|
||||
t.Fatal("MetaForTab returned zero meta for a live tab")
|
||||
}
|
||||
}
|
||||
<-done
|
||||
}
|
||||
@@ -34,6 +34,10 @@ func (t desktopCountingTool) Execute(context.Context, json.RawMessage) (string,
|
||||
}
|
||||
|
||||
func TestDesktopE2EBlocksRepeatedSuccessfulBashFileWrite(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("skipping desktop E2E repeat-guard test in short mode")
|
||||
}
|
||||
|
||||
var calls int32
|
||||
reg := tool.NewRegistry()
|
||||
reg.Add(desktopCountingTool{name: "bash", calls: &calls})
|
||||
|
||||
@@ -43,7 +43,7 @@ func sessionWithFreshSystemPrompt(session *agent.Session, system string) *agent.
|
||||
if systemPromptFrom(messages) == "" {
|
||||
return session
|
||||
}
|
||||
return &agent.Session{Messages: withFreshSystemPrompt(messages, system)}
|
||||
return session.CloneWithMessages(withFreshSystemPrompt(messages, system))
|
||||
}
|
||||
|
||||
func resumeWithFreshSystemPrompt(ctrl interface {
|
||||
@@ -55,7 +55,16 @@ func resumeWithFreshSystemPrompt(ctrl interface {
|
||||
return
|
||||
}
|
||||
if len(messages) > 0 {
|
||||
ctrl.Resume(&agent.Session{Messages: withFreshSystemPrompt(messages, systemPromptFrom(ctrl.History()))}, path)
|
||||
next := withFreshSystemPrompt(messages, systemPromptFrom(ctrl.History()))
|
||||
if path != "" {
|
||||
if loaded, err := agent.LoadSession(path); err == nil && loaded != nil {
|
||||
if resumed, ok := loaded.CloneWithMessagesIfCompatible(next); ok {
|
||||
ctrl.Resume(resumed, path)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
ctrl.Resume(agent.NewSession("").CloneWithMessages(next), path)
|
||||
return
|
||||
}
|
||||
if path != "" {
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"reasonix/internal/agent"
|
||||
"reasonix/internal/provider"
|
||||
)
|
||||
|
||||
type promptResumeCtrl struct {
|
||||
history []provider.Message
|
||||
resumed *agent.Session
|
||||
path string
|
||||
}
|
||||
|
||||
func (c *promptResumeCtrl) History() []provider.Message {
|
||||
return append([]provider.Message(nil), c.history...)
|
||||
}
|
||||
|
||||
func (c *promptResumeCtrl) Resume(s *agent.Session, path string) {
|
||||
c.resumed = s
|
||||
c.path = path
|
||||
}
|
||||
|
||||
func (c *promptResumeCtrl) SetSessionPath(path string) {
|
||||
c.path = path
|
||||
}
|
||||
|
||||
func TestSessionWithFreshSystemPromptPreservesLoadedRewriteBaseline(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "session.jsonl")
|
||||
s := agent.NewSession("old sys")
|
||||
s.Add(provider.Message{Role: provider.RoleUser, Content: "first"})
|
||||
s.Add(provider.Message{Role: provider.RoleAssistant, ToolCalls: []provider.ToolCall{{ID: "tool-1", Name: "read_file", Arguments: "{}"}}})
|
||||
s.Add(provider.Message{Role: provider.RoleTool, ToolCallID: "tool-1", Name: "read_file", Content: strings.Repeat("detail ", 100)})
|
||||
s.Add(provider.Message{Role: provider.RoleAssistant, Content: "done"})
|
||||
if err := s.Save(path); err != nil {
|
||||
t.Fatalf("Save base: %v", err)
|
||||
}
|
||||
|
||||
loaded, err := agent.LoadSession(path)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadSession: %v", err)
|
||||
}
|
||||
resumed := sessionWithFreshSystemPrompt(loaded, "new sys")
|
||||
msgs := resumed.Snapshot()
|
||||
msgs[3].Content = "[elided tool result]"
|
||||
resumed.Replace(msgs)
|
||||
if err := resumed.SaveRewrite(path); err != nil {
|
||||
t.Fatalf("SaveRewrite fresh-system resume: %v", err)
|
||||
}
|
||||
|
||||
reloaded, err := agent.LoadSession(path)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadSession rewritten: %v", err)
|
||||
}
|
||||
if got := reloaded.Messages[0].Content; got != "new sys" {
|
||||
t.Fatalf("system prompt after rewrite = %q, want new sys", got)
|
||||
}
|
||||
if got := reloaded.Messages[3].Content; got != "[elided tool result]" {
|
||||
t.Fatalf("tool result after rewrite = %q, want elided", got)
|
||||
}
|
||||
if matches, err := filepath.Glob(filepath.Join(filepath.Dir(path), "*-recovery-*.jsonl")); err != nil || len(matches) != 0 {
|
||||
t.Fatalf("recovery branches after owned resume rewrite = %v err=%v, want none", matches, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResumeWithFreshSystemPromptPreservesLoadedRewriteBaseline(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "session.jsonl")
|
||||
s := agent.NewSession("old sys")
|
||||
s.Add(provider.Message{Role: provider.RoleUser, Content: "first"})
|
||||
s.Add(provider.Message{Role: provider.RoleAssistant, ToolCalls: []provider.ToolCall{{ID: "tool-1", Name: "read_file", Arguments: "{}"}}})
|
||||
s.Add(provider.Message{Role: provider.RoleTool, ToolCallID: "tool-1", Name: "read_file", Content: strings.Repeat("detail ", 100)})
|
||||
s.Add(provider.Message{Role: provider.RoleAssistant, Content: "done"})
|
||||
if err := s.Save(path); err != nil {
|
||||
t.Fatalf("Save base: %v", err)
|
||||
}
|
||||
|
||||
loaded, err := agent.LoadSession(path)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadSession: %v", err)
|
||||
}
|
||||
ctrl := &promptResumeCtrl{history: []provider.Message{{Role: provider.RoleSystem, Content: "new sys"}}}
|
||||
resumeWithFreshSystemPrompt(ctrl, loaded.Snapshot(), path)
|
||||
if ctrl.resumed == nil {
|
||||
t.Fatalf("Resume was not called")
|
||||
}
|
||||
|
||||
msgs := ctrl.resumed.Snapshot()
|
||||
msgs[3].Content = "[elided tool result]"
|
||||
ctrl.resumed.Replace(msgs)
|
||||
if err := ctrl.resumed.SaveRewrite(path); err != nil {
|
||||
t.Fatalf("SaveRewrite resumed history: %v", err)
|
||||
}
|
||||
|
||||
if got := ctrl.path; got != path {
|
||||
t.Fatalf("resume path = %q, want %q", got, path)
|
||||
}
|
||||
reloaded, err := agent.LoadSession(path)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadSession rewritten: %v", err)
|
||||
}
|
||||
if got := reloaded.Messages[0].Content; got != "new sys" {
|
||||
t.Fatalf("system prompt after rewrite = %q, want new sys", got)
|
||||
}
|
||||
if got := reloaded.Messages[3].Content; got != "[elided tool result]" {
|
||||
t.Fatalf("tool result after rewrite = %q, want elided", got)
|
||||
}
|
||||
if matches, err := filepath.Glob(filepath.Join(filepath.Dir(path), "*-recovery-*.jsonl")); err != nil || len(matches) != 0 {
|
||||
t.Fatalf("recovery branches after resume rewrite = %v err=%v, want none", matches, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResumeWithFreshSystemPromptRejectsStaleCarriedHistoryBaseline(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "session.jsonl")
|
||||
current := agent.NewSession("old sys")
|
||||
current.Add(provider.Message{Role: provider.RoleUser, Content: "first"})
|
||||
current.Add(provider.Message{Role: provider.RoleAssistant, Content: "one"})
|
||||
current.Add(provider.Message{Role: provider.RoleUser, Content: "disk second"})
|
||||
current.Add(provider.Message{Role: provider.RoleAssistant, Content: "disk two"})
|
||||
if err := current.Save(path); err != nil {
|
||||
t.Fatalf("Save current: %v", err)
|
||||
}
|
||||
|
||||
stale := []provider.Message{
|
||||
{Role: provider.RoleSystem, Content: "old sys"},
|
||||
{Role: provider.RoleUser, Content: "first"},
|
||||
{Role: provider.RoleAssistant, Content: "one"},
|
||||
}
|
||||
ctrl := &promptResumeCtrl{history: []provider.Message{{Role: provider.RoleSystem, Content: "new sys"}}}
|
||||
resumeWithFreshSystemPrompt(ctrl, stale, path)
|
||||
if ctrl.resumed == nil {
|
||||
t.Fatalf("Resume was not called")
|
||||
}
|
||||
if err := ctrl.resumed.SaveRewrite(path); !errors.Is(err, agent.ErrSessionSnapshotConflict) {
|
||||
t.Fatalf("SaveRewrite stale carried history err = %v, want ErrSessionSnapshotConflict", err)
|
||||
}
|
||||
|
||||
reloaded, err := agent.LoadSession(path)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadSession current: %v", err)
|
||||
}
|
||||
if got := reloaded.Messages[len(reloaded.Messages)-1].Content; got != "disk two" {
|
||||
t.Fatalf("original tail after stale resume rewrite = %q, want disk two", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,243 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"reasonix/internal/agent"
|
||||
"reasonix/internal/provider"
|
||||
"reasonix/internal/store"
|
||||
)
|
||||
|
||||
func saveSnapshotTurns(t *testing.T, path string, turns int) *agent.Session {
|
||||
t.Helper()
|
||||
s := agent.NewSession("sys")
|
||||
for i := 0; i < turns; i++ {
|
||||
s.Add(provider.Message{Role: provider.RoleUser, Content: "prompt " + string(rune('a'+i))})
|
||||
s.Add(provider.Message{Role: provider.RoleAssistant, Content: "reply"})
|
||||
if err := s.SaveSnapshot(path); err != nil {
|
||||
t.Fatalf("SaveSnapshot turn %d: %v", i, err)
|
||||
}
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func TestMergeSessionInfosCountsRecoveryActivity(t *testing.T) {
|
||||
summaries := map[string]topicSummary{}
|
||||
now := time.Now()
|
||||
infos := []agent.SessionInfo{
|
||||
{
|
||||
Path: "/tmp/original.jsonl",
|
||||
Turns: 3,
|
||||
LastActivityAt: now.Add(-time.Hour),
|
||||
Scope: "global",
|
||||
TopicID: "topic-1",
|
||||
},
|
||||
{
|
||||
Path: "/tmp/original-recovery-abc.jsonl",
|
||||
Turns: 5,
|
||||
LastActivityAt: now,
|
||||
Scope: "global",
|
||||
TopicID: "topic-1",
|
||||
Recovered: true,
|
||||
},
|
||||
}
|
||||
mergeSessionInfos("/tmp", infos, map[string]string{}, map[string]agent.SessionInfo{}, map[string]string{}, summaries)
|
||||
summary := summaries[topicSummaryKey("global", "", "topic-1")]
|
||||
if !summary.hasNormalSession || !summary.hasRecoveryOnly {
|
||||
t.Fatalf("summary flags = %+v, want both normal and recovery seen", summary)
|
||||
}
|
||||
if summary.turns != 3 {
|
||||
t.Fatalf("turns = %d, want 3 (recovery copies must not double-count)", summary.turns)
|
||||
}
|
||||
// The copy is the live transcript after recovery: its newer activity must
|
||||
// drive topic recency, unread state, and time filters.
|
||||
if summary.lastActivityAt != now.UnixMilli() {
|
||||
t.Fatalf("lastActivityAt = %d, want recovery activity %d", summary.lastActivityAt, now.UnixMilli())
|
||||
}
|
||||
}
|
||||
|
||||
func TestTopicHiddenAsRecoveryOnly(t *testing.T) {
|
||||
recoveryOnly := topicSummary{hasRecoveryOnly: true}
|
||||
cases := []struct {
|
||||
name string
|
||||
summary topicSummary
|
||||
pinned bool
|
||||
sessions []runtimeSessionStatus
|
||||
want bool
|
||||
}{
|
||||
{"recovery-only idle", recoveryOnly, false, nil, true},
|
||||
{"normal session present", topicSummary{hasRecoveryOnly: true, hasNormalSession: true}, false, nil, false},
|
||||
{"pinned stays visible", recoveryOnly, true, nil, false},
|
||||
{"single open runtime", recoveryOnly, false, []runtimeSessionStatus{{open: true}}, false},
|
||||
// topicRuntimeStatus reports open/running only for single-session
|
||||
// topics; the hide rule must still see a two-session topic as live.
|
||||
{"two runtime sessions one open", recoveryOnly, false, []runtimeSessionStatus{{open: true}, {running: false}}, false},
|
||||
{"detached running runtime", recoveryOnly, false, []runtimeSessionStatus{{running: true}, {}}, false},
|
||||
{"idle runtime entries only", recoveryOnly, false, []runtimeSessionStatus{{}, {}}, true},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := topicHiddenAsRecoveryOnly(c.summary, c.pinned, c.sessions); got != c.want {
|
||||
t.Errorf("%s: hidden = %v, want %v", c.name, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestTrashSessionMatchesLiveSeesEventLogDivergence(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
live := filepath.Join(dir, "session.jsonl")
|
||||
s := saveSnapshotTurns(t, live, 1)
|
||||
|
||||
// Simulate an old trash copy taken at checkpoint time: same anchor bytes,
|
||||
// same event log state.
|
||||
trashDir := filepath.Join(dir, "trash")
|
||||
if err := os.MkdirAll(trashDir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
trashPath := filepath.Join(trashDir, "session.jsonl")
|
||||
for _, pair := range [][2]string{
|
||||
{live, trashPath},
|
||||
{store.SessionEventLog(live), store.SessionEventLog(trashPath)},
|
||||
} {
|
||||
b, err := os.ReadFile(pair[0])
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(pair[1], b, 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
same, err := trashSessionMatchesLive(live, trashPath)
|
||||
if err != nil {
|
||||
t.Fatalf("trashSessionMatchesLive identical: %v", err)
|
||||
}
|
||||
if !same {
|
||||
t.Fatal("identical live/trash reported as different")
|
||||
}
|
||||
|
||||
// The live session keeps chatting: growth lands in the event log only, so
|
||||
// the two .jsonl checkpoints stay byte-identical. Byte comparison would
|
||||
// call this a duplicate and delete the live session's newer history.
|
||||
s.Add(provider.Message{Role: provider.RoleUser, Content: "newer work"})
|
||||
if err := s.SaveSnapshot(live); err != nil {
|
||||
t.Fatalf("SaveSnapshot diverge: %v", err)
|
||||
}
|
||||
liveAnchor, _ := os.ReadFile(live)
|
||||
trashAnchor, _ := os.ReadFile(trashPath)
|
||||
if string(liveAnchor) != string(trashAnchor) {
|
||||
t.Skip("checkpoints diverged on disk; byte-compare trap not reproducible here")
|
||||
}
|
||||
same, err = trashSessionMatchesLive(live, trashPath)
|
||||
if err != nil {
|
||||
t.Fatalf("trashSessionMatchesLive diverged: %v", err)
|
||||
}
|
||||
if same {
|
||||
t.Fatal("live session with newer event log reported as duplicate of trash copy")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTrashPathsBlockedWhileLeaseHeld(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "session.jsonl")
|
||||
saveSnapshotTurns(t, path, 1)
|
||||
|
||||
// A live owner (any runtime — this process or another) holds the lease
|
||||
// lock on an open handle for its whole hold. Every destructive path must
|
||||
// refuse while it is held: probing once and deleting later would let the
|
||||
// owner's freshly locked lease file be unlinked out from under it.
|
||||
lease, err := agent.TryAcquireSessionLease(path)
|
||||
if err != nil {
|
||||
t.Fatalf("TryAcquireSessionLease: %v", err)
|
||||
}
|
||||
released := false
|
||||
defer func() {
|
||||
if !released {
|
||||
lease.Release()
|
||||
}
|
||||
}()
|
||||
|
||||
if err := trashSessionArtifactsBeforeMove(dir, path, "session.jsonl", nil); !errors.Is(err, errSessionBusyElsewhere) {
|
||||
t.Fatalf("trashSessionArtifactsBeforeMove err = %v, want errSessionBusyElsewhere", err)
|
||||
}
|
||||
if err := reconcileDesktopTrashSessionArtifacts(dir, path, "session.jsonl"); !errors.Is(err, errSessionBusyElsewhere) {
|
||||
t.Fatalf("reconcileDesktopTrashSessionArtifacts err = %v, want errSessionBusyElsewhere", err)
|
||||
}
|
||||
if err := removeDesktopSessionArtifacts(path); !errors.Is(err, errSessionBusyElsewhere) {
|
||||
t.Fatalf("removeDesktopSessionArtifacts err = %v, want errSessionBusyElsewhere", err)
|
||||
}
|
||||
if _, err := os.Stat(path); err != nil {
|
||||
t.Fatalf("session file touched despite live owner: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(store.SessionEventLog(path)); err != nil {
|
||||
t.Fatalf("event log touched despite live owner: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(store.SessionLeaseLock(path)); err != nil {
|
||||
t.Fatalf("lease lock deleted while held: %v", err)
|
||||
}
|
||||
|
||||
// Once the owner releases, the same trash call succeeds and the lock
|
||||
// sidecars are gone with it.
|
||||
lease.Release()
|
||||
released = true
|
||||
if err := trashSessionArtifactsBeforeMove(dir, path, "session.jsonl", nil); err != nil {
|
||||
t.Fatalf("trashSessionArtifactsBeforeMove after release: %v", err)
|
||||
}
|
||||
for _, p := range []string{
|
||||
path,
|
||||
store.SessionLockFile(path),
|
||||
store.SessionLeaseLock(path),
|
||||
store.SessionLeaseInfo(path),
|
||||
} {
|
||||
if _, err := os.Stat(p); !os.IsNotExist(err) {
|
||||
t.Errorf("artifact survived trash: %s (err=%v)", p, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPromptHistorySeesEventLogPrompts(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "session.jsonl")
|
||||
s := agent.NewSession("sys")
|
||||
s.Add(provider.Message{Role: provider.RoleUser, Content: "first prompt"})
|
||||
if err := s.SaveSnapshot(path); err != nil {
|
||||
t.Fatalf("SaveSnapshot: %v", err)
|
||||
}
|
||||
s.Add(provider.Message{Role: provider.RoleAssistant, Content: "reply"})
|
||||
s.Add(provider.Message{Role: provider.RoleUser, Content: "second prompt"})
|
||||
if err := s.SaveSnapshot(path); err != nil {
|
||||
t.Fatalf("SaveSnapshot append: %v", err)
|
||||
}
|
||||
|
||||
info, err := os.Stat(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
entries, err := collectPromptHistoryEntries(path, info, func(s string) string { return s })
|
||||
if err != nil {
|
||||
t.Fatalf("collectPromptHistoryEntries: %v", err)
|
||||
}
|
||||
if len(entries) != 2 {
|
||||
t.Fatalf("prompt history entries = %d, want 2 (event-log prompts must appear)", len(entries))
|
||||
}
|
||||
if entries[0].Text != "first prompt" || entries[1].Text != "second prompt" {
|
||||
t.Fatalf("prompt history texts = %q, %q", entries[0].Text, entries[1].Text)
|
||||
}
|
||||
if entries[1].At == 0 {
|
||||
t.Fatal("appended prompt lost its timestamp")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTopicTitleUserTurnsSeesEventLogTurns(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "session.jsonl")
|
||||
saveSnapshotTurns(t, path, 3)
|
||||
|
||||
users := topicTitleUserTurnsFromSession(path)
|
||||
if len(users) != 3 {
|
||||
t.Fatalf("user turns = %d, want 3 (≥3-turn title upgrade depends on this)", len(users))
|
||||
}
|
||||
}
|
||||
+171
-16
@@ -3,6 +3,7 @@ package main
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
@@ -144,12 +145,36 @@ func sessionTrashArtifacts(sessionPath, key string) []sessionTrashArtifact {
|
||||
{src: sessionPath, name: key},
|
||||
{src: store.SessionMeta(sessionPath), name: key + ".meta"},
|
||||
{src: store.SessionGoalState(sessionPath), name: stem + ".goal-state.json"},
|
||||
{src: store.SessionEventLog(sessionPath), name: stem + ".events.jsonl"},
|
||||
{src: store.SessionEventIndex(sessionPath), name: stem + ".event-index.json"},
|
||||
{src: sessionTelemetryPath(sessionPath), name: key + ".telemetry.json"},
|
||||
{src: store.SessionCheckpointDir(sessionPath), name: stem + ".ckpt"},
|
||||
{src: store.SessionJobsDir(sessionPath), name: stem + ".jobs"},
|
||||
}
|
||||
}
|
||||
|
||||
// errSessionBusyElsewhere is the sanitized error surfaced when a destructive
|
||||
// session operation is blocked by a live owner. It intentionally carries no
|
||||
// writer id, hostname, or path.
|
||||
var errSessionBusyElsewhere = errors.New("session is in use by another Reasonix window or process")
|
||||
|
||||
// acquireSessionRemovalGuard wraps agent.TryAcquireSessionRemovalGuard with
|
||||
// the sanitized busy error. The guard holds the session's save and lease
|
||||
// locks across the destructive operation and deletes the lock files
|
||||
// atomically with the release — a one-shot busy probe followed by RemoveAll
|
||||
// would let another process acquire the lease in between and then lose its
|
||||
// freshly locked lease file, breaking cross-process mutual exclusion.
|
||||
func acquireSessionRemovalGuard(sessionPath string) (*agent.SessionRemovalGuard, error) {
|
||||
guard, err := agent.TryAcquireSessionRemovalGuard(sessionPath)
|
||||
if err != nil {
|
||||
if errors.Is(err, agent.ErrSessionLeaseHeld) {
|
||||
return nil, errSessionBusyElsewhere
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return guard, nil
|
||||
}
|
||||
|
||||
func sessionOwnedArtifactPaths(sessionPath string) []string {
|
||||
key := filepath.Base(sessionPath)
|
||||
artifacts := sessionTrashArtifacts(sessionPath, key)
|
||||
@@ -180,6 +205,14 @@ func reconcileDesktopCleanupPending(dir string) error {
|
||||
}
|
||||
|
||||
func reconcileDesktopTrashSessionArtifacts(dir, sessionPath, key string) error {
|
||||
// Hold the removal guard across the whole move so no runtime can acquire
|
||||
// the session (or save into it) while its artifacts are relocated; the
|
||||
// lock sidecars are deleted atomically with the guard release.
|
||||
guard, err := acquireSessionRemovalGuard(sessionPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer guard.Release()
|
||||
itemDir := filepath.Join(sessionTrashPath(dir), key)
|
||||
if err := os.MkdirAll(itemDir, 0o755); err != nil {
|
||||
return err
|
||||
@@ -192,6 +225,9 @@ func reconcileDesktopTrashSessionArtifacts(dir, sessionPath, key string) error {
|
||||
if err := trashSubagentArtifacts(dir, sessionPath, itemDir); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := guard.RemoveSidecarsAndRelease(); err != nil {
|
||||
return err
|
||||
}
|
||||
meta := trashedSessionMeta{Key: key, DeletedAt: time.Now().UnixMilli()}
|
||||
b, err := json.MarshalIndent(meta, "", " ")
|
||||
if err != nil {
|
||||
@@ -216,11 +252,11 @@ func validateSessionTrashTarget(dir, sessionPath, key string) error {
|
||||
}
|
||||
trashPath := filepath.Join(itemDir, key)
|
||||
if trashInfo, err := os.Stat(trashPath); err == nil && !trashInfo.IsDir() {
|
||||
discardable, err := liveSessionDiscardable(sessionPath)
|
||||
removable, err := liveSessionRemovableWithExistingTrash(sessionPath, trashPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if discardable {
|
||||
if removable {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("session already exists in trash: %s", key)
|
||||
@@ -247,11 +283,11 @@ func prepareSessionTrashTarget(dir, sessionPath, key string) (bool, error) {
|
||||
}
|
||||
trashPath := filepath.Join(itemDir, key)
|
||||
if trashInfo, err := os.Stat(trashPath); err == nil && !trashInfo.IsDir() {
|
||||
discardable, err := liveSessionDiscardable(sessionPath)
|
||||
removable, err := liveSessionRemovableWithExistingTrash(sessionPath, trashPath)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if discardable {
|
||||
if removable {
|
||||
return false, removeDesktopSessionArtifacts(sessionPath)
|
||||
}
|
||||
return false, fmt.Errorf("session already exists in trash: %s", key)
|
||||
@@ -267,6 +303,29 @@ func prepareSessionTrashTarget(dir, sessionPath, key string) (bool, error) {
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// liveSessionRemovableWithExistingTrash reports whether a live session file may
|
||||
// be removed even though a trash copy already exists under the same key: the
|
||||
// live file must be discardable (empty stub) or byte-identical to the trash
|
||||
// copy, and no other runtime may hold its session lease — another process could
|
||||
// be mid-write, and removing the file would silently drop its next save.
|
||||
func liveSessionRemovableWithExistingTrash(sessionPath, trashPath string) (bool, error) {
|
||||
discardable, err := liveSessionDiscardable(sessionPath)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
duplicate := false
|
||||
if !discardable {
|
||||
duplicate, err = trashSessionMatchesLive(sessionPath, trashPath)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
}
|
||||
if !discardable && !duplicate {
|
||||
return false, nil
|
||||
}
|
||||
return !agent.SessionLeaseHeldByOtherRuntime(sessionPath), nil
|
||||
}
|
||||
|
||||
func liveSessionDiscardable(sessionPath string) (bool, error) {
|
||||
if agent.IsCleanupPending(sessionPath) {
|
||||
return true, nil
|
||||
@@ -291,6 +350,20 @@ func liveSessionDiscardable(sessionPath string) (bool, error) {
|
||||
return !session.HasContent(), nil
|
||||
}
|
||||
|
||||
func trashSessionMatchesLive(sessionPath, trashPath string) (bool, error) {
|
||||
if _, err := os.Stat(sessionPath); err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return true, nil
|
||||
}
|
||||
return false, err
|
||||
}
|
||||
// Compare decoded transcripts, not .jsonl bytes: the checkpoint only
|
||||
// changes at checkpoints, so two byte-identical .jsonl files can hide
|
||||
// diverged event logs — and treating them as duplicates would delete the
|
||||
// live session's newer history.
|
||||
return agent.SessionsShareContent(sessionPath, trashPath)
|
||||
}
|
||||
|
||||
func sessionFileHasConversationContent(sessionPath string) bool {
|
||||
if strings.TrimSpace(sessionPath) == "" || agent.IsCleanupPending(sessionPath) {
|
||||
return false
|
||||
@@ -317,6 +390,13 @@ func trashSessionArtifactsBeforeMove(dir, sessionPath, key string, beforeMove fu
|
||||
if !shouldMove {
|
||||
return nil
|
||||
}
|
||||
// Acquired after prepareSessionTrashTarget: the duplicate-trash path in
|
||||
// there takes its own removal guard, and the guard is not reentrant.
|
||||
guard, err := acquireSessionRemovalGuard(sessionPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer guard.Release()
|
||||
itemDir := filepath.Join(sessionTrashPath(dir), key)
|
||||
if err := os.MkdirAll(itemDir, 0o755); err != nil {
|
||||
return err
|
||||
@@ -332,6 +412,9 @@ func trashSessionArtifactsBeforeMove(dir, sessionPath, key string, beforeMove fu
|
||||
if err := trashSubagentArtifacts(dir, sessionPath, itemDir); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := guard.RemoveSidecarsAndRelease(); err != nil {
|
||||
return err
|
||||
}
|
||||
meta := trashedSessionMeta{Key: key, DeletedAt: time.Now().UnixMilli()}
|
||||
b, err := json.MarshalIndent(meta, "", " ")
|
||||
if err != nil {
|
||||
@@ -443,11 +526,8 @@ func purgeTrashedSessionFile(dir, path string) error {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if dm := loadSessionDisplays(dir); dm[key] != nil {
|
||||
delete(dm, key)
|
||||
if err := saveSessionDisplays(dir, dm); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := removeSessionDisplayKey(dir, key); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -580,11 +660,15 @@ func trashSubagentArtifacts(dir, sessionPath, itemDir string) error {
|
||||
}
|
||||
trashSubagentDir := filepath.Join(itemDir, "subagents")
|
||||
for _, artifact := range artifacts {
|
||||
if err := movePathIfExists(artifact.SessionPath, filepath.Join(trashSubagentDir, filepath.Base(artifact.SessionPath))); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := movePathIfExists(artifact.MetaPath, filepath.Join(trashSubagentDir, filepath.Base(artifact.MetaPath))); err != nil {
|
||||
return err
|
||||
paths := []string{artifact.SessionPath, artifact.MetaPath}
|
||||
paths = append(paths, store.SessionSidecarFiles(artifact.SessionPath)...)
|
||||
for _, src := range paths {
|
||||
if strings.TrimSpace(src) == "" {
|
||||
continue
|
||||
}
|
||||
if err := movePathIfExists(src, filepath.Join(trashSubagentDir, filepath.Base(src))); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
@@ -649,7 +733,7 @@ func validateSessionPath(dir, sessionPath string) (string, string, error) {
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
if filepath.Ext(absPath) != ".jsonl" {
|
||||
if !store.IsSessionTranscriptName(filepath.Base(absPath)) {
|
||||
return "", "", fmt.Errorf("not a session file: %s", sessionPath)
|
||||
}
|
||||
rel, err := filepath.Rel(absDir, absPath)
|
||||
@@ -694,7 +778,7 @@ func validateTrashedSessionPath(dir, sessionPath string) (string, string, string
|
||||
if err != nil {
|
||||
return "", "", "", err
|
||||
}
|
||||
if filepath.Ext(absPath) != ".jsonl" {
|
||||
if !store.IsSessionTranscriptName(filepath.Base(absPath)) {
|
||||
return "", "", "", fmt.Errorf("not a session file: %s", sessionPath)
|
||||
}
|
||||
rel, err := filepath.Rel(root, absPath)
|
||||
@@ -853,6 +937,77 @@ func saveSessionDisplays(dir string, m sessionDisplayMap) error {
|
||||
return fileutil.ReplaceFile(tmpPath, sessionDisplayPath(dir))
|
||||
}
|
||||
|
||||
func saveOrRemoveSessionDisplays(dir string, m sessionDisplayMap) error {
|
||||
if len(m) == 0 {
|
||||
err := os.Remove(sessionDisplayPath(dir))
|
||||
if os.IsNotExist(err) {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
return saveSessionDisplays(dir, m)
|
||||
}
|
||||
|
||||
func removeSessionDisplayKey(dir, key string) error {
|
||||
key = strings.TrimSpace(key)
|
||||
if key == "" {
|
||||
return nil
|
||||
}
|
||||
m := loadSessionDisplays(dir)
|
||||
if m[key] == nil {
|
||||
return nil
|
||||
}
|
||||
delete(m, key)
|
||||
return saveOrRemoveSessionDisplays(dir, m)
|
||||
}
|
||||
|
||||
func removeSessionDisplay(dir, sessionPath string) error {
|
||||
if strings.TrimSpace(sessionPath) == "" {
|
||||
return nil
|
||||
}
|
||||
return removeSessionDisplayKey(dir, filepath.Base(sessionPath))
|
||||
}
|
||||
|
||||
func pruneSessionDisplays(dir string, protected map[string]struct{}) error {
|
||||
m := loadSessionDisplays(dir)
|
||||
if len(m) == 0 {
|
||||
return nil
|
||||
}
|
||||
changed := false
|
||||
for key := range m {
|
||||
if sessionDisplayKeyStillOwned(dir, key, protected) {
|
||||
continue
|
||||
}
|
||||
delete(m, key)
|
||||
changed = true
|
||||
}
|
||||
if !changed {
|
||||
return nil
|
||||
}
|
||||
return saveOrRemoveSessionDisplays(dir, m)
|
||||
}
|
||||
|
||||
func sessionDisplayKeyStillOwned(dir, key string, protected map[string]struct{}) bool {
|
||||
key = strings.TrimSpace(key)
|
||||
if key == "" || filepath.Base(key) != key || !store.IsSessionTranscriptName(key) {
|
||||
return false
|
||||
}
|
||||
if protected != nil {
|
||||
if _, ok := protected[key]; ok {
|
||||
return true
|
||||
}
|
||||
}
|
||||
sessionPath := filepath.Join(dir, key)
|
||||
if info, err := os.Stat(sessionPath); err == nil && !info.IsDir() {
|
||||
return true
|
||||
}
|
||||
trashPath := filepath.Join(sessionTrashPath(dir), key, key)
|
||||
if info, err := os.Stat(trashPath); err == nil && !info.IsDir() {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func recordSessionDisplay(dir, sessionPath, content, display string) error {
|
||||
if strings.TrimSpace(sessionPath) == "" || content == display || strings.TrimSpace(display) == "" {
|
||||
return nil
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
//go:build !windows
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"golang.org/x/sys/unix"
|
||||
|
||||
"reasonix/internal/agent"
|
||||
"reasonix/internal/config"
|
||||
"reasonix/internal/control"
|
||||
)
|
||||
|
||||
// simulateForeignSessionLeaseHolder holds path's lease the way another process
|
||||
// would: the on-disk info names a foreign writer and a raw flock on a separate
|
||||
// fd keeps the lock file locked without registering in this process's
|
||||
// in-process lease bookkeeping (flock excludes other fds even within one
|
||||
// process, so this faithfully mimics a foreign holder).
|
||||
func simulateForeignSessionLeaseHolder(t *testing.T, path string) {
|
||||
t.Helper()
|
||||
if err := agent.SaveSessionLeaseInfo(path, agent.SessionLeaseInfo{
|
||||
SessionPath: path,
|
||||
WriterID: "other-host-4242-cafebabe",
|
||||
PID: os.Getpid() + 1,
|
||||
AcquiredAt: time.Now().UTC(),
|
||||
}); err != nil {
|
||||
t.Fatalf("SaveSessionLeaseInfo: %v", err)
|
||||
}
|
||||
f, err := os.OpenFile(path+".lease.lock", os.O_CREATE|os.O_RDWR, 0o600)
|
||||
if err != nil {
|
||||
t.Fatalf("open lease lock: %v", err)
|
||||
}
|
||||
if err := unix.Flock(int(f.Fd()), unix.LOCK_EX|unix.LOCK_NB); err != nil {
|
||||
_ = f.Close()
|
||||
t.Fatalf("flock lease lock: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
_ = unix.Flock(int(f.Fd()), unix.LOCK_UN)
|
||||
_ = f.Close()
|
||||
_ = os.Remove(path + ".lease.lock")
|
||||
_ = os.Remove(path + ".lease.json")
|
||||
})
|
||||
}
|
||||
|
||||
func TestDeleteSessionKeepsDuplicateLiveSessionHeldByOtherRuntime(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
|
||||
dir := config.SessionDir()
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
t.Fatalf("mkdir session dir: %v", err)
|
||||
}
|
||||
path := filepath.Join(dir, "duplicate-foreign-held.jsonl")
|
||||
content := []byte(`{"role":"user","content":"same recovery"}` + "\n")
|
||||
if err := os.WriteFile(path, content, 0o644); err != nil {
|
||||
t.Fatalf("write live session: %v", err)
|
||||
}
|
||||
trashPath := filepath.Join(dir, sessionTrashDir, filepath.Base(path), filepath.Base(path))
|
||||
if err := os.MkdirAll(filepath.Dir(trashPath), 0o755); err != nil {
|
||||
t.Fatalf("create trash dir: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(trashPath, content, 0o644); err != nil {
|
||||
t.Fatalf("write trash session: %v", err)
|
||||
}
|
||||
simulateForeignSessionLeaseHolder(t, path)
|
||||
|
||||
activePath := filepath.Join(dir, "active.jsonl")
|
||||
if err := os.WriteFile(activePath, []byte(`{"role":"user","content":"active"}`+"\n"), 0o644); err != nil {
|
||||
t.Fatalf("write active session: %v", err)
|
||||
}
|
||||
activeCtrl := control.New(control.Options{SessionDir: dir, SessionPath: activePath, Label: "active"})
|
||||
defer activeCtrl.Close()
|
||||
app := &App{
|
||||
tabs: map[string]*WorkspaceTab{"active": {ID: "active", Scope: "global", Ctrl: activeCtrl, Ready: true}},
|
||||
activeTabID: "active",
|
||||
tabOrder: []string{"active"},
|
||||
}
|
||||
|
||||
err := app.DeleteSession(filepath.Base(path))
|
||||
if err == nil || !strings.Contains(err.Error(), "session already exists in trash") {
|
||||
t.Fatalf("DeleteSession err = %v, want refusal while a foreign runtime holds the lease", err)
|
||||
}
|
||||
if got, readErr := os.ReadFile(path); readErr != nil || string(got) != string(content) {
|
||||
t.Fatalf("live session must stay intact, got %q err=%v", string(got), readErr)
|
||||
}
|
||||
}
|
||||
@@ -165,8 +165,18 @@ func TestDeleteSessionFile(t *testing.T) {
|
||||
os.WriteFile(metaPath, []byte("{}"), 0o644)
|
||||
goalPath := store.SessionGoalState(sessionPath)
|
||||
os.WriteFile(goalPath, []byte(`{"goal":"ship"}`), 0o644)
|
||||
eventLogPath := store.SessionEventLog(sessionPath)
|
||||
os.WriteFile(eventLogPath, []byte(`{"schema_version":1,"type":"replace","messages":[{"role":"user","content":"event"}]}`+"\n"), 0o644)
|
||||
eventIndexPath := store.SessionEventIndex(sessionPath)
|
||||
os.WriteFile(eventIndexPath, []byte(`{"schema_version":1,"message_count":1}`), 0o644)
|
||||
telemetryPath := sessionTelemetryPath(sessionPath)
|
||||
os.WriteFile(telemetryPath, []byte(`{"version":2,"readFiles":[]}`), 0o644)
|
||||
lockPath := store.SessionLockFile(sessionPath)
|
||||
os.WriteFile(lockPath, nil, 0o644)
|
||||
leaseLockPath := store.SessionLeaseLock(sessionPath)
|
||||
os.WriteFile(leaseLockPath, nil, 0o644)
|
||||
leaseInfoPath := store.SessionLeaseInfo(sessionPath)
|
||||
os.WriteFile(leaseInfoPath, []byte(`{"writer_id":"stale"}`), 0o644)
|
||||
ckptDir := filepath.Join(dir, "session.ckpt")
|
||||
if err := os.MkdirAll(ckptDir, 0o755); err != nil {
|
||||
t.Fatalf("mkdir ckpt: %v", err)
|
||||
@@ -192,6 +202,8 @@ func TestDeleteSessionFile(t *testing.T) {
|
||||
trashPath := filepath.Join(dir, sessionTrashDir, "session.jsonl", "session.jsonl")
|
||||
trashMetaPath := trashPath + ".meta"
|
||||
trashGoalPath := filepath.Join(dir, sessionTrashDir, "session.jsonl", "session.goal-state.json")
|
||||
trashEventLogPath := filepath.Join(dir, sessionTrashDir, "session.jsonl", "session.events.jsonl")
|
||||
trashEventIndexPath := filepath.Join(dir, sessionTrashDir, "session.jsonl", "session.event-index.json")
|
||||
trashTelemetryPath := filepath.Join(dir, sessionTrashDir, "session.jsonl", "session.jsonl.telemetry.json")
|
||||
trashCkptDir := filepath.Join(dir, sessionTrashDir, "session.jsonl", "session.ckpt")
|
||||
trashJobsDir := filepath.Join(dir, sessionTrashDir, "session.jsonl", "session.jobs")
|
||||
@@ -206,9 +218,24 @@ func TestDeleteSessionFile(t *testing.T) {
|
||||
if _, err := os.Stat(goalPath); !os.IsNotExist(err) {
|
||||
t.Error("session goal state should be removed from active sessions")
|
||||
}
|
||||
if _, err := os.Stat(eventLogPath); !os.IsNotExist(err) {
|
||||
t.Error("session event log should be removed from active sessions")
|
||||
}
|
||||
if _, err := os.Stat(eventIndexPath); !os.IsNotExist(err) {
|
||||
t.Error("session event index should be removed from active sessions")
|
||||
}
|
||||
if _, err := os.Stat(telemetryPath); !os.IsNotExist(err) {
|
||||
t.Error("session telemetry should be removed from active sessions")
|
||||
}
|
||||
if _, err := os.Stat(lockPath); !os.IsNotExist(err) {
|
||||
t.Error("session lock should be removed from active sessions")
|
||||
}
|
||||
if _, err := os.Stat(leaseLockPath); !os.IsNotExist(err) {
|
||||
t.Error("session lease lock should be removed from active sessions")
|
||||
}
|
||||
if _, err := os.Stat(leaseInfoPath); !os.IsNotExist(err) {
|
||||
t.Error("session lease info should be removed from active sessions")
|
||||
}
|
||||
if _, err := os.Stat(ckptDir); !os.IsNotExist(err) {
|
||||
t.Error("session checkpoints should be removed from active sessions")
|
||||
}
|
||||
@@ -224,9 +251,24 @@ func TestDeleteSessionFile(t *testing.T) {
|
||||
if _, err := os.Stat(trashGoalPath); err != nil {
|
||||
t.Fatalf("session goal state should be in trash: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(trashEventLogPath); err != nil {
|
||||
t.Fatalf("session event log should be in trash: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(trashEventIndexPath); err != nil {
|
||||
t.Fatalf("session event index should be in trash: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(trashTelemetryPath); err != nil {
|
||||
t.Fatalf("session telemetry should be in trash: %v", err)
|
||||
}
|
||||
for _, p := range []string{
|
||||
filepath.Join(dir, sessionTrashDir, "session.jsonl", "session.jsonl.lock"),
|
||||
filepath.Join(dir, sessionTrashDir, "session.jsonl", "session.jsonl.lease.lock"),
|
||||
filepath.Join(dir, sessionTrashDir, "session.jsonl", "session.jsonl.lease.json"),
|
||||
} {
|
||||
if _, err := os.Stat(p); !os.IsNotExist(err) {
|
||||
t.Fatalf("ephemeral session artifact should not be moved to trash: %s (err=%v)", p, err)
|
||||
}
|
||||
}
|
||||
if _, err := os.Stat(trashCkptDir); err != nil {
|
||||
t.Fatalf("session checkpoints should be in trash: %v", err)
|
||||
}
|
||||
@@ -442,6 +484,14 @@ func TestRestoreTrashedSessionFile(t *testing.T) {
|
||||
if err := os.WriteFile(goalPath, []byte(`{"goal":"restore"}`), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
eventLogPath := store.SessionEventLog(sessionPath)
|
||||
if err := os.WriteFile(eventLogPath, []byte(`{"schema_version":1,"type":"replace","messages":[{"role":"user","content":"event"}]}`+"\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
eventIndexPath := store.SessionEventIndex(sessionPath)
|
||||
if err := os.WriteFile(eventIndexPath, []byte(`{"schema_version":1,"message_count":1}`), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
telemetryPath := sessionTelemetryPath(sessionPath)
|
||||
if err := os.WriteFile(telemetryPath, []byte(`{"version":2,"readFiles":[]}`), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -481,6 +531,12 @@ func TestRestoreTrashedSessionFile(t *testing.T) {
|
||||
if _, err := os.Stat(goalPath); err != nil {
|
||||
t.Fatalf("session goal state should be restored: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(eventLogPath); err != nil {
|
||||
t.Fatalf("session event log should be restored: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(eventIndexPath); err != nil {
|
||||
t.Fatalf("session event index should be restored: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(telemetryPath); err != nil {
|
||||
t.Fatalf("session telemetry should be restored: %v", err)
|
||||
}
|
||||
@@ -731,12 +787,20 @@ func TestRemoveDesktopSessionArtifactsRemovesOwnedSidecars(t *testing.T) {
|
||||
sessionPath,
|
||||
store.SessionMeta(sessionPath),
|
||||
store.SessionGoalState(sessionPath),
|
||||
store.SessionEventLog(sessionPath),
|
||||
store.SessionEventIndex(sessionPath),
|
||||
sessionTelemetryPath(sessionPath),
|
||||
store.SessionLockFile(sessionPath),
|
||||
store.SessionLeaseLock(sessionPath),
|
||||
store.SessionLeaseInfo(sessionPath),
|
||||
} {
|
||||
if err := os.WriteFile(p, []byte("{}"), 0o644); err != nil {
|
||||
t.Fatalf("write %s: %v", p, err)
|
||||
}
|
||||
}
|
||||
if err := recordSessionDisplay(dir, sessionPath, "expanded prompt", "[Pasted text #1 · 2 lines]"); err != nil {
|
||||
t.Fatalf("record display: %v", err)
|
||||
}
|
||||
ckptDir := store.SessionCheckpointDir(sessionPath)
|
||||
if err := os.MkdirAll(ckptDir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -760,7 +824,12 @@ func TestRemoveDesktopSessionArtifactsRemovesOwnedSidecars(t *testing.T) {
|
||||
sessionPath,
|
||||
store.SessionMeta(sessionPath),
|
||||
store.SessionGoalState(sessionPath),
|
||||
store.SessionEventLog(sessionPath),
|
||||
store.SessionEventIndex(sessionPath),
|
||||
sessionTelemetryPath(sessionPath),
|
||||
store.SessionLockFile(sessionPath),
|
||||
store.SessionLeaseLock(sessionPath),
|
||||
store.SessionLeaseInfo(sessionPath),
|
||||
ckptDir,
|
||||
jobsDir,
|
||||
} {
|
||||
@@ -768,6 +837,9 @@ func TestRemoveDesktopSessionArtifactsRemovesOwnedSidecars(t *testing.T) {
|
||||
t.Fatalf("%s should be removed, stat err = %v", p, err)
|
||||
}
|
||||
}
|
||||
if got := resolveSessionDisplay(dir, sessionPath, "expanded prompt"); got != "expanded prompt" {
|
||||
t.Fatalf("session display key should be removed, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteSessionFileRejectsOutsideDir(t *testing.T) {
|
||||
@@ -956,6 +1028,48 @@ func TestSessionDisplayRoundTrip(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestPruneSessionDisplaysRemovesOnlyOrphans(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
content := "expanded prompt"
|
||||
if err := saveSessionDisplays(dir, sessionDisplayMap{
|
||||
"live.jsonl": map[string]string{messageDisplayKey(content): "live display"},
|
||||
"trashed.jsonl": map[string]string{messageDisplayKey(content): "trash display"},
|
||||
"protected.jsonl": map[string]string{messageDisplayKey(content): "protected display"},
|
||||
"missing.jsonl": map[string]string{messageDisplayKey(content): "missing display"},
|
||||
"sidecar.events.jsonl": map[string]string{
|
||||
messageDisplayKey(content): "invalid display",
|
||||
},
|
||||
}); err != nil {
|
||||
t.Fatalf("save displays: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dir, "live.jsonl"), []byte("data"), 0o644); err != nil {
|
||||
t.Fatalf("write live session: %v", err)
|
||||
}
|
||||
trashDir := filepath.Join(dir, sessionTrashDir, "trashed.jsonl")
|
||||
if err := os.MkdirAll(trashDir, 0o755); err != nil {
|
||||
t.Fatalf("mkdir trash: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(trashDir, "trashed.jsonl"), []byte("data"), 0o644); err != nil {
|
||||
t.Fatalf("write trashed session: %v", err)
|
||||
}
|
||||
|
||||
if err := pruneSessionDisplays(dir, map[string]struct{}{"protected.jsonl": {}}); err != nil {
|
||||
t.Fatalf("prune displays: %v", err)
|
||||
}
|
||||
|
||||
got := loadSessionDisplays(dir)
|
||||
for _, key := range []string{"live.jsonl", "trashed.jsonl", "protected.jsonl"} {
|
||||
if got[key] == nil {
|
||||
t.Fatalf("%s display should be retained; got %#v", key, got)
|
||||
}
|
||||
}
|
||||
for _, key := range []string{"missing.jsonl", "sidecar.events.jsonl"} {
|
||||
if got[key] != nil {
|
||||
t.Fatalf("%s display should be pruned; got %#v", key, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecordSessionDisplaySkipsNoop(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
sessionPath := filepath.Join(dir, "s.jsonl")
|
||||
|
||||
+1212
-265
File diff suppressed because it is too large.
Load diff
@@ -97,6 +97,16 @@ func TestProviderViewFromEntry_MigratesProviderWideVision(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestProviderViewFromEntryIncludesThinking(t *testing.T) {
|
||||
view := providerViewFromEntry(config.ProviderEntry{
|
||||
Name: "anthropic",
|
||||
Thinking: "ADAPTIVE",
|
||||
}, false, true)
|
||||
if view.Thinking != "adaptive" {
|
||||
t.Fatalf("ProviderView.Thinking = %q, want adaptive", view.Thinking)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProviderViewFromEntryShowsKeySource(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
t.Setenv("TEST_PROVIDER_KEY_SOURCE", "")
|
||||
@@ -120,6 +130,43 @@ func TestProviderViewFromEntryShowsKeySource(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSettingsExposesEffectiveSandboxWriteRoots(t *testing.T) {
|
||||
home := isolateDesktopUserDirs(t)
|
||||
project := robustTempDir(t)
|
||||
cfg := config.LoadForEdit(config.UserConfigPath())
|
||||
cfg.Sandbox.AllowWrite = []string{
|
||||
"${HOME}/.m2",
|
||||
"${HOME}/.m2/repository",
|
||||
}
|
||||
if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
|
||||
t.Fatalf("save config: %v", err)
|
||||
}
|
||||
|
||||
app := NewApp()
|
||||
app.tabs = map[string]*WorkspaceTab{
|
||||
"project": {ID: "project", Scope: "project", WorkspaceRoot: project, Ready: true},
|
||||
}
|
||||
app.activeTabID = "project"
|
||||
|
||||
got := app.Settings().Sandbox
|
||||
if got.EffectiveWorkspaceRoot != project {
|
||||
t.Fatalf("EffectiveWorkspaceRoot = %q, want %q", got.EffectiveWorkspaceRoot, project)
|
||||
}
|
||||
// Settings expose expanded configured roots; the writer confiner normalizes
|
||||
// separators later when enforcing them.
|
||||
want := []string{
|
||||
project,
|
||||
home + "/.m2",
|
||||
home + "/.m2/repository",
|
||||
}
|
||||
if !reflect.DeepEqual(got.EffectiveWriteRoots, want) {
|
||||
t.Fatalf("EffectiveWriteRoots = %v, want %v", got.EffectiveWriteRoots, want)
|
||||
}
|
||||
if !reflect.DeepEqual(got.AllowWrite, cfg.Sandbox.AllowWrite) {
|
||||
t.Fatalf("AllowWrite = %v, want raw configured paths %v", got.AllowWrite, cfg.Sandbox.AllowWrite)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProviderViewFromEntryExposesNoAuthAvailability(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
t.Setenv("LOCAL_API_KEY", "")
|
||||
@@ -382,6 +429,60 @@ func TestSaveProviderFiltersNonChatModels(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSaveProviderPersistsThinkingOverride(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
|
||||
app := NewApp()
|
||||
if err := app.SaveProvider(ProviderView{
|
||||
Name: "glm-proxy",
|
||||
Kind: "openai",
|
||||
BaseURL: "https://proxy.example.com/v1",
|
||||
Models: []string{"glm-4.5-air"},
|
||||
APIKeyEnv: "GLM_PROXY_API_KEY",
|
||||
Thinking: "DISABLED",
|
||||
}); err != nil {
|
||||
t.Fatalf("SaveProvider: %v", err)
|
||||
}
|
||||
|
||||
cfg := config.LoadForEdit(config.UserConfigPath())
|
||||
got, ok := cfg.Provider("glm-proxy")
|
||||
if !ok {
|
||||
t.Fatal("saved provider not found")
|
||||
}
|
||||
if got.Thinking != "disabled" {
|
||||
t.Fatalf("saved provider thinking = %q, want disabled", got.Thinking)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSaveProviderPersistsAuthHeader(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
|
||||
app := NewApp()
|
||||
if err := app.SaveProvider(ProviderView{
|
||||
Name: "minimax-global-anthropic",
|
||||
Kind: "anthropic",
|
||||
BaseURL: "https://api.minimax.io/anthropic",
|
||||
Models: []string{"MiniMax-M3"},
|
||||
APIKeyEnv: "MINIMAX_API_KEY",
|
||||
AuthHeader: true,
|
||||
}); err != nil {
|
||||
t.Fatalf("SaveProvider: %v", err)
|
||||
}
|
||||
|
||||
cfg := config.LoadForEdit(config.UserConfigPath())
|
||||
got, ok := cfg.Provider("minimax-global-anthropic")
|
||||
if !ok {
|
||||
t.Fatal("saved provider not found")
|
||||
}
|
||||
if !got.AuthHeader {
|
||||
t.Fatal("saved provider auth_header = false, want true")
|
||||
}
|
||||
view := providerViewFromEntry(*got, false, true)
|
||||
if !view.AuthHeader {
|
||||
t.Fatal("provider view authHeader = false, want true")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSaveProviderPersistsCustomEndpointURLs(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
|
||||
@@ -443,6 +544,7 @@ func TestSaveProviderPreservesHiddenProviderFields(t *testing.T) {
|
||||
Thinking: "adaptive",
|
||||
Effort: "high",
|
||||
VisionDetail: "low",
|
||||
ExtraBody: map[string]any{"enable_thinking": true},
|
||||
NoProxy: true,
|
||||
}}
|
||||
if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
|
||||
@@ -463,6 +565,9 @@ func TestSaveProviderPreservesHiddenProviderFields(t *testing.T) {
|
||||
if !found {
|
||||
t.Fatalf("Settings providers missing custom: %+v", settings.Providers)
|
||||
}
|
||||
if view.ExtraBody["enable_thinking"] != true {
|
||||
t.Fatalf("settings extra_body = %+v, want enable_thinking=true", view.ExtraBody)
|
||||
}
|
||||
|
||||
if err := app.SaveProvider(view); err != nil {
|
||||
t.Fatalf("SaveProvider: %v", err)
|
||||
@@ -485,6 +590,9 @@ func TestSaveProviderPreservesHiddenProviderFields(t *testing.T) {
|
||||
if got.VisionDetail != "low" {
|
||||
t.Fatalf("vision_detail = %q, want low", got.VisionDetail)
|
||||
}
|
||||
if got.ExtraBody["enable_thinking"] != true {
|
||||
t.Fatalf("extra_body = %+v, want enable_thinking=true", got.ExtraBody)
|
||||
}
|
||||
if !got.NoProxy {
|
||||
t.Fatal("no_proxy = false, want preserved true")
|
||||
}
|
||||
@@ -721,7 +829,7 @@ func TestSetReasoningLanguageUpdatesLiveTabControllers(t *testing.T) {
|
||||
}
|
||||
|
||||
userComposed := userCtrl.Compose("hi")
|
||||
if !strings.Contains(userComposed, "Simplified Chinese") {
|
||||
if !strings.Contains(userComposed, "简体中文") {
|
||||
t.Fatalf("user-level tab Compose = %q, want zh reasoning language", userComposed)
|
||||
}
|
||||
projectComposed := projectCtrl.Compose("hi")
|
||||
@@ -1109,3 +1217,134 @@ func TestSaveHooksSettingsForRootUsesDisplayedProjectRoot(t *testing.T) {
|
||||
t.Fatal("active project root was written instead of displayed project root")
|
||||
}
|
||||
}
|
||||
|
||||
// TestLoadDesktopUserConfigForViewDoesNotPersistLegacyProviderAccess locks the
|
||||
// read-path contract: loading a legacy-form config (configured providers but
|
||||
// no declared desktop.provider_access) through the View helpers returns a
|
||||
// normalized in-memory view while leaving the file bytes untouched. The
|
||||
// on-disk migration only happens once a locked write path runs.
|
||||
func TestLoadDesktopUserConfigForViewDoesNotPersistLegacyProviderAccess(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
userPath := config.UserConfigPath()
|
||||
if err := os.MkdirAll(filepath.Dir(userPath), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
legacy := "default_model = \"local/m1\"\n\n[[providers]]\nname = \"local\"\nbase_url = \"http://127.0.0.1:9999/v1\"\nmodels = [\"m1\"]\n"
|
||||
if err := os.WriteFile(userPath, []byte(legacy), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
app := NewApp()
|
||||
for name, load := range map[string]func() (*config.Config, string, error){
|
||||
"view": app.loadDesktopUserConfigForView,
|
||||
"view-with-credentials": app.loadDesktopUserConfigForViewWithCredentials,
|
||||
} {
|
||||
cfg, _, err := load()
|
||||
if err != nil {
|
||||
t.Fatalf("%s load: %v", name, err)
|
||||
}
|
||||
if len(cfg.Desktop.ProviderAccess) == 0 {
|
||||
t.Fatalf("%s load should normalize legacy provider access in memory", name)
|
||||
}
|
||||
raw, err := os.ReadFile(userPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(raw) != legacy {
|
||||
t.Fatalf("%s load must not rewrite the user config, got:\n%s", name, raw)
|
||||
}
|
||||
}
|
||||
|
||||
// The first locked write path persists the pending migration.
|
||||
if err := app.applyConfigOnly(func(*config.Config) error { return nil }); err != nil {
|
||||
t.Fatalf("applyConfigOnly: %v", err)
|
||||
}
|
||||
if !configDeclaresProviderAccess(userPath) {
|
||||
t.Fatal("locked write path should persist the provider access migration to disk")
|
||||
}
|
||||
migrated := config.LoadForEditWithoutCredentials(userPath)
|
||||
if len(migrated.Desktop.ProviderAccess) == 0 {
|
||||
t.Fatalf("migrated config lost provider access: %v", migrated.Desktop.ProviderAccess)
|
||||
}
|
||||
}
|
||||
|
||||
// TestLoadDesktopUserConfigViewKeepsLegacyBotConfigMigrationInMemory locks the
|
||||
// same contract for the legacy bot-config migration: read paths (including the
|
||||
// bot runtime's credential-loading view) see the merged bot config in memory
|
||||
// without any file being written; the locked write path performs the on-disk
|
||||
// migration.
|
||||
func TestLoadDesktopUserConfigViewKeepsLegacyBotConfigMigrationInMemory(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
userPath := config.UserConfigPath()
|
||||
if err := os.MkdirAll(filepath.Dir(userPath), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
userBody := "default_model = \"local/m1\"\n"
|
||||
if err := os.WriteFile(userPath, []byte(userBody), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
legacyRoot := t.TempDir()
|
||||
legacyPath := filepath.Join(legacyRoot, "reasonix.toml")
|
||||
legacyBody := "[bot]\nenabled = true\nmodel = \"local/m1\"\n"
|
||||
if err := os.WriteFile(legacyPath, []byte(legacyBody), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
app := NewApp()
|
||||
app.tabs = map[string]*WorkspaceTab{
|
||||
"t": {ID: "t", Scope: "project", WorkspaceRoot: legacyRoot, Ready: true},
|
||||
}
|
||||
app.activeTabID = "t"
|
||||
|
||||
assertFilesUntouched := func(step string) {
|
||||
t.Helper()
|
||||
rawUser, err := os.ReadFile(userPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(rawUser) != userBody {
|
||||
t.Fatalf("%s must not rewrite the user config, got:\n%s", step, rawUser)
|
||||
}
|
||||
rawLegacy, err := os.ReadFile(legacyPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(rawLegacy) != legacyBody {
|
||||
t.Fatalf("%s must not rewrite the legacy config, got:\n%s", step, rawLegacy)
|
||||
}
|
||||
}
|
||||
|
||||
cfg, _, err := app.loadDesktopUserConfigForView()
|
||||
if err != nil {
|
||||
t.Fatalf("loadDesktopUserConfigForView: %v", err)
|
||||
}
|
||||
if !cfg.Bot.Enabled {
|
||||
t.Fatal("view load should merge the legacy bot config in memory")
|
||||
}
|
||||
assertFilesUntouched("loadDesktopUserConfigForView")
|
||||
|
||||
botCfg, err := app.loadDesktopBotConfig()
|
||||
if err != nil {
|
||||
t.Fatalf("loadDesktopBotConfig: %v", err)
|
||||
}
|
||||
if !botCfg.Bot.Enabled {
|
||||
t.Fatal("bot runtime load should see the merged legacy bot config")
|
||||
}
|
||||
assertFilesUntouched("loadDesktopBotConfig")
|
||||
|
||||
// The first locked write path migrates the bot config into the user file.
|
||||
if err := app.applyConfigOnly(func(*config.Config) error { return nil }); err != nil {
|
||||
t.Fatalf("applyConfigOnly: %v", err)
|
||||
}
|
||||
migrated := config.LoadForEditWithoutCredentials(userPath)
|
||||
if !migrated.Bot.Enabled {
|
||||
t.Fatal("locked write path should persist the legacy bot config migration")
|
||||
}
|
||||
rawLegacy, err := os.ReadFile(legacyPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(rawLegacy) != legacyBody {
|
||||
t.Fatalf("migration must not rewrite the legacy config, got:\n%s", rawLegacy)
|
||||
}
|
||||
}
|
||||
+23
-2
@@ -135,12 +135,33 @@ func (a *App) releaseSharedHost(root string) {
|
||||
}
|
||||
|
||||
func (a *App) releaseTabSharedHost(tab *WorkspaceTab) {
|
||||
if tab == nil || tab.SharedHostKey == "" {
|
||||
if tab == nil {
|
||||
return
|
||||
}
|
||||
// SharedHostKey is a.mu-guarded (the build goroutine publishes it under
|
||||
// the lock); do the take under the lock and the slow host release after.
|
||||
// Callers must not hold a.mu.
|
||||
a.mu.Lock()
|
||||
key := takeTabSharedHostKey(tab)
|
||||
a.mu.Unlock()
|
||||
if key == "" {
|
||||
return
|
||||
}
|
||||
a.releaseSharedHost(key)
|
||||
}
|
||||
|
||||
// takeTabSharedHostKey clears the tab's shared-host key and returns it so the
|
||||
// caller can release it later. Use from inside a.mu critical sections:
|
||||
// releaseSharedHost may close the host and reap MCP subprocesses, which is far
|
||||
// too slow to run under the app lock — call a.releaseSharedHost(key) after
|
||||
// unlocking.
|
||||
func takeTabSharedHostKey(tab *WorkspaceTab) string {
|
||||
if tab == nil || tab.SharedHostKey == "" {
|
||||
return ""
|
||||
}
|
||||
key := tab.SharedHostKey
|
||||
tab.SharedHostKey = ""
|
||||
a.releaseSharedHost(key)
|
||||
return key
|
||||
}
|
||||
|
||||
// closeAllSharedHosts closes every shared host. Called during app shutdown.
|
||||
|
||||
@@ -122,7 +122,7 @@ api_key_env = "PROJECT_API_KEY"
|
||||
if !got.Supported || got.Current != "auto" || got.Default != "high" {
|
||||
t.Fatalf("EffortForTab model registry = %+v, want supported auto/high", got)
|
||||
}
|
||||
wantLevels := []string{"auto", "high", "max"}
|
||||
wantLevels := []string{"auto", "disabled", "high", "max"}
|
||||
if len(got.Levels) != len(wantLevels) {
|
||||
t.Fatalf("levels = %v, want %v", got.Levels, wantLevels)
|
||||
}
|
||||
|
||||
+1482
-311
File diff suppressed because it is too large.
Load diff
+236
-1
@@ -9,6 +9,7 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"reasonix/internal/agent"
|
||||
"reasonix/internal/config"
|
||||
"reasonix/internal/control"
|
||||
"reasonix/internal/event"
|
||||
@@ -73,6 +74,30 @@ func (s *snapshotObservingSession) Snapshot() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func expectAppMutexAvailableDuringSnapshot(t *testing.T, app *App, checks chan<- struct{}) func() {
|
||||
t.Helper()
|
||||
return func() {
|
||||
acquired := make(chan struct{})
|
||||
go func() {
|
||||
app.mu.Lock()
|
||||
app.mu.Unlock() //nolint:staticcheck // probe: lock must be immediately acquirable
|
||||
close(acquired)
|
||||
}()
|
||||
select {
|
||||
case <-acquired:
|
||||
case <-time.After(500 * time.Millisecond):
|
||||
t.Error("Snapshot ran while holding app mutex")
|
||||
}
|
||||
if checks == nil {
|
||||
return
|
||||
}
|
||||
select {
|
||||
case checks <- struct{}{}:
|
||||
default:
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestListTabsKeepsExplicitOrderWhenActiveChanges(t *testing.T) {
|
||||
app := testAppWithOrderedTabs(t, "b", "a", "b", "c")
|
||||
|
||||
@@ -181,6 +206,90 @@ func TestKeepOnlyVisibleTabDetachesRunningHiddenTab(t *testing.T) {
|
||||
ctrl.Close()
|
||||
}
|
||||
|
||||
func TestKeepOnlyVisibleTabSnapshotsHiddenTabWithoutAppLock(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
app := &App{
|
||||
tabs: map[string]*WorkspaceTab{},
|
||||
tabOrder: []string{"hidden", "target"},
|
||||
activeTabID: "hidden",
|
||||
}
|
||||
snapshotChecks := make(chan struct{}, 2)
|
||||
hiddenCtrl := &snapshotObservingSession{
|
||||
SessionAPI: control.New(control.Options{Label: "hidden"}),
|
||||
onSnapshot: expectAppMutexAvailableDuringSnapshot(t, app, snapshotChecks),
|
||||
}
|
||||
hidden := &WorkspaceTab{
|
||||
ID: "hidden",
|
||||
Scope: "global",
|
||||
WorkspaceRoot: globalTabWorkspaceRoot(),
|
||||
TopicID: "topic-hidden",
|
||||
Ctrl: hiddenCtrl,
|
||||
Ready: true,
|
||||
sink: &tabEventSink{tabID: "hidden"},
|
||||
disabledMCP: map[string]ServerView{},
|
||||
}
|
||||
target := &WorkspaceTab{
|
||||
ID: "target",
|
||||
Scope: "global",
|
||||
Ready: true,
|
||||
disabledMCP: map[string]ServerView{},
|
||||
}
|
||||
app.tabs["hidden"] = hidden
|
||||
app.tabs["target"] = target
|
||||
|
||||
if _, err := app.keepOnlyVisibleTab("target"); err != nil {
|
||||
t.Fatalf("keepOnlyVisibleTab: %v", err)
|
||||
}
|
||||
select {
|
||||
case <-snapshotChecks:
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("hidden tab was not snapshotted before pruning")
|
||||
}
|
||||
assertTabIDs(t, app.ListTabs(), "target")
|
||||
}
|
||||
|
||||
func TestCloseTabSnapshotsWithoutAppLock(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
app := &App{
|
||||
tabs: map[string]*WorkspaceTab{},
|
||||
tabOrder: []string{"closing", "survivor"},
|
||||
activeTabID: "closing",
|
||||
}
|
||||
snapshotChecks := make(chan struct{}, 1)
|
||||
closingCtrl := &snapshotObservingSession{
|
||||
SessionAPI: control.New(control.Options{Label: "closing"}),
|
||||
onSnapshot: expectAppMutexAvailableDuringSnapshot(t, app, snapshotChecks),
|
||||
}
|
||||
closing := &WorkspaceTab{
|
||||
ID: "closing",
|
||||
Scope: "global",
|
||||
WorkspaceRoot: globalTabWorkspaceRoot(),
|
||||
TopicID: "topic-closing",
|
||||
Ctrl: closingCtrl,
|
||||
Ready: true,
|
||||
sink: &tabEventSink{tabID: "closing"},
|
||||
disabledMCP: map[string]ServerView{},
|
||||
}
|
||||
survivor := &WorkspaceTab{
|
||||
ID: "survivor",
|
||||
Scope: "global",
|
||||
Ready: true,
|
||||
disabledMCP: map[string]ServerView{},
|
||||
}
|
||||
app.tabs["closing"] = closing
|
||||
app.tabs["survivor"] = survivor
|
||||
|
||||
if err := app.CloseTab("closing"); err != nil {
|
||||
t.Fatalf("CloseTab: %v", err)
|
||||
}
|
||||
select {
|
||||
case <-snapshotChecks:
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("closing tab was not snapshotted")
|
||||
}
|
||||
assertTabIDs(t, app.ListTabs(), "survivor")
|
||||
}
|
||||
|
||||
func TestKeepOnlyVisibleTabCancelsBuildingHiddenTab(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
cancelled := false
|
||||
@@ -292,6 +401,60 @@ func TestClearTabBuildCancelCancelsAbandonedBuildContext(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureSessionLeaseSerializesConcurrentSameTabAcquire(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
dir := config.SessionDir()
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
t.Fatalf("mkdir sessions: %v", err)
|
||||
}
|
||||
path := filepath.Join(dir, "same-tab-concurrent-lease.jsonl")
|
||||
tab := &WorkspaceTab{ID: "tab"}
|
||||
t.Cleanup(tab.releaseSessionLease)
|
||||
|
||||
acquired := make(chan struct{})
|
||||
releaseHook := make(chan struct{})
|
||||
var once sync.Once
|
||||
sessionLeaseAcquireHookForTest = func() {
|
||||
once.Do(func() {
|
||||
close(acquired)
|
||||
<-releaseHook
|
||||
})
|
||||
}
|
||||
t.Cleanup(func() { sessionLeaseAcquireHookForTest = nil })
|
||||
|
||||
firstErr := make(chan error, 1)
|
||||
go func() {
|
||||
firstErr <- tab.ensureSessionLease(path)
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-acquired:
|
||||
case err := <-firstErr:
|
||||
t.Fatalf("first ensureSessionLease returned before hook: %v", err)
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("first ensureSessionLease did not acquire lease")
|
||||
}
|
||||
|
||||
secondErr := make(chan error, 1)
|
||||
go func() {
|
||||
secondErr <- tab.ensureSessionLease(path)
|
||||
}()
|
||||
|
||||
select {
|
||||
case err := <-secondErr:
|
||||
t.Fatalf("second ensureSessionLease returned while first acquire was unbound: %v", err)
|
||||
case <-time.After(50 * time.Millisecond):
|
||||
}
|
||||
|
||||
close(releaseHook)
|
||||
if err := <-firstErr; err != nil {
|
||||
t.Fatalf("first ensureSessionLease: %v", err)
|
||||
}
|
||||
if err := <-secondErr; err != nil {
|
||||
t.Fatalf("second ensureSessionLease should reuse the tab lease: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAttachExistingSessionRuntimeSkipsRemovedTab(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
dir := config.SessionDir()
|
||||
@@ -398,6 +561,38 @@ func TestRemoveWorkspaceSnapshotsProjectTabBeforeRemovingBinding(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRemoveWorkspaceSnapshotsProjectTabWithoutAppLock(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
projectRoot := t.TempDir()
|
||||
if err := addProject(projectRoot, "Project"); err != nil {
|
||||
t.Fatalf("add project: %v", err)
|
||||
}
|
||||
app := &App{
|
||||
tabs: map[string]*WorkspaceTab{
|
||||
"project": {ID: "project", Scope: "project", WorkspaceRoot: projectRoot, TopicID: "topic-project", Ready: true, disabledMCP: map[string]ServerView{}},
|
||||
"global": {ID: "global", Scope: "global", WorkspaceRoot: globalTabWorkspaceRoot(), TopicID: "topic-global", Ready: true, disabledMCP: map[string]ServerView{}},
|
||||
},
|
||||
tabOrder: []string{"project", "global"},
|
||||
activeTabID: "project",
|
||||
detachedSessions: map[string]*WorkspaceTab{},
|
||||
}
|
||||
snapshotChecks := make(chan struct{}, 1)
|
||||
app.tabs["project"].Ctrl = &snapshotObservingSession{
|
||||
SessionAPI: control.New(control.Options{Label: "project"}),
|
||||
onSnapshot: expectAppMutexAvailableDuringSnapshot(t, app, snapshotChecks),
|
||||
}
|
||||
|
||||
if err := app.RemoveWorkspace(projectRoot); err != nil {
|
||||
t.Fatalf("RemoveWorkspace: %v", err)
|
||||
}
|
||||
select {
|
||||
case <-snapshotChecks:
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("project tab was not snapshotted before removing workspace")
|
||||
}
|
||||
assertTabIDs(t, app.ListTabs(), "global")
|
||||
}
|
||||
|
||||
func TestRemoveWorkspaceRejectsRunningProjectRuntime(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
projectRoot := t.TempDir()
|
||||
@@ -453,12 +648,16 @@ func TestListTabsRepairsStaleOrderWithoutRacing(t *testing.T) {
|
||||
var wg sync.WaitGroup
|
||||
start := make(chan struct{})
|
||||
errs := make(chan string, 8)
|
||||
iterations := 100
|
||||
if testing.Short() {
|
||||
iterations = 5
|
||||
}
|
||||
for i := 0; i < 8; i++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
<-start
|
||||
for j := 0; j < 100; j++ {
|
||||
for j := 0; j < iterations; j++ {
|
||||
if got := strings.Join(tabIDs(app.ListTabs()), ","); got != "a,b,c" {
|
||||
errs <- got
|
||||
return
|
||||
@@ -679,6 +878,42 @@ func TestBuildTabControllerReusesOpenSessionPathRuntime(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildTabControllerBlocksWhenSessionLeaseHeld(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
dir := desktopSessionDir(globalTabWorkspaceRoot())
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
t.Fatalf("mkdir sessions: %v", err)
|
||||
}
|
||||
path := filepath.Join(dir, "leased.jsonl")
|
||||
if err := os.WriteFile(path, nil, 0o644); err != nil {
|
||||
t.Fatalf("write placeholder session: %v", err)
|
||||
}
|
||||
lease, err := agent.TryAcquireSessionLease(path)
|
||||
if err != nil {
|
||||
t.Fatalf("TryAcquireSessionLease: %v", err)
|
||||
}
|
||||
defer lease.Release()
|
||||
|
||||
app := NewApp()
|
||||
tab := app.createTabEntryWithID("global", globalTabWorkspaceRoot(), "", "leased")
|
||||
tab.SessionPath = path
|
||||
tab.sink = &tabEventSink{tabID: "leased", app: app}
|
||||
app.tabs[tab.ID] = tab
|
||||
app.tabOrder = []string{tab.ID}
|
||||
app.activeTabID = tab.ID
|
||||
|
||||
app.buildTabController(tab)
|
||||
if tab.Ctrl != nil {
|
||||
t.Fatalf("tab controller = %T, want nil when lease is held", tab.Ctrl)
|
||||
}
|
||||
if !tab.Ready {
|
||||
t.Fatal("tab should be ready with startup error")
|
||||
}
|
||||
if !strings.Contains(tab.StartupErr, agent.ErrSessionLeaseHeld.Error()) {
|
||||
t.Fatalf("startup error = %q, want lease-held error", tab.StartupErr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenGlobalTabResolvesTopicToLatestSessionRuntime(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
dir := desktopSessionDir(globalTabWorkspaceRoot())
|
||||
|
||||
@@ -1170,6 +1170,27 @@ func TestCreateTopicDefaultsToAutoNewSessionTitle(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestListProjectTreeFallsBackToTopicIDCreatedAt(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
|
||||
const topicID = "legacy_20260606-114914_2276f13fd87c"
|
||||
if err := setTopicTitleWithSource("", topicID, "你好,你是谁", topicTitleSourceManual); err != nil {
|
||||
t.Fatalf("set topic title: %v", err)
|
||||
}
|
||||
if err := prependTopicInProjectsFile("", topicID, false); err != nil {
|
||||
t.Fatalf("prepend topic: %v", err)
|
||||
}
|
||||
|
||||
nodes := NewApp().ListProjectTree()
|
||||
if len(nodes) != 1 || nodes[0].Kind != "global_folder" || len(nodes[0].Children) != 1 {
|
||||
t.Fatalf("project tree = %#v, want Global with one topic", nodes)
|
||||
}
|
||||
expected := time.Date(2026, 6, 6, 11, 49, 14, 0, time.UTC).UnixMilli()
|
||||
if got := nodes[0].Children[0].CreatedAt; got != expected {
|
||||
t.Fatalf("project tree createdAt = %d, want %d", got, expected)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateTopicAppearsFirstInProjectTree(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
|
||||
@@ -1567,6 +1588,53 @@ func TestAutoTitleTopicStripsReasoningLanguagePrefix(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAutoTitleTopicRefreshesOnThirdUserTurn(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
|
||||
projectRoot := t.TempDir()
|
||||
topic, err := NewApp().CreateTopic("project", projectRoot, "")
|
||||
if err != nil {
|
||||
t.Fatalf("create topic: %v", err)
|
||||
}
|
||||
sessionPath := filepath.Join(t.TempDir(), "session.jsonl")
|
||||
firstTurn := strings.Join([]string{
|
||||
`{"role":"user","content":"帮我看看"}`,
|
||||
`{"role":"assistant","content":"可以"}`,
|
||||
}, "\n") + "\n"
|
||||
if err := os.WriteFile(sessionPath, []byte(firstTurn), 0o644); err != nil {
|
||||
t.Fatalf("write first session: %v", err)
|
||||
}
|
||||
|
||||
title, updated := autoTitleTopicFromSession(projectRoot, topic.ID, sessionPath)
|
||||
if !updated || title != "帮我看看" {
|
||||
t.Fatalf("first auto title = %q updated=%v, want 帮我看看/true", title, updated)
|
||||
}
|
||||
|
||||
thirdTurn := strings.Join([]string{
|
||||
`{"role":"user","content":"帮我看看"}`,
|
||||
`{"role":"assistant","content":"可以"}`,
|
||||
`{"role":"user","content":"继续"}`,
|
||||
`{"role":"assistant","content":"继续分析"}`,
|
||||
`{"role":"user","content":"实现自动更新会话标题"}`,
|
||||
`{"role":"assistant","content":"已实现"}`,
|
||||
}, "\n") + "\n"
|
||||
if err := os.WriteFile(sessionPath, []byte(thirdTurn), 0o644); err != nil {
|
||||
t.Fatalf("write third session: %v", err)
|
||||
}
|
||||
|
||||
title, updated = autoTitleTopicFromSession(projectRoot, topic.ID, sessionPath)
|
||||
if !updated || title != "实现自动更新会话标题" {
|
||||
t.Fatalf("third-turn auto title = %q updated=%v, want 实现自动更新会话标题/true", title, updated)
|
||||
}
|
||||
if got := loadTopicTitle(projectRoot, topic.ID); got != "实现自动更新会话标题" {
|
||||
t.Fatalf("stored title = %q, want 实现自动更新会话标题", got)
|
||||
}
|
||||
meta := loadTopicAutoTitleMeta(projectRoot)[topic.ID]
|
||||
if meta.Stage != 3 {
|
||||
t.Fatalf("auto title stage = %d, want 3", meta.Stage)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAutoTitleDoesNotOverrideManualTopicTitle(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
|
||||
@@ -1592,6 +1660,50 @@ func TestAutoTitleDoesNotOverrideManualTopicTitle(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAutoTitleDoesNotOverrideManualSessionTitle(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
|
||||
projectRoot := t.TempDir()
|
||||
topic, err := NewApp().CreateTopic("project", projectRoot, "")
|
||||
if err != nil {
|
||||
t.Fatalf("create topic: %v", err)
|
||||
}
|
||||
sessionPath := filepath.Join(t.TempDir(), "session.jsonl")
|
||||
if err := os.WriteFile(sessionPath, []byte(`{"role":"user","content":"讲讲这个代码库的架构"}`+"\n"), 0o644); err != nil {
|
||||
t.Fatalf("write session: %v", err)
|
||||
}
|
||||
if err := agent.SaveBranchMetaPreserveUpdated(sessionPath, agent.BranchMeta{CustomTitle: "手动会话标题"}); err != nil {
|
||||
t.Fatalf("save branch meta: %v", err)
|
||||
}
|
||||
|
||||
if title, updated := autoTitleTopicFromSession(projectRoot, topic.ID, sessionPath); updated || title != "" {
|
||||
t.Fatalf("manual session title should not auto-update, title=%q updated=%v", title, updated)
|
||||
}
|
||||
if got := loadTopicTitle(projectRoot, topic.ID); got != defaultTopicTitle {
|
||||
t.Fatalf("stored title = %q, want default title", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenameTopicBlankKeepsManualTitleSource(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
|
||||
projectRoot := t.TempDir()
|
||||
app := NewApp()
|
||||
topic, err := app.CreateTopic("project", projectRoot, "")
|
||||
if err != nil {
|
||||
t.Fatalf("create topic: %v", err)
|
||||
}
|
||||
if err := app.RenameTopic(topic.ID, " "); err != nil {
|
||||
t.Fatalf("rename blank topic: %v", err)
|
||||
}
|
||||
if got := loadTopicTitle(projectRoot, topic.ID); got != defaultTopicTitle {
|
||||
t.Fatalf("stored title = %q, want %q", got, defaultTopicTitle)
|
||||
}
|
||||
if got := loadTopicTitleSource(projectRoot, topic.ID); got != topicTitleSourceManual {
|
||||
t.Fatalf("title source = %q, want manual", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTrashTopicMovesRelatedSessionsToTrash(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
|
||||
|
||||
+29
-13
@@ -38,7 +38,7 @@ func (a *App) startTray() bool {
|
||||
a.tray = t
|
||||
a.mu.Unlock()
|
||||
|
||||
t.end = startDesktopTray(func() {
|
||||
end := startDesktopTray(func() {
|
||||
systray.SetIcon(trayIconBytes)
|
||||
systray.SetTitle("Reasonix")
|
||||
systray.SetTooltip("Reasonix")
|
||||
@@ -51,21 +51,25 @@ func (a *App) startTray() bool {
|
||||
systray.SetOnSecondaryTapped(nil)
|
||||
|
||||
labels := trayMenuLabels(a.trayLocale())
|
||||
t.openItem = systray.AddMenuItem(labels.openTitle, labels.openTooltip)
|
||||
t.quitItem = systray.AddMenuItem(labels.quitTitle, labels.quitTooltip)
|
||||
openItem := systray.AddMenuItem(labels.openTitle, labels.openTooltip)
|
||||
quitItem := systray.AddMenuItem(labels.quitTitle, labels.quitTooltip)
|
||||
|
||||
// Publish the menu items under a.mu: this callback runs on the systray
|
||||
// goroutine while bound settings calls (updateTrayLocale) read them.
|
||||
a.mu.Lock()
|
||||
t.openItem = openItem
|
||||
t.quitItem = quitItem
|
||||
a.trayReady = true
|
||||
a.mu.Unlock()
|
||||
t.markReady()
|
||||
|
||||
a.goSafe("trayOpenLoop", func() {
|
||||
for range t.openItem.ClickedCh {
|
||||
for range openItem.ClickedCh {
|
||||
a.showFromTray()
|
||||
}
|
||||
})
|
||||
a.goSafe("trayQuitLoop", func() {
|
||||
for range t.quitItem.ClickedCh {
|
||||
for range quitItem.ClickedCh {
|
||||
a.quitFromTray()
|
||||
}
|
||||
})
|
||||
@@ -77,35 +81,47 @@ func (a *App) startTray() bool {
|
||||
}
|
||||
a.mu.Unlock()
|
||||
})
|
||||
a.mu.Lock()
|
||||
t.end = end
|
||||
a.mu.Unlock()
|
||||
return true
|
||||
}
|
||||
|
||||
func (a *App) stopTray() {
|
||||
a.mu.RLock()
|
||||
t := a.tray
|
||||
var end func()
|
||||
if t != nil {
|
||||
end = t.end
|
||||
}
|
||||
a.mu.RUnlock()
|
||||
if t == nil || t.end == nil {
|
||||
if t == nil || end == nil {
|
||||
return
|
||||
}
|
||||
t.once.Do(t.end)
|
||||
t.once.Do(end)
|
||||
}
|
||||
|
||||
func (a *App) updateTrayLocale(locale string) {
|
||||
a.mu.RLock()
|
||||
t := a.tray
|
||||
var openItem, quitItem *systray.MenuItem
|
||||
if t != nil {
|
||||
openItem = t.openItem
|
||||
quitItem = t.quitItem
|
||||
}
|
||||
a.mu.RUnlock()
|
||||
if t == nil || t.openItem == nil || t.quitItem == nil {
|
||||
if openItem == nil || quitItem == nil {
|
||||
return
|
||||
}
|
||||
labels := trayMenuLabels(locale)
|
||||
t.openItem.SetTitle(labels.openTitle)
|
||||
t.openItem.SetTooltip(labels.openTooltip)
|
||||
t.quitItem.SetTitle(labels.quitTitle)
|
||||
t.quitItem.SetTooltip(labels.quitTooltip)
|
||||
openItem.SetTitle(labels.openTitle)
|
||||
openItem.SetTooltip(labels.openTooltip)
|
||||
quitItem.SetTitle(labels.quitTitle)
|
||||
quitItem.SetTooltip(labels.quitTooltip)
|
||||
}
|
||||
|
||||
func (a *App) trayLocale() string {
|
||||
cfg, _, err := a.loadDesktopUserConfigForEdit()
|
||||
cfg, _, err := a.loadDesktopUserConfigForView()
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
+29
-19
@@ -33,36 +33,38 @@ import (
|
||||
// has no Wails dependency so the logic is unit-tested directly; updater_app.go is
|
||||
// the thin Wails binding that wires these into App methods and progress events.
|
||||
|
||||
// Manifest endpoints — R2 CDN first (fast, especially in CN), GitHub releases as
|
||||
// fallback. The build channel picks the rolling pointer so a canary build polls
|
||||
// the canary line and a stable build polls latest; the two never cross.
|
||||
// Manifest endpoints — R2 CDN first (fast, especially in CN), then the crash
|
||||
// worker release gateway. The build channel picks the rolling pointer so a
|
||||
// canary build polls the canary line and a stable build polls latest; the two
|
||||
// never cross. The gateway deliberately avoids GitHub's repository-wide
|
||||
// /releases/latest shortcut because CLI tags (v*) and desktop tags (desktop-v*)
|
||||
// are separate release lines in the same repo.
|
||||
const (
|
||||
r2Base = "https://dl.reasonix.io"
|
||||
ghReleasesBase = "https://github.com/esengine/reasonix/releases"
|
||||
httpTimeout = 15 * time.Second
|
||||
r2Base = "https://dl.reasonix.io"
|
||||
releaseGatewayBase = "https://crash.reasonix.io/v1/desktop/releases"
|
||||
downloadPageURL = "https://reasonix.io/#start"
|
||||
httpTimeout = 15 * time.Second
|
||||
)
|
||||
|
||||
// manifestEndpoints returns the primary (R2) then fallback (GitHub) manifest URLs
|
||||
// for the running build's channel.
|
||||
func manifestEndpoints() []string {
|
||||
if channel == "canary" {
|
||||
// Canary publishes only to R2 (no GitHub release), so there is no
|
||||
// GitHub fallback for this channel.
|
||||
return []string{r2Base + "/canary/latest.json"}
|
||||
return []string{
|
||||
r2Base + "/canary/latest.json",
|
||||
releaseGatewayBase + "/canary/latest.json",
|
||||
}
|
||||
}
|
||||
return []string{
|
||||
r2Base + "/latest/latest.json",
|
||||
ghReleasesBase + "/latest/download/latest.json",
|
||||
releaseGatewayBase + "/stable/latest.json",
|
||||
}
|
||||
}
|
||||
|
||||
// downloadPage is the human-facing releases page shown when self-update is
|
||||
// unavailable (macOS) or the manifest omits its own link.
|
||||
func downloadPage() string {
|
||||
if channel == "canary" {
|
||||
return ghReleasesBase // lists pre-releases too
|
||||
}
|
||||
return ghReleasesBase + "/latest"
|
||||
return downloadPageURL
|
||||
}
|
||||
|
||||
// UpdateInfo is the CheckUpdate result that drives the frontend's update banner.
|
||||
@@ -570,13 +572,10 @@ func applyLinux(targz []byte) error {
|
||||
}
|
||||
|
||||
func applyWindowsFile(path string) error {
|
||||
return installerCommand(path, currentInstallDir()).Start()
|
||||
return startWindowsUpdateHandoff(path, currentInstallDir(), currentExecutablePath())
|
||||
}
|
||||
|
||||
// currentInstallDir is the directory of the running executable — the location a
|
||||
// Windows update must overwrite. Empty when it can't be resolved, in which case
|
||||
// the installer falls back to its own InstallDir logic.
|
||||
func currentInstallDir() string {
|
||||
func currentExecutablePath() string {
|
||||
exe, err := os.Executable()
|
||||
if err != nil {
|
||||
return ""
|
||||
@@ -584,6 +583,17 @@ func currentInstallDir() string {
|
||||
if resolved, err := filepath.EvalSymlinks(exe); err == nil {
|
||||
exe = resolved
|
||||
}
|
||||
return exe
|
||||
}
|
||||
|
||||
// currentInstallDir is the directory of the running executable — the location a
|
||||
// Windows update must overwrite. Empty when it can't be resolved, in which case
|
||||
// the installer falls back to its own InstallDir logic.
|
||||
func currentInstallDir() string {
|
||||
exe := currentExecutablePath()
|
||||
if exe == "" {
|
||||
return ""
|
||||
}
|
||||
return filepath.Dir(exe)
|
||||
}
|
||||
|
||||
|
||||
Loaded 100 of 429 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user