mirror of
https://github.com/esengine/DeepSeek-Reasonix.git
synced 2026-11-07 10:45:59 -10:00
Compare commits
115
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1a4a8e2366 | ||
|
|
eea2d3d2f1 | ||
|
|
ae7ff78e78 | ||
|
|
ca08f277f4 | ||
|
|
aaa4f586b1 | ||
|
|
b9daee966a | ||
|
|
cda88398cc | ||
|
|
5f372e2229 | ||
|
|
c0c5acb382 | ||
|
|
9371541e1c | ||
|
|
8981f49c6a | ||
|
|
cec99a96d1 | ||
|
|
d1852af99f | ||
|
|
945568ce89 | ||
|
|
a736f120bc | ||
|
|
d2a603eb9e | ||
|
|
5b4909fcab | ||
|
|
6248f800a4 | ||
|
|
cdc749cca8 | ||
|
|
837689dfa4 | ||
|
|
b92e5affba | ||
|
|
93105b148f | ||
|
|
dd8b40eb53 | ||
|
|
eccc45eca2 | ||
|
|
d7a11e2d27 | ||
|
|
f16e5e3f04 | ||
|
|
3e7c42bc2b | ||
|
|
613165df93 | ||
|
|
7d261d9edd | ||
|
|
fc939ca7a6 | ||
|
|
c9a3131e77 | ||
|
|
af0e954f56 | ||
|
|
9976474a6c | ||
|
|
b126505b4f | ||
|
|
9b6a0f3327 | ||
|
|
5b4cfd0607 | ||
|
|
74ef86d84a | ||
|
|
c7a36b43b1 | ||
|
|
8ce6939120 | ||
|
|
1c147780ff | ||
|
|
50661c33db | ||
|
|
6fda6bf868 | ||
|
|
ccec12753c | ||
|
|
524bf552fb | ||
|
|
34bac65194 | ||
|
|
383178d22c | ||
|
|
41578aff84 | ||
|
|
38b253563c | ||
|
|
154743d3b8 | ||
|
|
d92e52b580 | ||
|
|
916f5943fa | ||
|
|
7f259f2f96 | ||
|
|
f26fb981be | ||
|
|
f93eb63929 | ||
|
|
a75ab7728b | ||
|
|
23cb22d3cb | ||
|
|
cbcc2ebb1e | ||
|
|
fb0b7396e3 | ||
|
|
674c2b45e7 | ||
|
|
ec6929c157 | ||
|
|
dcd4135049 | ||
|
|
08ea935ea9 | ||
|
|
2a3a3738db | ||
|
|
d1eb9c968e | ||
|
|
a2597fb17c | ||
|
|
41ea14ccc9 | ||
|
|
b3bea0d076 | ||
|
|
efff0a825f | ||
|
|
44712c3aad | ||
|
|
d188af4207 | ||
|
|
8a89f0874b | ||
|
|
350379ffff | ||
|
|
65ecbce661 | ||
|
|
2656f33f28 | ||
|
|
e31c2347b4 | ||
|
|
74c2f11209 | ||
|
|
be65b117e0 | ||
|
|
19f05e8509 | ||
|
|
4fde96dccc | ||
|
|
9c38dd1129 | ||
|
|
355e047c27 | ||
|
|
0abc13d3c0 | ||
|
|
4055d9e404 | ||
|
|
ddd0db3ee7 | ||
|
|
ba95fc74f1 | ||
|
|
1da925d0f9 | ||
|
|
eaf5329101 | ||
|
|
dc659c7509 | ||
|
|
310567fd08 | ||
|
|
77059988f8 | ||
|
|
4b87d87883 | ||
|
|
b8997b34eb | ||
|
|
3c6ad78273 | ||
|
|
2cbd60e326 | ||
|
|
7b41ec8a08 | ||
|
|
9be610d41d | ||
|
|
d16063929c | ||
|
|
0ab4329305 | ||
|
|
692a46d8d3 | ||
|
|
8066440bb7 | ||
|
|
aa09229ab2 | ||
|
|
87d59a1816 | ||
|
|
f98edeec7b | ||
|
|
cab93a8b02 | ||
|
|
2b517203be | ||
|
|
08a1288603 | ||
|
|
e5eb7cbee2 | ||
|
|
d5db1cc446 | ||
|
|
02bf796dcc | ||
|
|
19c7b76de1 | ||
|
|
d803f11506 | ||
|
|
ac956885c1 | ||
|
|
286106c0e7 | ||
|
|
f181b32fcc | ||
|
|
4f915fe504 |
No files matched your search
@@ -0,0 +1,293 @@
|
||||
name: Android
|
||||
|
||||
# The Android shell under `android/` ships from the same tag the desktop line
|
||||
# publishes, and lands on the same release page.
|
||||
#
|
||||
# It is a separate workflow rather than a job inside release-studio.yml for two
|
||||
# reasons. The toolchains share nothing — Go, the Android SDK, a Gradle wrapper —
|
||||
# so a runner carrying both would install three toolchains to build one artifact.
|
||||
# And `signing-contract` pins which workflows may reach the SignPath credentials;
|
||||
# this one never touches them, and keeping it out of that graph is what keeps the
|
||||
# fingerprint stable.
|
||||
#
|
||||
# A failure here does not hold back the desktop release: the two are independent
|
||||
# by construction, and an APK is worth less than the desktop builds it sits
|
||||
# beside.
|
||||
on:
|
||||
push:
|
||||
tags: ['studio-v*']
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
tag:
|
||||
description: "Existing studio tag to build for and attach to (for example studio-v2.51.0)"
|
||||
required: false
|
||||
type: string
|
||||
ref:
|
||||
description: "A branch to build as a trial run of this workflow; nothing is attached to any release"
|
||||
required: false
|
||||
type: string
|
||||
|
||||
permissions:
|
||||
contents: write # upload the APK to the release the tag belongs to
|
||||
|
||||
concurrency:
|
||||
group: android-${{ inputs.tag || inputs.ref || github.ref_name }}
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
resolve:
|
||||
name: resolve the tag
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
outputs:
|
||||
tag: ${{ steps.release.outputs.tag }}
|
||||
version: ${{ steps.release.outputs.version }}
|
||||
sha: ${{ steps.release.outputs.sha }}
|
||||
candidate: ${{ steps.release.outputs.candidate }}
|
||||
publish: ${{ steps.release.outputs.publish }}
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
with:
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
|
||||
# The same tag grammar release-studio.yml enforces, spelled the same way:
|
||||
# two workflows that disagree about which tags name a release would publish
|
||||
# one artifact for a tag the other refused.
|
||||
- name: Resolve tag and version
|
||||
id: release
|
||||
env:
|
||||
IN_TAG: ${{ inputs.tag }}
|
||||
IN_REF: ${{ inputs.ref }}
|
||||
REF_NAME: ${{ github.ref_name }}
|
||||
run: |
|
||||
# A trial run builds a branch as a throwaway version and publishes nothing.
|
||||
if [ -n "$IN_REF" ] && [ -z "$IN_TAG" ]; then
|
||||
sha="$(git rev-parse --verify "origin/$IN_REF^{commit}")"
|
||||
{ echo "tag="; echo "version=v0.0.0-dev.${sha:0:7}"; echo "sha=$sha"
|
||||
echo "candidate=false"; echo "publish=false"; } >> "$GITHUB_OUTPUT"
|
||||
echo "trial build of $IN_REF at $sha"
|
||||
exit 0
|
||||
fi
|
||||
tag="${IN_TAG:-$REF_NAME}"
|
||||
semver='^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-([0-9A-Za-z-]+)(\.[0-9A-Za-z-]+)*)?$'
|
||||
if [[ ! "$tag" =~ ^studio-v(.+)$ ]] || [[ ! "${BASH_REMATCH[1]}" =~ $semver ]]; then
|
||||
echo "::error::studio release tag must be studio-vMAJOR.MINOR.PATCH[-PRERELEASE], got: $tag"
|
||||
exit 1
|
||||
fi
|
||||
sha="$(git rev-parse --verify "refs/tags/$tag^{commit}")"
|
||||
if ! git merge-base --is-ancestor "$sha" origin/studio; then
|
||||
echo "::error::$tag is not on studio history"
|
||||
exit 1
|
||||
fi
|
||||
version="${tag#studio-}"
|
||||
candidate=false
|
||||
if [[ "${version#v}" =~ $semver ]] && [ -n "${BASH_REMATCH[4]}" ]; then
|
||||
candidate=true
|
||||
fi
|
||||
{ echo "tag=$tag"; echo "version=$version"; echo "sha=$sha"
|
||||
echo "candidate=$candidate"; echo "publish=true"; } >> "$GITHUB_OUTPUT"
|
||||
echo "resolved $tag -> $version at $sha (candidate=$candidate)"
|
||||
|
||||
build:
|
||||
name: build the APK
|
||||
needs: resolve
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
# `publish` decides whether to attach anything, and the only honest answer to
|
||||
# "will this APK install" is whether a key was restored.
|
||||
outputs:
|
||||
signed: ${{ steps.key.outputs.signed }}
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
with:
|
||||
ref: ${{ needs.resolve.outputs.sha }}
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
|
||||
- uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
cache: true
|
||||
cache-dependency-path: go.sum
|
||||
|
||||
# pnpm first: setup-node's pnpm cache needs the executable to exist.
|
||||
- uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v4
|
||||
with:
|
||||
version: 10
|
||||
|
||||
- uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7
|
||||
with:
|
||||
node-version: "24"
|
||||
cache: pnpm
|
||||
cache-dependency-path: desktop/frontend-next/pnpm-lock.yaml
|
||||
|
||||
- uses: actions/setup-java@cf277c60eb25467037889841efdb72551f06f6c3 # v4
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: "21"
|
||||
cache: gradle
|
||||
|
||||
# The SDK is the one thing nothing sets for you, per android/README.md. The
|
||||
# runner image carries one; this asserts the two pieces the build names
|
||||
# rather than re-installing them, so a missing package fails here by name
|
||||
# instead of surfacing later as an unresolved compileSdk.
|
||||
- name: Provide the Android SDK
|
||||
run: |
|
||||
if [ -z "${ANDROID_HOME:-}" ]; then
|
||||
echo "::error::ANDROID_HOME is unset; the runner image is expected to provide the Android SDK"
|
||||
exit 1
|
||||
fi
|
||||
for piece in "platforms/android-35" "build-tools/35.0.0"; do
|
||||
if [ ! -d "$ANDROID_HOME/$piece" ]; then
|
||||
echo "::error::the Android SDK at $ANDROID_HOME has no $piece; android/README.md names it as a requirement"
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
echo "ANDROID_SDK_ROOT=$ANDROID_HOME" >> "$GITHUB_ENV"
|
||||
|
||||
# A key that exists signs; a key that does not leaves the artifact
|
||||
# unsigned, which is what a release with no key has always produced. The
|
||||
# decision is the repository's, not this workflow's: it reads the four
|
||||
# secrets android/README.md already names.
|
||||
- name: Restore the signing key
|
||||
id: key
|
||||
env:
|
||||
KEYSTORE_BASE64: ${{ secrets.REASONIX_KEYSTORE_BASE64 }}
|
||||
STORE_PASSWORD: ${{ secrets.REASONIX_KEYSTORE_PASSWORD }}
|
||||
KEY_PASSWORD: ${{ secrets.REASONIX_KEY_PASSWORD }}
|
||||
KEY_ALIAS: ${{ secrets.REASONIX_KEY_ALIAS }}
|
||||
run: |
|
||||
if [ -z "$KEYSTORE_BASE64" ] || [ -z "$STORE_PASSWORD" ] || [ -z "$KEY_ALIAS" ]; then
|
||||
echo "signed=false" >> "$GITHUB_OUTPUT"
|
||||
echo "no signing key configured: the APK will be unsigned"
|
||||
exit 0
|
||||
fi
|
||||
mkdir -p android/keystore
|
||||
printf '%s' "$KEYSTORE_BASE64" | base64 --decode > android/keystore/release.jks
|
||||
{
|
||||
echo "storeFile=keystore/release.jks"
|
||||
echo "storePassword=${STORE_PASSWORD}"
|
||||
echo "keyAlias=${KEY_ALIAS}"
|
||||
# PKCS#12 keystores use one password for both; only write the second
|
||||
# when it is genuinely a different fact.
|
||||
[ -n "$KEY_PASSWORD" ] && echo "keyPassword=${KEY_PASSWORD}"
|
||||
} > android/keystore/keystore.properties
|
||||
chmod 600 android/keystore/*
|
||||
echo "signed=true" >> "$GITHUB_OUTPUT"
|
||||
echo "signing key restored"
|
||||
|
||||
- name: Build the kernel and the page
|
||||
working-directory: android
|
||||
env:
|
||||
VERSION: ${{ needs.resolve.outputs.version }}
|
||||
run: |
|
||||
export REASONIX_APP_VERSION="${VERSION#v}"
|
||||
bash ./scripts/build-kernel.sh
|
||||
bash ./scripts/sync-page.sh
|
||||
|
||||
# The one end-to-end check that needs no phone: the kernel the shell starts,
|
||||
# serving the page the APK carries. Running it here means an APK that would
|
||||
# open blank fails the build instead of the install.
|
||||
- name: Verify the packaged page
|
||||
working-directory: android
|
||||
run: |
|
||||
(cd .. && go build -o reasonix-verify ./cmd/reasonix)
|
||||
KERNEL_BIN="$PWD/../reasonix-verify" bash ./scripts/verify-page.sh
|
||||
|
||||
- name: Assemble
|
||||
working-directory: android
|
||||
env:
|
||||
VERSION: ${{ needs.resolve.outputs.version }}
|
||||
run: |
|
||||
# The version comes from the tag, so the About screen and the update
|
||||
# check name this release. versionCode keeps its existing shape, which
|
||||
# is why the build digit stays 1: it is a single decimal place, and a
|
||||
# number that grows past it would collide with the next patch.
|
||||
./gradlew assembleRelease --no-daemon "-PreasonixVersion=${VERSION#v}"
|
||||
|
||||
- name: Name the artifact
|
||||
id: apk
|
||||
working-directory: android/app/build/outputs/apk/release
|
||||
run: |
|
||||
src="$(ls ./*.apk | head -n 1)"
|
||||
if [ -z "$src" ]; then
|
||||
echo "::error::assembleRelease produced no APK"
|
||||
exit 1
|
||||
fi
|
||||
case "$src" in
|
||||
*-unsigned.apk) name="ReasonixMobile-${{ needs.resolve.outputs.version }}-unsigned.apk" ;;
|
||||
*) name="ReasonixMobile-${{ needs.resolve.outputs.version }}.apk" ;;
|
||||
esac
|
||||
mv "$src" "$name"
|
||||
sha256sum "$name" > "$name.sha256"
|
||||
{
|
||||
echo "path=$PWD/$name"
|
||||
echo "name=$name"
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
ls -l "$name" "$name.sha256"
|
||||
|
||||
# An unsigned package is not a lesser APK, it is an unusable one: Android's
|
||||
# package manager rejects it outright rather than reporting a signature
|
||||
# mismatch. Say so where the run summary can be read, instead of letting a
|
||||
# green build imply a shippable artifact.
|
||||
- name: Say what an unsigned build means
|
||||
if: steps.key.outputs.signed != 'true'
|
||||
run: |
|
||||
echo "::warning::no signing key configured, so this APK is unsigned. Android refuses to install an unsigned package, so the release will carry no APK; the file stays a workflow artifact for whoever signs it."
|
||||
{
|
||||
echo "### No APK on this release"
|
||||
echo ""
|
||||
echo "The build produced an unsigned APK, which Android refuses to install."
|
||||
echo "It is attached to this run as a workflow artifact only. Add the four"
|
||||
echo "signing secrets to publish one."
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v8
|
||||
with:
|
||||
name: android-apk
|
||||
path: |
|
||||
android/app/build/outputs/apk/release/*.apk
|
||||
android/app/build/outputs/apk/release/*.sha256
|
||||
if-no-files-found: error
|
||||
|
||||
publish:
|
||||
name: attach the APK
|
||||
needs: [resolve, build]
|
||||
# Nothing to attach unless the build was signed. Skipping is the honest
|
||||
# outcome: a release page with an APK nobody can install is worse than a
|
||||
# release page without one.
|
||||
if: needs.build.outputs.signed == 'true' && needs.resolve.outputs.publish == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
|
||||
with:
|
||||
name: android-apk
|
||||
path: dist
|
||||
|
||||
# The release itself belongs to release-studio.yml, which creates it and
|
||||
# owns its notes. This waits for it rather than creating a second one: two
|
||||
# jobs racing to define a release is how a page ends up with the wrong body.
|
||||
- name: Attach to the release
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
TAG: ${{ needs.resolve.outputs.tag }}
|
||||
REPO: ${{ github.repository }}
|
||||
run: |
|
||||
# 90 minutes: on the last Studio release the page appeared about 40
|
||||
# minutes after the tag run started, and the APK build spends part of
|
||||
# that before this wait begins.
|
||||
for _ in $(seq 1 360); do
|
||||
if gh release view "$TAG" --repo "$REPO" >/dev/null 2>&1; then
|
||||
gh release upload "$TAG" dist/*.apk dist/*.sha256 --repo "$REPO" --clobber
|
||||
echo "attached to $TAG"
|
||||
exit 0
|
||||
fi
|
||||
sleep 15
|
||||
done
|
||||
echo "::error::release $TAG did not appear within 90 minutes; the APK was built but not published"
|
||||
exit 1
|
||||
@@ -318,6 +318,32 @@ jobs:
|
||||
"TestHeldPathsCoverOnlyTheSameFileAcrossCaseSpellings",
|
||||
"TestLegacyWaitDoesNotConsumeExecutionCapacity"] - $tests | length) == 0
|
||||
' "$RUNNER_TEMP/isolation-owners.jsonl"
|
||||
jq -se '
|
||||
. as $events |
|
||||
{"reasonix/internal/platform/worktree": [
|
||||
"TestApplyPathsRejectsRedirectedAncestors",
|
||||
"TestApplyPathsRejectsRedirectedAncestors/outside_delete",
|
||||
"TestApplyPathsRejectsRedirectedAncestors/inside_delete",
|
||||
"TestApplyPathsRejectsRedirectedAncestors/dangling_delete",
|
||||
"TestApplyPathsRejectsRedirectedAncestors/outside_write",
|
||||
"TestApplyPathsRejectsRedirectedAncestors/inside_write",
|
||||
"TestApplyPathsRejectsRedirectedAncestors/regular_parent",
|
||||
"TestApplyPathsRejectsRedirectedAncestors/unchanged",
|
||||
"TestApplyPathsRejectsRedirectedAncestors/missing_creation",
|
||||
"TestWriteChangesRejectsRedirectedDeletion",
|
||||
"TestWriteChangesUnlinksLeafDirectoryLink",
|
||||
"TestWholeWorkspaceApplyPreservesFileToDirectoryTransition"],
|
||||
"reasonix/internal/runtime/isolation": [
|
||||
"TestApplyToolRetainsResultAfterAncestorReplacement"]} |
|
||||
to_entries | all(.[]; . as $package |
|
||||
([ $events[] | select(.Package == $package.key and .Test == null and
|
||||
(.Action == "pass" or .Action == "fail" or .Action == "skip")) ] |
|
||||
length == 1 and .[0].Action == "pass") and
|
||||
all(.value[]; . as $test |
|
||||
[ $events[] | select(.Package == $package.key and .Test == $test and
|
||||
(.Action == "pass" or .Action == "fail" or .Action == "skip")) ] |
|
||||
length == 1 and .[0].Action == "pass"))
|
||||
' "$RUNNER_TEMP/isolation-owners.jsonl"
|
||||
|
||||
- name: upload Windows isolation test inventory
|
||||
if: always() && env.RUN_STEPS == 'true' && runner.os == 'Windows' && github.event_name == 'pull_request'
|
||||
@@ -983,12 +1009,12 @@ jobs:
|
||||
timeout-minutes: 5
|
||||
run: go test -count=1 -timeout=3m -run '^TestBashPowerShellExecuteDetailedContract$|^TestBashPowerShell51PreflightRejectsAndAndDetailed$|^TestBashPowerShellOutputIsUTF8$|^TestBashPowerShellSurfacesNonZeroExit$|^TestBashPowerShellRejectsChaining$' ./internal/tools/builtin
|
||||
|
||||
# Full push suite uses the same 18m package timeout as Windows smoke and a
|
||||
# 35m step budget above it. -count=1 as at the smoke step. A failure prints
|
||||
# the failed tests' output and the 15 slowest boot tests.
|
||||
# Full push suite: a 36m package timeout inside a 45m step budget. -count=1
|
||||
# as at the smoke step. A failure prints the failed tests' output and the 15
|
||||
# slowest boot tests.
|
||||
- name: test (full)
|
||||
if: env.RUN_STEPS == 'true' && runner.os == 'Windows' && github.event_name != 'pull_request'
|
||||
timeout-minutes: 35
|
||||
timeout-minutes: 45
|
||||
env:
|
||||
# Run the prompt-cache prefix-stability guard (TestCacheHit*) in CI: a
|
||||
# regression there silently tanks the cache hit rate the project is
|
||||
@@ -999,9 +1025,9 @@ jobs:
|
||||
WINDOWS_SANDBOX_WAIT_MS: "20000"
|
||||
shell: bash
|
||||
run: |
|
||||
# internal/assembly/boot alone takes 14-15 minutes on a Windows runner;
|
||||
# internal/assembly/boot alone can take over 25 minutes on a Windows runner;
|
||||
# the package limit leaves headroom inside the step limit above.
|
||||
go test -count=1 -p 4 -timeout=28m -json ./... | tee "$RUNNER_TEMP/full.jsonl" |
|
||||
go test -count=1 -p 4 -timeout=36m -json ./... | tee "$RUNNER_TEMP/full.jsonl" |
|
||||
jq -rj --unbuffered 'select(.Action == "output" and .Test == null) | .Output'
|
||||
|
||||
- name: test (full) failure detail
|
||||
|
||||
@@ -169,6 +169,19 @@ Every package ships with a `.minisig` signature, and the release carries
|
||||
The same release also carries the 2.x `reasonix` CLI as archives for
|
||||
`darwin|linux|windows × amd64|arm64`.
|
||||
|
||||
The Android build ships on that release as `ReasonixMobile-<version>.apk`
|
||||
(`arm64`, Android 8.0 or newer). The kernel and the page run on the phone, and
|
||||
the agent reaches your servers over SSH; pairing with a computer is done by
|
||||
scanning the code it shows.
|
||||
|
||||
The APK is signed with the Android release key rather than minisign, so it
|
||||
carries a `.sha256` and no `.minisig`.
|
||||
|
||||
When that key is absent the build is named
|
||||
`ReasonixMobile-<version>-unsigned.apk`. Android refuses to install an unsigned
|
||||
package, so those builds stay a workflow artifact for whoever wants to sign one,
|
||||
and the release carries no APK at all.
|
||||
|
||||
### Reasonix 1.x (stable)
|
||||
|
||||
The 1.x CLI installs through npm on any supported platform, or Homebrew on
|
||||
|
||||
@@ -156,6 +156,15 @@ Reasonix 分为两条版本线,调整的原因见[版本路线公告](https://
|
||||
|
||||
同一个发布页也提供 2.x 的 `reasonix` CLI 归档(`darwin|linux|windows × amd64|arm64`)。
|
||||
|
||||
安卓版在同一个发布页上,文件名是 `ReasonixMobile-<版本>.apk`(`arm64`,Android 8.0 及以上)。
|
||||
|
||||
这个 APK 用 Android 发布密钥签名,不走 minisign,因此只有一个 `.sha256`,没有 `.minisig`。
|
||||
|
||||
该密钥缺失时产物名为 `ReasonixMobile-<版本>-unsigned.apk`。Android 不接受未签名的包,
|
||||
这类构建只留作 workflow 产物供自行签名,发布页上不会挂 APK。
|
||||
|
||||
内核与界面都跑在手机上,Agent 通过 SSH 连接你自己的服务器;与电脑配对时扫它显示的二维码。
|
||||
|
||||
### Reasonix 1.x(稳定版)
|
||||
|
||||
1.x CLI 在任意支持的平台上都可以通过 npm 安装,macOS 也可以使用 Homebrew:
|
||||
|
||||
@@ -20,10 +20,25 @@ plugins {
|
||||
}
|
||||
|
||||
// One version, read by the About screen and the update check.
|
||||
//
|
||||
// A published build takes its version from the tag it was cut for, so the About
|
||||
// screen and the update check name that release rather than the number this file
|
||||
// was last edited to. Without the property — a local build — the numbers below
|
||||
// are the answer.
|
||||
def publishedVersion = findProperty("reasonixVersion") as String
|
||||
def versionMajor = 2
|
||||
def versionMinor = 0
|
||||
def versionPatch = 0
|
||||
def versionBuild = 1
|
||||
if (publishedVersion) {
|
||||
def parsed = publishedVersion =~ /^(\d+)\.(\d+)\.(\d+)/
|
||||
if (!parsed.find()) {
|
||||
throw new GradleException("reasonixVersion must start with MAJOR.MINOR.PATCH, got: ${publishedVersion}")
|
||||
}
|
||||
versionMajor = parsed.group(1) as int
|
||||
versionMinor = parsed.group(2) as int
|
||||
versionPatch = parsed.group(3) as int
|
||||
}
|
||||
|
||||
// Where the two build inputs are expected. Both are produced from the checkout
|
||||
// beside this module, by the scripts named in each message.
|
||||
@@ -53,6 +68,12 @@ android {
|
||||
// what `MainActivity` feeds real inset values for, since a WebView does
|
||||
// not implement `env(safe-area-inset-*)`.
|
||||
targetSdk 35
|
||||
// versionCode must strictly increase, because Android refuses an update
|
||||
// whose versionCode is not higher. The build digit is a single decimal
|
||||
// place, so the scheme collides once a patch reaches 10: 2.55.10 and
|
||||
// 2.56.0 both come out as 25601. Patch releases above 9 are not expected
|
||||
// on Studio, and widening the digit now would renumber every build, so
|
||||
// the constraint is recorded rather than fixed.
|
||||
versionCode versionMajor * 10000 + versionMinor * 100 + versionPatch * 10 + versionBuild
|
||||
versionName "${versionMajor}.${versionMinor}.${versionPatch}"
|
||||
|
||||
|
||||
+2917
-2806
File diff suppressed because it is too large.
Load diff
@@ -47,7 +47,7 @@ app_gradle="$android/app/build.gradle"
|
||||
version_field() {
|
||||
sed -n "s/^def $1 *= *\([0-9]*\).*/\1/p" "$app_gradle" | head -n1
|
||||
}
|
||||
app_version="$(version_field versionMajor).$(version_field versionMinor).$(version_field versionPatch)"
|
||||
app_version="${REASONIX_APP_VERSION:-$(version_field versionMajor).$(version_field versionMinor).$(version_field versionPatch)}"
|
||||
build_time_utc="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
|
||||
ldflags="-s -w"
|
||||
ldflags="$ldflags -X main.version=v$app_version"
|
||||
|
||||
@@ -32,7 +32,8 @@ fi
|
||||
|
||||
# 32 random bytes as hex — the same shape `Kernel.mintToken` writes.
|
||||
token=$(od -An -tx1 -N32 /dev/urandom | tr -d ' \n')
|
||||
printf '%s\n' "$token" > "$work/home/remote/launch.token"
|
||||
# The kernel refuses a token file other users can read.
|
||||
(umask 077; printf '%s\n' "$token" > "$work/home/remote/launch.token")
|
||||
|
||||
export REASONIX_HOME="$work/home"
|
||||
export HOME="$work/home"
|
||||
|
||||
@@ -72,6 +72,25 @@ go run ./cmd/reasonix serve # from the repository root
|
||||
cd desktop/frontend-next && pnpm install && pnpm dev # :5273, proxies API paths
|
||||
```
|
||||
|
||||
## Messages sent during a run
|
||||
|
||||
**Settings → Appearance → Keyboard shortcuts → Messages sent while running** chooses the default:
|
||||
|
||||
- **Steer at the next tool boundary** (default) adds guidance to the current run.
|
||||
- **Send after the whole run** queues a new turn until the active run finishes.
|
||||
|
||||
The choice is local to this device's browser profile, applies across projects,
|
||||
and survives reloads. It does not change idle sends or messages already queued.
|
||||
Skill invocations continue to use follow-up turns.
|
||||
|
||||
The queue still supports hold/resume, editing, and taking a message back into
|
||||
the composer. **Send now** overrides the wait for a selected message by stopping
|
||||
the current response and starting that message as a new turn; it is unavailable
|
||||
while the queue is held.
|
||||
|
||||
Guidance not delivered before a run ends still becomes a follow-up through the
|
||||
kernel's existing queue.
|
||||
|
||||
## Global show/hide shortcut
|
||||
|
||||
In the desktop shell, **Settings → Appearance → Global show / hide shortcut**
|
||||
|
||||
@@ -16,8 +16,10 @@
|
||||
"smoke": "electron test/smoke.js",
|
||||
"browser-live": "electron test/browser_live.js",
|
||||
"shortcut-live": "electron test/globalshortcut_live.js",
|
||||
"theme-live": "electron test/theme_live.js",
|
||||
"browser-resize-live": "electron test/browser_resize_live.js",
|
||||
"browser-fit-live": "electron test/browser_fit_live.js",
|
||||
"pick-live": "electron test/elementpick_live.js",
|
||||
"browser-fit-pages": "electron test/browser_fit_pages.js",
|
||||
"sleep-live": "electron test/sleep_live.js",
|
||||
"image-live": "electron test/image_live.js",
|
||||
|
||||
@@ -3,6 +3,7 @@ const crypto = require("node:crypto");
|
||||
const { WebContentsView, session } = require("electron");
|
||||
const { installPaneReload } = require("./reload");
|
||||
const { guestNavigationAllowed, typed } = require("./browserguard");
|
||||
const { ARM, STOP } = require("./elementpick");
|
||||
const { InputWake } = require("./inputwake");
|
||||
const { KeyboardKeeper } = require("./keyboard");
|
||||
|
||||
@@ -23,6 +24,26 @@ const PAGE_RADIUS = 8;
|
||||
// How many pages may stay asleep at once; past this the oldest is buried.
|
||||
const SLEPT_LIMIT = 18;
|
||||
|
||||
// How long a pick waits for the person. Past it the page is left as it was
|
||||
// found: a picker left armed swallows every click on the page, so the wait is
|
||||
// a minute and a half, not an afternoon.
|
||||
const PICK_LIMIT_MS = 90_000;
|
||||
// A page's own scripts live in world 0; the picker answers from its own.
|
||||
const PICK_WORLD = 999;
|
||||
// The page wrote the answer; these repeat the page script's own limits because
|
||||
// the shell cannot trust it to have run them.
|
||||
const TEXT_CLIP = 4000;
|
||||
const NEARBY_CLIP = 300;
|
||||
const MARKUP_CLIP = 6000;
|
||||
const VALUE_CLIP = 500;
|
||||
const ATTR_LIMIT = 64;
|
||||
const STYLE_CLIP = 200;
|
||||
const ELEMENT_STRINGS = ["pageUrl", "pageTitle", "tagName", "role", "accessibleName", "selector", "xpath", "text", "nearbyText", "htmlExcerpt"];
|
||||
const STYLE_KEYS = ["backgroundColor", "color", "display", "fontFamily", "fontSize", "fontWeight"];
|
||||
const SELECTOR_BASIS = ["testid", "aria-label", "id", "class"];
|
||||
const CANCEL_REASONS = ["escape", "dismissed", "timeout", "hidden", "navigation", "gone"];
|
||||
const clip = (value, limit) => (value.length > limit ? value.slice(0, limit) : value);
|
||||
|
||||
// A command that switches a CDP domain on. The kernel sends these once at
|
||||
// attach, and a sleep throws away the session they were said on, so they are
|
||||
// kept per page and said again to every session a wake builds.
|
||||
@@ -222,6 +243,17 @@ class BrowserViews {
|
||||
entry.onEvent(method, params);
|
||||
});
|
||||
this.keyboard.watch(contents, () => Date.now() < entry.synthUntil);
|
||||
// The picker's key belongs to the page while the page holds the keyboard:
|
||||
// a press that went there never reaches the window's own page, so it is
|
||||
// taken here and the picker is started where the person is looking.
|
||||
contents.on("before-input-event", (event, input) => {
|
||||
if (!input || input.type !== "keyDown" || input.isAutoRepeat || !input.key || !input.shift) return;
|
||||
if (String(input.key).toLowerCase() !== "c") return;
|
||||
if (process.platform === "darwin" ? !input.meta : !input.control) return;
|
||||
if (entry.targetId !== this.shown) return;
|
||||
event.preventDefault();
|
||||
this.send("browser:pick-shortcut", { targetId: entry.targetId });
|
||||
});
|
||||
this.watchLoads(entry.targetId, entry);
|
||||
const gone = () => {
|
||||
// A sleep closes the contents itself, so a death under it is the sleep
|
||||
@@ -374,7 +406,7 @@ class BrowserViews {
|
||||
// at it, a navigation or a settle is pending, a certificate or a dialog waits,
|
||||
// it is sounding, or DevTools is open. Downloads need no guard here.
|
||||
maySleep(entry) {
|
||||
if (entry.targetId === this.shown || entry.returning || entry.certificate || entry.dialog) return false;
|
||||
if (entry.targetId === this.shown || entry.picking || entry.returning || entry.certificate || entry.dialog) return false;
|
||||
const contents = entry.view && entry.view.webContents;
|
||||
if (typeof contents?.isCurrentlyAudible === "function" && contents.isCurrentlyAudible()) return false;
|
||||
if (typeof contents?.isDevToolsOpened === "function" && contents.isDevToolsOpened()) return false;
|
||||
@@ -569,6 +601,13 @@ class BrowserViews {
|
||||
for (const entry of this.entries.values()) {
|
||||
if (entry.view) this.putAway(entry.view, entry);
|
||||
}
|
||||
// A pick behind a page nobody can see cannot be used: the person is looking
|
||||
// at a picture, so the pick is settled instead of swallowing clicks unseen.
|
||||
for (const entry of this.entries.values()) {
|
||||
if (!entry.picking) continue;
|
||||
entry.picking.reason = "hidden";
|
||||
void this.cancelPick(entry.targetId);
|
||||
}
|
||||
}
|
||||
|
||||
putAway(view, entry) {
|
||||
@@ -608,6 +647,170 @@ class BrowserViews {
|
||||
}
|
||||
}
|
||||
|
||||
// pick arms one page's element picker and answers what the person pointed
|
||||
// at, or { cancelled: true } when they pressed Escape, navigated away, or
|
||||
// left it longer than a pick may wait. The highlight and the hover card are
|
||||
// drawn inside the page: a native view sits above anything this window's own
|
||||
// page could draw over it.
|
||||
// The page wrote the answer, so the shell decides what an answer is: fields
|
||||
// from an allow-list, strings clipped to the page script's own limits, and a
|
||||
// shape it cannot read treated as gone.
|
||||
clean(answer) {
|
||||
const gone = { cancelled: true, reason: "gone" };
|
||||
if (!answer || typeof answer !== "object") return gone;
|
||||
if (answer.cancelled) {
|
||||
const reason = CANCEL_REASONS.includes(answer.reason) ? answer.reason : "gone";
|
||||
return { cancelled: true, reason };
|
||||
}
|
||||
const el = answer.element;
|
||||
if (!el || typeof el !== "object") return gone;
|
||||
const out = {};
|
||||
for (const field of ELEMENT_STRINGS) {
|
||||
const value = el[field];
|
||||
if (typeof value !== "string") continue;
|
||||
out[field] = clip(value, field === "htmlExcerpt" ? MARKUP_CLIP : field === "nearbyText" ? NEARBY_CLIP : TEXT_CLIP);
|
||||
}
|
||||
if (SELECTOR_BASIS.includes(el.selectorBasis)) out.selectorBasis = el.selectorBasis;
|
||||
if (el.attributes && typeof el.attributes === "object") {
|
||||
const attributes = {};
|
||||
for (const [key, value] of Object.entries(el.attributes)) {
|
||||
if (Object.keys(attributes).length >= ATTR_LIMIT) break;
|
||||
if (typeof value === "string") attributes[clip(key, VALUE_CLIP)] = clip(value, VALUE_CLIP);
|
||||
}
|
||||
out.attributes = attributes;
|
||||
}
|
||||
if (Number.isFinite(el.capturedAt)) out.capturedAt = el.capturedAt;
|
||||
if (el.rect && typeof el.rect === "object") {
|
||||
const { x, y, width, height } = el.rect;
|
||||
if ([x, y, width, height].every(Number.isFinite)) out.rect = { x, y, width, height };
|
||||
}
|
||||
if (el.style && typeof el.style === "object") {
|
||||
out.style = {};
|
||||
for (const key of STYLE_KEYS) if (typeof el.style[key] === "string") out.style[key] = clip(el.style[key], STYLE_CLIP);
|
||||
}
|
||||
if (!out.selector || !out.tagName) return gone;
|
||||
return { cancelled: false, element: out };
|
||||
}
|
||||
|
||||
async pick(targetId, labels) {
|
||||
const entry = this.entries.get(targetId);
|
||||
if (!entry) return { cancelled: true, reason: "gone" };
|
||||
// A pick draws inside the page that is on screen; a page that is not shown
|
||||
// would pick behind the one the person is looking at.
|
||||
if (entry.targetId !== this.shown) return { cancelled: true, reason: "hidden" };
|
||||
if (entry.slept) await this.wake(entry).catch(() => {});
|
||||
const contents = entry.view && entry.view.webContents;
|
||||
if (!contents || contents.isDestroyed()) return { cancelled: true, reason: "gone" };
|
||||
// A second pick on the same page replaces the first: one picker, one
|
||||
// overlay, and the older call answers as cancelled.
|
||||
if (entry.picking) {
|
||||
entry.picking.reason = "dismissed";
|
||||
await this.cancelPick(targetId).catch(() => {});
|
||||
}
|
||||
// Why the pick ends when it ends without an element: Escape and a dismissal
|
||||
// come from the page, the other three from here. The person is told which,
|
||||
// so "nothing happened" is never the answer.
|
||||
const picking = (entry.picking = { reason: "" });
|
||||
const timer = setTimeout(() => {
|
||||
picking.reason = "timeout";
|
||||
void this.cancelPick(targetId);
|
||||
}, PICK_LIMIT_MS);
|
||||
if (typeof timer.unref === "function") timer.unref();
|
||||
try {
|
||||
const answer = this.clean(await contents.executeJavaScript(ARM(labels), false));
|
||||
if (answer.cancelled) return picking.reason ? { cancelled: true, reason: picking.reason } : answer;
|
||||
// The page cannot invent an element: the shell re-finds the selector in
|
||||
// its own world before the answer is trusted.
|
||||
if (!(await this.lands(contents, answer.element))) return { cancelled: true, reason: "gone" };
|
||||
// The picture is what the person saw: a complaint about how something
|
||||
// looks is answered by the element, not only by its markup.
|
||||
const shot = await this.shot(entry, answer.element.rect).catch(() => "");
|
||||
if (shot) answer.shot = shot;
|
||||
return answer;
|
||||
} catch {
|
||||
// The page navigated, reloaded, slept, or was closed under the pick.
|
||||
return { cancelled: true, reason: picking.reason || "navigation" };
|
||||
} finally {
|
||||
if (entry.picking === picking) entry.picking = null;
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
// lands asks the DOM from the shell's own world whether a selector still
|
||||
// names an element of the tag the pick reported. A page script cannot answer
|
||||
// this one in the page's place.
|
||||
async lands(contents, element) {
|
||||
if (!element || !element.selector || !element.tagName) return false;
|
||||
const tag = await contents
|
||||
.executeJavaScriptInIsolatedWorld(PICK_WORLD, [
|
||||
{
|
||||
code: `(() => {
|
||||
const el = document.querySelector(${JSON.stringify(element.selector)});
|
||||
return el ? el.tagName.toLowerCase() : "";
|
||||
})()`,
|
||||
},
|
||||
])
|
||||
.catch(() => "");
|
||||
return tag === String(element.tagName).toLowerCase();
|
||||
}
|
||||
|
||||
// shot captures the element as the view drew it. The page's rect is in CSS
|
||||
// pixels and the view may be fitted, so the scale is applied and the result
|
||||
// is kept inside the view; an element scrolled out of sight has no picture.
|
||||
async shot(entry, rect) {
|
||||
const contents = entry.view && entry.view.webContents;
|
||||
if (!contents || contents.isDestroyed() || !rect) return "";
|
||||
const scale = entry.scale || 1;
|
||||
const bounds = entry.view.getBounds();
|
||||
const at = {
|
||||
x: Math.max(0, Math.round(rect.x * scale)),
|
||||
y: Math.max(0, Math.round(rect.y * scale)),
|
||||
width: Math.round(rect.width * scale),
|
||||
height: Math.round(rect.height * scale),
|
||||
};
|
||||
at.width = Math.min(at.width, bounds.width - at.x);
|
||||
at.height = Math.min(at.height, bounds.height - at.y);
|
||||
if (at.width < 1 || at.height < 1) return "";
|
||||
const image = await contents.capturePage(at);
|
||||
return image.isEmpty() ? "" : image.toDataURL();
|
||||
}
|
||||
|
||||
// verify re-finds a selector after the page changed, so a person can see
|
||||
// whether an edit landed where the pick pointed.
|
||||
async verify(targetId, selector) {
|
||||
const entry = this.entries.get(targetId);
|
||||
if (!entry || !selector) return { found: false };
|
||||
const contents = entry.view && entry.view.webContents;
|
||||
if (!contents || contents.isDestroyed()) return { found: false };
|
||||
const at = await contents
|
||||
.executeJavaScriptInIsolatedWorld(PICK_WORLD, [
|
||||
{
|
||||
code: `(() => {
|
||||
const el = document.querySelector(${JSON.stringify(selector)});
|
||||
if (!el) return { found: false };
|
||||
const r = el.getBoundingClientRect();
|
||||
return { found: true, tag: el.tagName.toLowerCase(), rect: { x: r.x, y: r.y, width: r.width, height: r.height } };
|
||||
})()`,
|
||||
},
|
||||
])
|
||||
.catch(() => ({ found: false }));
|
||||
if (!at || !at.found || !at.rect) return { found: false };
|
||||
const shot = await this.shot(entry, at.rect).catch(() => "");
|
||||
return { found: true, tag: at.tag, rect: at.rect, shot };
|
||||
}
|
||||
|
||||
// cancelPick disarms the picker, which settles a pick still waiting on the
|
||||
// page. A page that has gone answers nothing, which reads as cancelled.
|
||||
cancelPick(targetId) {
|
||||
const entry = this.entries.get(targetId);
|
||||
const contents = entry && entry.view && entry.view.webContents;
|
||||
if (!contents || contents.isDestroyed()) return Promise.resolve(false);
|
||||
return contents.executeJavaScript(STOP, false).then(
|
||||
() => true,
|
||||
() => false,
|
||||
);
|
||||
}
|
||||
|
||||
// navigate starts loading what the person typed and answers "", or the code
|
||||
// of why the window will not: "scheme" or "network_file".
|
||||
navigate(targetId, address) {
|
||||
|
||||
@@ -0,0 +1,572 @@
|
||||
"use strict";
|
||||
|
||||
// The page half of the element picker, kept as a function so it can be
|
||||
// stringified into the page. The highlight and the hover card are drawn there
|
||||
// because a native view sits above anything this window's own page draws.
|
||||
//
|
||||
// Nothing here may reach out of the function: the page runs it alone, with no
|
||||
// module and no binding of this process to refer to.
|
||||
|
||||
function pagePicker(labels = {}) {
|
||||
// The window owns the words and sends them in: the page only draws them.
|
||||
const L = {
|
||||
color: "Color",
|
||||
background: "Background",
|
||||
font: "Font",
|
||||
frame: "Element inside an embedded frame — not supported",
|
||||
shadow: "Element inside a shadow root — not supported",
|
||||
...labels,
|
||||
};
|
||||
const KEY = "__reasonixElementPicker";
|
||||
const CLIP = 4000;
|
||||
// Any words of its own already say what the element is, in any language;
|
||||
// only one that cannot speak — an icon, an image — is given the words
|
||||
// around it. A Chinese heading is a sentence in a dozen characters, so the
|
||||
// test is whether it has text at all, not how much.
|
||||
const NEARBY_OWN = 2;
|
||||
const NEARBY_LIMIT = 300;
|
||||
let settlePick = null;
|
||||
// The promise is the pick: it settles on the click, on Escape, or when the
|
||||
// page takes the picker away from under it.
|
||||
const answer = new Promise((done) => {
|
||||
settlePick = done;
|
||||
});
|
||||
const previous = window[KEY];
|
||||
if (previous) previous.stop();
|
||||
|
||||
const TAG_ROLES = {
|
||||
A: "link",
|
||||
BUTTON: "button",
|
||||
IMG: "img",
|
||||
NAV: "navigation",
|
||||
MAIN: "main",
|
||||
FORM: "form",
|
||||
SELECT: "combobox",
|
||||
TEXTAREA: "textbox",
|
||||
H1: "heading",
|
||||
H2: "heading",
|
||||
H3: "heading",
|
||||
H4: "heading",
|
||||
H5: "heading",
|
||||
H6: "heading",
|
||||
};
|
||||
const INPUT_ROLES = {
|
||||
checkbox: "checkbox",
|
||||
radio: "radio",
|
||||
range: "slider",
|
||||
button: "button",
|
||||
submit: "button",
|
||||
reset: "button",
|
||||
};
|
||||
const KEPT = new Set(["id", "class", "href", "src", "alt", "title", "name", "type", "placeholder", "data-testid"]);
|
||||
// The roles an element can have while saying nothing to a reader by itself:
|
||||
// an icon button, an image, a field. Context is for these. A decorative
|
||||
// span has no role, and the page around it does not give it one.
|
||||
const SPEAKING = new Set(["button", "link", "textbox", "checkbox", "radio", "slider", "combobox", "img"]);
|
||||
|
||||
const clip = (text, limit) => (text.length > limit ? text.slice(0, limit) : text);
|
||||
const textOf = (node) => (node.innerText || node.textContent || "").trim();
|
||||
|
||||
// rgb()/rgba() as the page computed them, written the way a person reads a
|
||||
// colour; a translucent one is left as computed so its alpha is not lost.
|
||||
const hex = (css) => {
|
||||
const match = /^rgba?\(([^)]+)\)$/.exec(css || "");
|
||||
if (!match) return css || "";
|
||||
const parts = match[1].split(/[\s,/]+/).filter(Boolean).map(Number);
|
||||
if (parts.length > 3 && parts[3] < 1) return css;
|
||||
const [r, g, b] = parts;
|
||||
return ("#" + [r, g, b].map((one) => Math.round(one).toString(16).padStart(2, "0")).join("")).toUpperCase();
|
||||
};
|
||||
|
||||
const roleOf = (el) => {
|
||||
const stated = el.getAttribute("role");
|
||||
if (stated) return stated;
|
||||
if (el.tagName === "INPUT") return INPUT_ROLES[el.type] || "textbox";
|
||||
return TAG_ROLES[el.tagName] || "";
|
||||
};
|
||||
|
||||
const nameOf = (el) => {
|
||||
const ids = (el.getAttribute("aria-labelledby") || "").split(/\s+/).filter(Boolean);
|
||||
if (ids.length) {
|
||||
const text = ids
|
||||
.map((id) => {
|
||||
const node = document.getElementById(id);
|
||||
return node ? textOf(node) : "";
|
||||
})
|
||||
.join(" ")
|
||||
.trim();
|
||||
if (text) return clip(text, 500);
|
||||
}
|
||||
for (const attr of ["aria-label", "alt", "title", "placeholder"]) {
|
||||
const value = el.getAttribute(attr);
|
||||
if (value && value.trim()) return clip(value.trim(), 500);
|
||||
}
|
||||
return clip(textOf(el), 500);
|
||||
};
|
||||
|
||||
// What the page declares as an identity outlives what it builds for styling:
|
||||
// a hash class is gone after the next deploy, a data-testid is not.
|
||||
const attrValue = (value) => '"' + value.replace(/["\\]/g, "\\$&") + '"';
|
||||
|
||||
// A CSS path a person can paste, preferring the page's own identity over its
|
||||
// styling. Falls back to tag.class with :nth-of-type where a sibling would
|
||||
// answer the same, up to eight levels of parent.
|
||||
const selectorOf = (el) => {
|
||||
const testId = el.getAttribute("data-testid");
|
||||
if (testId) return { selector: `[data-testid=${attrValue(testId)}]`, basis: "testid" };
|
||||
const label = el.getAttribute("aria-label");
|
||||
if (label) return { selector: `${el.tagName.toLowerCase()}[aria-label=${attrValue(label)}]`, basis: "aria-label" };
|
||||
const own = el.getAttribute("id");
|
||||
if (own) return { selector: "#" + CSS.escape(own), basis: "id" };
|
||||
const parts = [];
|
||||
let node = el;
|
||||
for (let depth = 0; node && node.nodeType === 1 && depth < 8; depth++) {
|
||||
const id = node.getAttribute("id");
|
||||
if (id) {
|
||||
parts.unshift("#" + CSS.escape(id));
|
||||
break;
|
||||
}
|
||||
let part = node.tagName.toLowerCase();
|
||||
for (const name of [...node.classList].filter(Boolean).slice(0, 3)) part += "." + CSS.escape(name);
|
||||
const parent = node.parentElement;
|
||||
if (parent) {
|
||||
const same = [...parent.children].filter((child) => child.tagName === node.tagName);
|
||||
if (same.length > 1) part += `:nth-of-type(${same.indexOf(node) + 1})`;
|
||||
}
|
||||
parts.unshift(part);
|
||||
node = node.parentElement;
|
||||
}
|
||||
return { selector: parts.join(" > "), basis: "class" };
|
||||
};
|
||||
|
||||
const xpathOf = (el) => {
|
||||
const parts = [];
|
||||
let node = el;
|
||||
for (let depth = 0; node && node.nodeType === 1 && depth < 12; depth++) {
|
||||
let index = 1;
|
||||
for (let sib = node.previousElementSibling; sib; sib = sib.previousElementSibling) {
|
||||
if (sib.tagName === node.tagName) index++;
|
||||
}
|
||||
parts.unshift(`${node.tagName.toLowerCase()}[${index}]`);
|
||||
node = node.parentElement;
|
||||
}
|
||||
return "/" + parts.join("/");
|
||||
};
|
||||
|
||||
// What the element says. A password field never hands over what was typed in
|
||||
// it: the pick leaves with the fact that it was one, and nothing else.
|
||||
const textFor = (el) => {
|
||||
// An editable region holds what the person is writing right now.
|
||||
if (el.isContentEditable) return "[masked editable text]";
|
||||
if (el.tagName === "INPUT" && el.type === "password") return "[masked password input]";
|
||||
if (el.tagName === "INPUT" || el.tagName === "TEXTAREA") {
|
||||
return clip([el.getAttribute("aria-label"), el.getAttribute("placeholder"), el.getAttribute("name"), el.type].filter(Boolean).join(" "), CLIP);
|
||||
}
|
||||
return clip(textOf(el), CLIP);
|
||||
};
|
||||
|
||||
// What surrounds an element, for the ones that cannot speak for themselves.
|
||||
// An element with text of its own asks for nothing: the nearest article
|
||||
// around a title is the whole page, which is not context but noise.
|
||||
// What surrounds an element, for the ones that cannot speak for themselves.
|
||||
// An element with text of its own asks for nothing, and neither does one
|
||||
// with no role at all: a decorative mark is not made meaningful by the page
|
||||
// around it, so the words there would be noise rather than context.
|
||||
const nearbyFor = (el) => {
|
||||
if (textOf(el).trim().length >= NEARBY_OWN) return "";
|
||||
if (!SPEAKING.has(roleOf(el))) return "";
|
||||
const near = el.closest("article, section, main, form, li, tr, dialog") || el.parentElement;
|
||||
return near ? clip(textOf(near), NEARBY_LIMIT) : "";
|
||||
};
|
||||
|
||||
// The element's own markup with its scripts and the values a person typed
|
||||
// taken out, so what goes to the chat is structure, not their input.
|
||||
const excerptFor = (el) => {
|
||||
const clone = el.cloneNode(true);
|
||||
for (const drop of clone.querySelectorAll("script, style, noscript, template")) drop.remove();
|
||||
for (const field of clone.querySelectorAll("input")) field.removeAttribute("value");
|
||||
if (clone.tagName === "INPUT") clone.removeAttribute("value");
|
||||
for (const area of clone.querySelectorAll("textarea")) area.textContent = "";
|
||||
if (clone.isContentEditable) clone.textContent = "";
|
||||
return clip(clone.outerHTML, 6000);
|
||||
};
|
||||
|
||||
const describe = (el) => {
|
||||
const rect = el.getBoundingClientRect();
|
||||
const computed = getComputedStyle(el);
|
||||
const attributes = {};
|
||||
for (const attr of el.attributes) {
|
||||
const name = attr.name.toLowerCase();
|
||||
if (name === "value") continue;
|
||||
if (!KEPT.has(name) && !name.startsWith("aria-")) continue;
|
||||
attributes[name] = clip(attr.value, 500);
|
||||
}
|
||||
const style = {
|
||||
color: hex(computed.color),
|
||||
display: computed.display,
|
||||
fontFamily: computed.fontFamily,
|
||||
fontSize: computed.fontSize,
|
||||
fontWeight: computed.fontWeight,
|
||||
};
|
||||
if (computed.backgroundColor && computed.backgroundColor !== "rgba(0, 0, 0, 0)" && computed.backgroundColor !== "transparent") {
|
||||
style.backgroundColor = hex(computed.backgroundColor);
|
||||
}
|
||||
const path = selectorOf(el);
|
||||
const info = {
|
||||
pageUrl: location.href,
|
||||
pageTitle: document.title,
|
||||
tagName: el.tagName.toLowerCase(),
|
||||
selector: path.selector,
|
||||
xpath: xpathOf(el),
|
||||
htmlExcerpt: excerptFor(el),
|
||||
attributes,
|
||||
rect: { x: rect.x, y: rect.y, width: rect.width, height: rect.height },
|
||||
style,
|
||||
capturedAt: Date.now(),
|
||||
};
|
||||
// Which of the four the selector came from, so a reader can tell a stable
|
||||
// identity from one that a deploy will change.
|
||||
info.selectorBasis = path.basis;
|
||||
// A field with no answer is left out rather than sent empty: "there is no
|
||||
// role" and "the role is an empty string" are different answers, and the
|
||||
// second is not one this page can give.
|
||||
const role = roleOf(el);
|
||||
if (role) info.role = role;
|
||||
const name = nameOf(el);
|
||||
if (name) info.accessibleName = name;
|
||||
const own = textFor(el);
|
||||
if (own) info.text = own;
|
||||
const nearby = nearbyFor(el);
|
||||
if (nearby) info.nearbyText = nearby;
|
||||
return info;
|
||||
};
|
||||
|
||||
const box = document.createElement("div");
|
||||
box.dataset.reasonixPick = "box";
|
||||
box.setAttribute("aria-hidden", "true");
|
||||
box.style.cssText =
|
||||
"position:fixed;z-index:2147483646;pointer-events:none;border:2px solid #3b82f6;" +
|
||||
"border-radius:3px;background:rgba(59,130,246,.12);box-sizing:border-box";
|
||||
const card = document.createElement("div");
|
||||
card.dataset.reasonixPick = "card";
|
||||
card.setAttribute("aria-hidden", "true");
|
||||
card.style.cssText =
|
||||
"position:fixed;z-index:2147483647;pointer-events:none;background:#1f2328;color:#e6edf3;" +
|
||||
"border-radius:8px;padding:9px 12px;font:12px/1.6 ui-sans-serif,system-ui,sans-serif;" +
|
||||
"box-shadow:0 8px 24px rgba(0,0,0,.28);min-width:190px;max-width:340px";
|
||||
|
||||
// One card line. The label is the quiet side, the value the loud one; both
|
||||
// are set as text, never as markup, because the page chose both.
|
||||
const line = (label, value, loud) => {
|
||||
const row = document.createElement("div");
|
||||
row.style.cssText = "display:flex;gap:12px;justify-content:space-between;align-items:baseline";
|
||||
const key = document.createElement("span");
|
||||
key.textContent = label;
|
||||
key.style.cssText = `flex:none;color:${loud ? "#e6edf3" : "#8b949e"};font-weight:${loud ? 600 : 400}`;
|
||||
const val = document.createElement("span");
|
||||
val.textContent = value;
|
||||
val.style.cssText =
|
||||
"color:#e6edf3;font-family:ui-monospace,SFMono-Regular,Menlo,monospace;overflow:hidden;" +
|
||||
"text-overflow:ellipsis;white-space:nowrap" + (loud ? ";font-weight:600" : "");
|
||||
row.append(key, val);
|
||||
card.append(row);
|
||||
};
|
||||
|
||||
let target = null;
|
||||
// The chain of children walked up from, so the down keys can walk back.
|
||||
let descended = [];
|
||||
// Shift-click keeps a set: the person is naming a region, not one node.
|
||||
const chosen = [];
|
||||
const marks = [];
|
||||
let armed = true;
|
||||
let listeners = [];
|
||||
|
||||
const clear = () => {
|
||||
for (const [type, handler, capture] of listeners) document.removeEventListener(type, handler, capture);
|
||||
listeners = [];
|
||||
for (const { cover } of frameCovers) cover.remove();
|
||||
frameCovers.length = 0;
|
||||
for (const at of marks) at.remove();
|
||||
marks.length = 0;
|
||||
box.remove();
|
||||
card.remove();
|
||||
if (window[KEY] && window[KEY].stop === stop) delete window[KEY];
|
||||
};
|
||||
|
||||
const settle = (result) => {
|
||||
if (!armed) return;
|
||||
armed = false;
|
||||
clear();
|
||||
settlePick(result);
|
||||
};
|
||||
|
||||
const stop = (reason = "dismissed") => settle({ cancelled: true, reason });
|
||||
|
||||
// Where the card sits: under the element when the viewport has room for it,
|
||||
// above otherwise, and never past either edge.
|
||||
// A frame's own document takes the pointer, so the top document never hears a
|
||||
// move over it. A cover over each frame keeps the picker's events coming and
|
||||
// says why that area is not pickable instead of going quiet.
|
||||
const frameCovers = [];
|
||||
const positionCovers = () => {
|
||||
for (const { cover, frame } of frameCovers) {
|
||||
const rect = frame.getBoundingClientRect();
|
||||
cover.style.left = rect.left + "px";
|
||||
cover.style.top = rect.top + "px";
|
||||
cover.style.width = rect.width + "px";
|
||||
cover.style.height = rect.height + "px";
|
||||
cover.style.display = rect.width > 0 && rect.height > 0 ? "block" : "none";
|
||||
}
|
||||
};
|
||||
const coverFrames = () => {
|
||||
for (const { cover } of frameCovers) cover.remove();
|
||||
frameCovers.length = 0;
|
||||
for (const frame of document.querySelectorAll("iframe, frame")) {
|
||||
const cover = document.createElement("div");
|
||||
cover.dataset.reasonixFrame = "1";
|
||||
cover.style.cssText =
|
||||
"position:fixed;z-index:2147483646;pointer-events:auto;background:rgba(59,130,246,0.08);border:1px dashed rgba(59,130,246,0.55);box-sizing:border-box";
|
||||
cover.addEventListener("pointermove", () => show(frame), true);
|
||||
cover.addEventListener("click", (event) => {
|
||||
swallow(event);
|
||||
show(frame);
|
||||
}, true);
|
||||
document.documentElement.append(cover);
|
||||
frameCovers.push({ cover, frame });
|
||||
}
|
||||
positionCovers();
|
||||
};
|
||||
|
||||
const place = () => {
|
||||
if (!target) return;
|
||||
const rect = target.getBoundingClientRect();
|
||||
const width = Math.max(0, rect.width);
|
||||
const height = Math.max(0, rect.height);
|
||||
box.style.left = rect.left + "px";
|
||||
box.style.top = rect.top + "px";
|
||||
box.style.width = width + "px";
|
||||
box.style.height = height + "px";
|
||||
card.style.visibility = "hidden";
|
||||
card.style.left = "0px";
|
||||
card.style.top = "0px";
|
||||
const size = card.getBoundingClientRect();
|
||||
const below = rect.bottom + 8;
|
||||
const top = below + size.height <= innerHeight ? below : Math.max(8, rect.top - size.height - 8);
|
||||
const left = Math.min(Math.max(8, rect.left), Math.max(8, innerWidth - size.width - 8));
|
||||
card.style.left = left + "px";
|
||||
card.style.top = top + "px";
|
||||
card.style.visibility = "visible";
|
||||
};
|
||||
|
||||
// The chain from the document down to the element, short enough to read in
|
||||
// the card: a person walking up with the arrow keys needs to see where they
|
||||
// are, not the whole tree.
|
||||
const ancestorsOf = (el) => {
|
||||
const chain = [];
|
||||
for (let node = el; node && node.nodeType === 1; node = node.parentElement) {
|
||||
chain.unshift(node);
|
||||
if (chain.length > 8) break;
|
||||
}
|
||||
return chain;
|
||||
};
|
||||
const crumbLabel = (el) => el.tagName.toLowerCase() + (el.id ? "#" + el.id : "");
|
||||
|
||||
// A frame or a shadow host cannot be read from here: the picker only ever
|
||||
// sees the host element, so it says so instead of handing over a useless
|
||||
// answer that looks like the real thing.
|
||||
const blockedOf = (el) => {
|
||||
if (!el) return "";
|
||||
if (el.tagName === "IFRAME" || el.tagName === "FRAME") return "iframe";
|
||||
if (el.shadowRoot) return "shadow";
|
||||
return "";
|
||||
};
|
||||
|
||||
const show = (el) => {
|
||||
target = el;
|
||||
const rect = el.getBoundingClientRect();
|
||||
const computed = getComputedStyle(el);
|
||||
card.textContent = "";
|
||||
const blocked = blockedOf(el);
|
||||
if (blocked) {
|
||||
const note = document.createElement("div");
|
||||
note.textContent = blocked === "iframe" ? L.frame : L.shadow;
|
||||
note.style.cssText = "max-width:260px;color:#e6edf3;font-size:12px;line-height:1.5";
|
||||
card.append(note);
|
||||
box.style.display = "block";
|
||||
card.style.display = "block";
|
||||
place();
|
||||
return;
|
||||
}
|
||||
const crumb = document.createElement("div");
|
||||
crumb.style.cssText = "display:flex;gap:4px;overflow:hidden;white-space:nowrap;margin-bottom:4px;color:#8b949e;font-size:11px";
|
||||
const chain = ancestorsOf(el).slice(-5);
|
||||
chain.forEach((node, index) => {
|
||||
if (index) {
|
||||
const sep = document.createElement("span");
|
||||
sep.textContent = "›";
|
||||
sep.style.opacity = "0.6";
|
||||
crumb.append(sep);
|
||||
}
|
||||
const part = document.createElement("span");
|
||||
part.textContent = crumbLabel(node);
|
||||
if (node === el) {
|
||||
part.style.color = "#e6edf3";
|
||||
part.style.fontWeight = "600";
|
||||
}
|
||||
crumb.append(part);
|
||||
});
|
||||
card.append(crumb);
|
||||
line(el.tagName.toLowerCase(), `${Math.round(rect.width)}×${Math.round(rect.height)}`, true);
|
||||
line(L.color, hex(computed.color), false);
|
||||
if (computed.backgroundColor && computed.backgroundColor !== "rgba(0, 0, 0, 0)" && computed.backgroundColor !== "transparent") {
|
||||
line(L.background, hex(computed.backgroundColor), false);
|
||||
}
|
||||
line(L.font, `${computed.fontSize} ${computed.fontFamily}`, false);
|
||||
box.style.display = "block";
|
||||
card.style.display = "block";
|
||||
place();
|
||||
};
|
||||
|
||||
document.documentElement.append(box, card);
|
||||
coverFrames();
|
||||
box.style.display = "none";
|
||||
card.style.display = "none";
|
||||
|
||||
// Every gesture is taken from the page while the pick is armed: a click
|
||||
// chooses an element instead of pressing a button, and no page handler hears
|
||||
// it either. Escape is the way out that changes nothing.
|
||||
const swallow = (event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
event.stopImmediatePropagation();
|
||||
};
|
||||
let moveAt = null;
|
||||
let moveFrame = 0;
|
||||
const onMove = (event) => {
|
||||
// One hit test per frame: a page with a heavy layout is not asked to answer
|
||||
// a move it will not draw.
|
||||
moveAt = event;
|
||||
if (moveFrame) return;
|
||||
moveFrame = requestAnimationFrame(() => {
|
||||
moveFrame = 0;
|
||||
if (!armed) return;
|
||||
const at = moveAt;
|
||||
moveAt = null;
|
||||
if (!at) return;
|
||||
const el = document.elementFromPoint(at.clientX, at.clientY);
|
||||
if (!el || el === box || el === card) return;
|
||||
if (el.dataset && el.dataset.reasonixFrame) return;
|
||||
if (el === target) return;
|
||||
descended = [];
|
||||
show(el);
|
||||
});
|
||||
};
|
||||
// A picked element keeps a mark with its order, so a set stays visible while
|
||||
// the person keeps adding to it.
|
||||
const mark = (el) => {
|
||||
const rect = el.getBoundingClientRect();
|
||||
const at = document.createElement("div");
|
||||
at.dataset.reasonixPicked = "1";
|
||||
at.setAttribute("aria-hidden", "true");
|
||||
at.style.cssText = `position:fixed;z-index:2147483645;pointer-events:none;border:1px solid #22c55e;background:rgba(34,197,94,0.12);box-sizing:border-box;left:${rect.left}px;top:${rect.top}px;width:${rect.width}px;height:${rect.height}px`;
|
||||
const order = document.createElement("span");
|
||||
order.textContent = String(marks.length + 1);
|
||||
order.style.cssText = "position:absolute;top:-17px;left:-1px;background:#22c55e;color:#04170a;font:600 10px ui-sans-serif,system-ui,sans-serif;padding:1px 4px;border-radius:3px";
|
||||
at.append(order);
|
||||
document.documentElement.append(at);
|
||||
marks.push(at);
|
||||
};
|
||||
const onPick = (event) => {
|
||||
if (!armed) return;
|
||||
swallow(event);
|
||||
// The pointer may have climbed with the arrow keys: what is picked is the
|
||||
// level the person is looking at, not what happens to be under the cursor.
|
||||
try {
|
||||
// A frame or shadow host is not an answer: the click leaves the pick
|
||||
// armed while the card already says why.
|
||||
if (target) {
|
||||
if (blockedOf(target)) return;
|
||||
if (event.shiftKey) {
|
||||
chosen.push(target);
|
||||
mark(target);
|
||||
return;
|
||||
}
|
||||
return settle(chosen.length ? { cancelled: false, elements: [...chosen, target].map(describe) } : { cancelled: false, element: describe(target) });
|
||||
}
|
||||
const el = document.elementFromPoint(event.clientX, event.clientY);
|
||||
if (!el) return settle({ cancelled: true });
|
||||
if (blockedOf(el)) return;
|
||||
if (event.shiftKey) {
|
||||
chosen.push(el);
|
||||
mark(el);
|
||||
return;
|
||||
}
|
||||
settle(chosen.length ? { cancelled: false, elements: [...chosen, el].map(describe) } : { cancelled: false, element: describe(el) });
|
||||
} catch (error) {
|
||||
console.error("reasonix element pick failed", error);
|
||||
settle({ cancelled: true, reason: "gone" });
|
||||
}
|
||||
};
|
||||
const onKey = (event) => {
|
||||
if (event.key === "Escape") {
|
||||
swallow(event);
|
||||
stop("escape");
|
||||
return;
|
||||
}
|
||||
// Up walks to the parent and down back to the child it left, with the chain
|
||||
// drawn in the card so neither move is a guess.
|
||||
if (event.key === "ArrowUp" || event.key === "ArrowLeft") {
|
||||
swallow(event);
|
||||
const parent = target && target.parentElement;
|
||||
if (parent && parent !== document.documentElement) {
|
||||
descended.push(target);
|
||||
show(parent);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (event.key === "ArrowDown" || event.key === "ArrowRight") {
|
||||
swallow(event);
|
||||
const child = descended.pop();
|
||||
if (child && target && child.parentElement === target) show(child);
|
||||
return;
|
||||
}
|
||||
// Every other key belongs to the picker as well: Enter on a focused button
|
||||
// would run the page action a click is not allowed to run, and the pick is
|
||||
// not using the page.
|
||||
swallow(event);
|
||||
};
|
||||
const onScroll = () => {
|
||||
place();
|
||||
positionCovers();
|
||||
};
|
||||
|
||||
for (const [type, handler, capture] of [
|
||||
["pointerdown", swallow, true],
|
||||
["pointerup", swallow, true],
|
||||
["mousedown", swallow, true],
|
||||
["mouseup", swallow, true],
|
||||
["dblclick", swallow, true],
|
||||
["contextmenu", swallow, true],
|
||||
["click", onPick, true],
|
||||
["pointermove", onMove, true],
|
||||
["mousemove", onMove, true],
|
||||
["keydown", onKey, true],
|
||||
["scroll", onScroll, true],
|
||||
["resize", onScroll, false],
|
||||
]) {
|
||||
document.addEventListener(type, handler, capture);
|
||||
listeners.push([type, handler, capture]);
|
||||
}
|
||||
|
||||
window[KEY] = { stop };
|
||||
return answer;
|
||||
}
|
||||
|
||||
// ARM runs the picker in the page's own world; the promise it answers with is
|
||||
// what executeJavaScript returns, so a pick takes as long as the person does.
|
||||
const ARM = (labels) => `(${pagePicker.toString()})(${JSON.stringify(labels || {})})`;
|
||||
|
||||
// STOP disarms whatever picker is armed, settling that pick as cancelled.
|
||||
const STOP = `(() => { const picker = window.__reasonixElementPicker; if (picker) picker.stop(); })()`;
|
||||
|
||||
module.exports = { ARM, STOP };
|
||||
@@ -5,17 +5,20 @@
|
||||
// compositor leaves uncovered at a fractional scale matches the app.
|
||||
const GROUND = { light: "#f4f1ea", dark: "#060a10" };
|
||||
|
||||
// "auto" and anything unrecognised follow the system, as the page does.
|
||||
function groundFor(prefs, systemDark) {
|
||||
const theme = prefs?.["rx-theme"];
|
||||
const dark = theme === "dark" ? true : theme === "light" ? false : systemDark;
|
||||
function groundFor(prefs, systemDark, painted) {
|
||||
const source = themeSourceFor(prefs, painted);
|
||||
const dark = source === "system" ? systemDark : source === "dark";
|
||||
return dark ? GROUND.dark : GROUND.light;
|
||||
}
|
||||
|
||||
// The scheme the window frame takes. Windows draws a frameless window's border
|
||||
// from it, so a dark page on a light system needs it set to stay borderless.
|
||||
function themeSourceFor(prefs) {
|
||||
const theme = prefs?.["rx-theme"];
|
||||
function themeSourceFor(prefs, painted) {
|
||||
const theme = painted?.mode ?? prefs?.["rx-theme"];
|
||||
if (theme === "scheduled") {
|
||||
const scheme = painted?.scheme ?? prefs?.["rx-theme-resolved"];
|
||||
return scheme === "dark" || scheme === "light" ? scheme : "system";
|
||||
}
|
||||
return theme === "dark" || theme === "light" ? theme : "system";
|
||||
}
|
||||
|
||||
|
||||
@@ -337,9 +337,12 @@ function fitted() {
|
||||
}
|
||||
|
||||
const storedPrefs = () => loadPrefs(prefsFile(app.getPath("userData")));
|
||||
const ground = () => groundFor(storedPrefs(), nativeTheme.shouldUseDarkColors);
|
||||
let paintedTheme = null;
|
||||
const ground = () => groundFor(storedPrefs(), nativeTheme.shouldUseDarkColors, paintedTheme);
|
||||
const followTheme = () => {
|
||||
nativeTheme.themeSource = themeSourceFor(storedPrefs());
|
||||
const source = themeSourceFor(storedPrefs(), paintedTheme);
|
||||
if (nativeTheme.themeSource !== source) nativeTheme.themeSource = source;
|
||||
if (win && !win.isDestroyed()) win.setBackgroundColor(ground());
|
||||
};
|
||||
|
||||
function createWindow() {
|
||||
@@ -400,9 +403,11 @@ const uiLang = () => uiLanguage(loadPrefs(prefsFile(app.getPath("userData"))), a
|
||||
registerPrefs(ipcMain, () => prefsFile(app.getPath("userData")), fromWindow, () => {
|
||||
installApplicationMenu(uiLang);
|
||||
followTheme();
|
||||
if (win && !win.isDestroyed()) win.setBackgroundColor(ground());
|
||||
void powerGuard?.refresh();
|
||||
browserViews?.setSleepAfterMs(pageSleepAfterMs(storedPrefs()));
|
||||
}, paint => {
|
||||
paintedTheme = paint;
|
||||
followTheme();
|
||||
});
|
||||
nativeTheme.on("updated", () => {
|
||||
if (win && !win.isDestroyed()) win.setBackgroundColor(ground());
|
||||
@@ -446,6 +451,15 @@ ipcMain.handle("browser:control", (event, targetId, action) => {
|
||||
ipcMain.handle("browser:navigate", (event, targetId, address) =>
|
||||
fromWindow(event) ? (browserViews?.navigate(String(targetId), String(address)) ?? "scheme") : "scheme",
|
||||
);
|
||||
ipcMain.handle("browser:pick", (event, targetId, labels) =>
|
||||
fromWindow(event) ? (browserViews?.pick(String(targetId), labels) ?? { cancelled: true }) : { cancelled: true },
|
||||
);
|
||||
ipcMain.handle("browser:pick-cancel", (event, targetId) =>
|
||||
fromWindow(event) ? (browserViews?.cancelPick(String(targetId)) ?? false) : false,
|
||||
);
|
||||
ipcMain.handle("browser:verify", (event, targetId, selector) =>
|
||||
fromWindow(event) ? (browserViews?.verify(String(targetId), String(selector)) ?? { found: false }) : { found: false },
|
||||
);
|
||||
ipcMain.handle("browser:trust-certificate", (event, targetId) =>
|
||||
fromWindow(event) ? (browserViews?.trustCertificate(String(targetId)) ?? false) : false,
|
||||
);
|
||||
|
||||
@@ -52,7 +52,7 @@ function savePrefs(file, prefs) {
|
||||
// in the same process and must not reach this file.
|
||||
// fileOf is asked on each use: the profile directory is settled during boot,
|
||||
// after these handlers exist.
|
||||
function registerPrefs(ipcMain, fileOf, fromWindow, onSave = () => {}) {
|
||||
function registerPrefs(ipcMain, fileOf, fromWindow, onSave = () => {}, onTheme = () => {}) {
|
||||
ipcMain.on("prefs:load", (event) => {
|
||||
event.returnValue = fromWindow(event) ? pickPagePrefs(loadPrefs(fileOf())) : {};
|
||||
});
|
||||
@@ -71,6 +71,15 @@ function registerPrefs(ipcMain, fileOf, fromWindow, onSave = () => {}) {
|
||||
}
|
||||
event.returnValue = true;
|
||||
});
|
||||
ipcMain.on("prefs:theme", (event, paint) => {
|
||||
if (!fromWindow(event) || !event.senderFrame || event.senderFrame !== event.sender.mainFrame) return;
|
||||
if (!paint || typeof paint !== "object" || Array.isArray(paint)) return;
|
||||
const { mode, scheme } = paint;
|
||||
if (mode !== "auto" && mode !== "scheduled" && mode !== "light" && mode !== "dark") return;
|
||||
if (scheme !== "light" && scheme !== "dark") return;
|
||||
if ((mode === "light" || mode === "dark") && mode !== scheme) return;
|
||||
onTheme({ mode, scheme });
|
||||
});
|
||||
}
|
||||
|
||||
function pickPagePrefs(prefs) {
|
||||
|
||||
@@ -22,6 +22,7 @@ contextBridge.exposeInMainWorld("reasonixHost", {
|
||||
platform: process.platform,
|
||||
titleBar,
|
||||
setUnreadBadge: (unread) => ipcRenderer.invoke("window:unread-badge", unread),
|
||||
syncTheme: (mode, scheme) => ipcRenderer.send("prefs:theme", { mode, scheme }),
|
||||
minimiseWindow: () => ipcRenderer.invoke("window:minimise"),
|
||||
toggleMaximiseWindow: () => ipcRenderer.invoke("window:toggle-maximise"),
|
||||
isWindowMaximised: () => ipcRenderer.invoke("window:is-maximised"),
|
||||
@@ -64,10 +65,17 @@ contextBridge.exposeInMainWorld("reasonixHost", {
|
||||
freezeBrowserView: () => ipcRenderer.invoke("browser:freeze"),
|
||||
controlBrowserView: (targetId, action) => ipcRenderer.invoke("browser:control", String(targetId), String(action)),
|
||||
navigateBrowserView: (targetId, address) => ipcRenderer.invoke("browser:navigate", String(targetId), String(address)),
|
||||
// Arm this page's element picker, and let a person put it away again. The
|
||||
// answer is the element they pointed at, or that they picked nothing.
|
||||
pickBrowserElement: (targetId, labels) => ipcRenderer.invoke("browser:pick", String(targetId), labels),
|
||||
cancelBrowserPick: (targetId) => ipcRenderer.invoke("browser:pick-cancel", String(targetId)),
|
||||
verifyBrowserElement: (targetId, selector) => ipcRenderer.invoke("browser:verify", String(targetId), String(selector)),
|
||||
// Why a page did not load, and a login a page or proxy asks for. The password
|
||||
// goes back to this window's main process only; the kernel never sees it.
|
||||
onBrowserLoadState: (listener) => subscribe("browser:load-state", listener),
|
||||
onBrowserLogin: (listener) => subscribe("browser:login", listener),
|
||||
// The picker's key, pressed in the page rather than in this one.
|
||||
onBrowserPickShortcut: (listener) => subscribe("browser:pick-shortcut", listener),
|
||||
trustBrowserCertificate: (targetId) => ipcRenderer.invoke("browser:trust-certificate", String(targetId)),
|
||||
answerBrowserLogin: (id, username, password) =>
|
||||
ipcRenderer.invoke("browser:login-answer", String(id), String(username || ""), String(password || "")),
|
||||
|
||||
@@ -0,0 +1,319 @@
|
||||
"use strict";
|
||||
// Drives the element picker in the real shell: a person's own mouse and keys
|
||||
// over a page of our own, in a user-data directory of our own. Run with
|
||||
// `pnpm pick-live`; it needs no kernel and no network.
|
||||
const http = require("node:http");
|
||||
const os = require("node:os");
|
||||
const path = require("node:path");
|
||||
const fs = require("node:fs");
|
||||
const { app, BrowserWindow } = require("electron");
|
||||
const { BrowserViews } = require("../src/browserviews");
|
||||
|
||||
// Nothing this test writes may land in the Studio's own profile: a run that
|
||||
// picked a password field must not leave that page cached in anyone's session.
|
||||
app.setPath("userData", fs.mkdtempSync(path.join(os.tmpdir(), "rx-pick-live-")));
|
||||
|
||||
const wait = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
const fails = [];
|
||||
const check = (name, ok, got) => {
|
||||
console.log((ok ? "ok " : "FAIL ") + name + (ok ? "" : " — " + JSON.stringify(got)));
|
||||
if (!ok) fails.push(name);
|
||||
};
|
||||
|
||||
const GIF = "data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7";
|
||||
const PAGE = `<!doctype html><html><head><meta charset="utf-8"><title>Pick lab</title>
|
||||
<style>
|
||||
body { margin: 0; font: 16px sans-serif; }
|
||||
#hero { position: absolute; left: 40px; top: 60px; width: 300px; height: 120px; background: #c0392b; color: #fff; }
|
||||
#logo { width: 40px; height: 20px; margin: 10px; }
|
||||
#go { position: absolute; left: 40px; top: 220px; }
|
||||
#pw { position: absolute; left: 40px; top: 270px; }
|
||||
</style></head><body>
|
||||
<div id="hero">Hero <img id="logo" alt="logo" src="${GIF}"></div>
|
||||
<button id="go" class="btn__a1b2c3" data-testid="go-button" aria-label="投放" onclick="window.__clicked = true">Go</button>
|
||||
<label>Password <input id="pw" type="password" value="secret-value"></label>
|
||||
<script>window.__enter = false; addEventListener("keydown", (event) => { if (event.key === "Enter") window.__enter = true; });</script>
|
||||
<h1 id="head" style="position:absolute;left:40px;top:340px">A heading long enough to carry its own words and ask for no page context around it</h1>
|
||||
<span id="dot" style="position:absolute;left:40px;top:420px;width:7px;height:7px;background:#2ecc71"></span>
|
||||
<div id="badge" class="badge__x9y8z7" aria-label="状态徽标" style="position:absolute;left:40px;top:470px">ok</div>
|
||||
<em class="em__a1b2c3" style="position:absolute;left:40px;top:520px">hash</em>
|
||||
<iframe id="frame" srcdoc="<p>inside</p>" style="position:absolute;left:40px;top:570px;width:120px;height:60px"></iframe>
|
||||
<div id="shadow" style="position:absolute;left:200px;top:570px;width:80px;height:40px"></div>
|
||||
<script>document.getElementById("shadow").attachShadow({ mode: "open" }).innerHTML = "<span>inside shadow</span>";</script>
|
||||
</body></html>`;
|
||||
|
||||
app.whenReady().then(async () => {
|
||||
const server = http.createServer((_q, s) => {
|
||||
s.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
|
||||
s.end(PAGE);
|
||||
});
|
||||
await new Promise((r) => server.listen(0, "127.0.0.1", r));
|
||||
const origin = `http://127.0.0.1:${server.address().port}/`;
|
||||
|
||||
const win = new BrowserWindow({ width: 1400, height: 1000, show: true, webPreferences: { sandbox: true } });
|
||||
await win.loadURL("data:text/html,<body style='margin:0'><p>studio page</p></body>");
|
||||
const views = new BrowserViews({ win, kernelOrigin: "http://127.0.0.1:1" });
|
||||
let loaded;
|
||||
const ready = new Promise((r) => (loaded = r));
|
||||
const page = views.create({
|
||||
partition: "pick-live",
|
||||
url: origin,
|
||||
onEvent: (method) => method === "Page.loadEventFired" && loaded(),
|
||||
onClosed() {},
|
||||
onPopup() {},
|
||||
onDownload() {},
|
||||
});
|
||||
await page.send("Page.enable");
|
||||
await Promise.race([ready, wait(4000)]);
|
||||
const view = views.entries.get(page.targetId).view;
|
||||
view.webContents.on("console-message", (_event, _level, message) => console.log("page console:", message));
|
||||
views.show(page.targetId, { x: 0, y: 0, width: 1280, height: 900 });
|
||||
await wait(600);
|
||||
// Input only reaches a window the platform considers live; a background
|
||||
// window drops the pressed half of a click while still delivering moves.
|
||||
win.show();
|
||||
win.focus();
|
||||
await wait(300);
|
||||
view.webContents.focus();
|
||||
|
||||
const evalIn = (expression) => page.send("Runtime.evaluate", { expression, returnByValue: true }).then((r) => r.result.value);
|
||||
const at = (id) =>
|
||||
evalIn(`(() => { const b = document.getElementById(${JSON.stringify(id)}).getBoundingClientRect();
|
||||
return { x: b.x + b.width / 2, y: b.y + b.height / 2, width: b.width, height: b.height }; })()`);
|
||||
const atSel = (selector) =>
|
||||
evalIn(`(() => { const b = document.querySelector(${JSON.stringify(selector)}).getBoundingClientRect();
|
||||
return { x: b.x + b.width / 2, y: b.y + b.height / 2, width: b.width, height: b.height }; })()`);
|
||||
const move = (point) => page.send("Input.dispatchMouseEvent", { type: "mouseMoved", x: point.x, y: point.y, button: "none", clickCount: 0 });
|
||||
const click = async (point) => {
|
||||
await move(point);
|
||||
// Let the hover settle before the press: a new pick arms a frame later.
|
||||
await wait(80);
|
||||
// A native input event rather than a CDP press: the page half is what this
|
||||
// guards, and the window's own input path is what a person's click takes.
|
||||
const x = Math.round(point.x);
|
||||
const y = Math.round(point.y);
|
||||
view.webContents.sendInputEvent({ type: "mouseDown", x, y, button: "left", clickCount: 1 });
|
||||
view.webContents.sendInputEvent({ type: "mouseUp", x, y, button: "left", clickCount: 1 });
|
||||
};
|
||||
const overlays = () =>
|
||||
evalIn(`[...document.querySelectorAll('[data-reasonix-pick="box"], [data-reasonix-pick="card"]')].map((el) => ({
|
||||
kind: el.dataset.reasonixPick, text: el.innerText, shown: getComputedStyle(el).display !== "none" }))`);
|
||||
|
||||
await evalIn("window.__clicked = false");
|
||||
|
||||
// 1. A person points at the image: the highlight and the card are drawn in
|
||||
// the page, and what comes back is the element and where it sits.
|
||||
const picking = views.pick(page.targetId);
|
||||
await wait(200);
|
||||
const logo = await at("logo");
|
||||
await move(logo);
|
||||
await wait(150);
|
||||
const drawn = await overlays();
|
||||
check("hovering draws the highlight and the card inside the page", drawn.length === 2 && drawn.every((one) => one.shown), drawn);
|
||||
check(
|
||||
"the card names the colour and the font, and adds no background for a transparent one",
|
||||
/Color/.test(drawn[1].text) && /Font/.test(drawn[1].text) && !/Background/.test(drawn[1].text),
|
||||
drawn[1].text,
|
||||
);
|
||||
await click(logo);
|
||||
const picked = await picking;
|
||||
check("a click answers with the element under the pointer", picked.cancelled === false && picked.element.tagName === "img", picked);
|
||||
check("the element comes with a picture of itself", /^data:image\/png/.test(picked.shot || ""), (picked.shot || "").slice(0, 24));
|
||||
check("the selector names it again", picked.element.selector === "#logo", picked.element.selector);
|
||||
check(
|
||||
"the box is the one the page laid out",
|
||||
Math.abs(picked.element.rect.width - logo.width) <= 1 && Math.abs(picked.element.rect.height - logo.height) <= 1,
|
||||
{ rect: picked.element.rect, logo },
|
||||
);
|
||||
check("it carries the page it came from", picked.element.pageUrl === origin, picked.element.pageUrl);
|
||||
check("an element with little text of its own gets its container's", picked.element.nearbyText === "Hero", picked.element.nearbyText);
|
||||
check("the card is put away once the pick settles", (await overlays()).length === 0, await overlays());
|
||||
|
||||
// 2. A pick is not a click for the page: the button's own handler never runs.
|
||||
const buttonPick = views.pick(page.targetId);
|
||||
await wait(200);
|
||||
await click(await at("go"));
|
||||
const button = await buttonPick;
|
||||
check("the page's own click handler does not run", (await evalIn("window.__clicked")) === false, await evalIn("window.__clicked"));
|
||||
check(
|
||||
"a button says what it is and what it is called",
|
||||
button.element.tagName === "button" && button.element.role === "button" && button.element.accessibleName === "投放",
|
||||
button.element,
|
||||
);
|
||||
check("a button with its own word asks for no context", button.element.nearbyText === undefined, button.element.nearbyText);
|
||||
|
||||
// 3. What a person typed into a password field never leaves the page.
|
||||
const passwordPick = views.pick(page.targetId);
|
||||
await wait(200);
|
||||
await click(await at("pw"));
|
||||
const password = await passwordPick;
|
||||
check("a password field answers with a placeholder, not its value", password.element.text === "[masked password input]", password.element.text);
|
||||
check("neither the markup nor the attributes carry what was typed", !JSON.stringify(password.element).includes("secret-value"), password.element.htmlExcerpt);
|
||||
|
||||
// 4. A heading says what it is already: the nearest article around it is the
|
||||
// whole page, and that must not be handed over as context.
|
||||
const headingPick = views.pick(page.targetId);
|
||||
await wait(200);
|
||||
await click(await at("head"));
|
||||
const heading = await headingPick;
|
||||
check("a heading carries no page-wide context", heading.element.nearbyText === undefined, heading.element.nearbyText);
|
||||
|
||||
// 5. A decorative mark has no words and no role: nothing around it makes it
|
||||
// meaningful, so no context comes along either, and the fields that have no
|
||||
// answer are left out instead of sent empty.
|
||||
const dotPick = views.pick(page.targetId);
|
||||
await wait(200);
|
||||
await click(await at("dot"));
|
||||
const dot = await dotPick;
|
||||
check(
|
||||
"a decorative mark carries no context and no empty fields",
|
||||
dot.element.nearbyText === undefined && dot.element.role === undefined && dot.element.text === undefined,
|
||||
dot.element,
|
||||
);
|
||||
|
||||
// 6. The page's own identity wins over its styling: a hash class is gone
|
||||
// after the next deploy, a data-testid is not.
|
||||
const identity = views.pick(page.targetId);
|
||||
await wait(200);
|
||||
await click(await at("go"));
|
||||
const byTestId = await identity;
|
||||
check(
|
||||
"a data-testid is the selector when the page has one",
|
||||
byTestId.element.selector === '[data-testid="go-button"]' && byTestId.element.selectorBasis === "testid",
|
||||
byTestId.element,
|
||||
);
|
||||
check("the hash class behind it is not used", !byTestId.element.selector.includes("btn__a1b2c3"), byTestId.element.selector);
|
||||
|
||||
const labelled = views.pick(page.targetId);
|
||||
await wait(200);
|
||||
await click(await at("badge"));
|
||||
const byLabel = await labelled;
|
||||
check(
|
||||
"an aria-label is the next choice",
|
||||
byLabel.element.selector === 'div[aria-label="状态徽标"]' && byLabel.element.selectorBasis === "aria-label",
|
||||
byLabel.element,
|
||||
);
|
||||
|
||||
const styled = views.pick(page.targetId);
|
||||
await wait(200);
|
||||
await click(await atSel(".em__a1b2c3"));
|
||||
const byClass = await styled;
|
||||
check(
|
||||
"with neither, the styling class is used and labelled as such",
|
||||
byClass.element.selectorBasis === "class" && byClass.element.selector.includes("em__a1b2c3"),
|
||||
byClass.element,
|
||||
);
|
||||
check(
|
||||
"and it still finds the element",
|
||||
(await evalIn(`document.querySelector(${JSON.stringify(byClass.element.selector)})?.tagName`)) === "EM",
|
||||
byClass.element.selector,
|
||||
);
|
||||
|
||||
// 7. Stepping up with the arrow key: what the card shows and what comes back
|
||||
// are the same level.
|
||||
const climbing = views.pick(page.targetId);
|
||||
await wait(200);
|
||||
const logoPoint = await at("logo");
|
||||
await move(logoPoint);
|
||||
await wait(100);
|
||||
const boxWidth = () => evalIn(`parseFloat(document.querySelector('[data-reasonix-pick="box"]').style.width)`);
|
||||
check("the pointer starts on the image", (await boxWidth()) === 40, await boxWidth());
|
||||
view.webContents.sendInputEvent({ type: "keyDown", keyCode: "Up" });
|
||||
await wait(120);
|
||||
view.webContents.sendInputEvent({ type: "keyUp", keyCode: "Up" });
|
||||
check("one step up is the hero", (await boxWidth()) === 300, await boxWidth());
|
||||
const chainText = await evalIn(`document.querySelector('[data-reasonix-pick="card"]').innerText`);
|
||||
check("the card shows the chain it climbed", /div#hero/.test(chainText) && !/img/.test(chainText), chainText);
|
||||
// Pressed without moving: a mousemove would put the pointer back on the image.
|
||||
const climbX = Math.round(logoPoint.x);
|
||||
const climbY = Math.round(logoPoint.y);
|
||||
view.webContents.sendInputEvent({ type: "mouseDown", x: climbX, y: climbY, button: "left", clickCount: 1 });
|
||||
view.webContents.sendInputEvent({ type: "mouseUp", x: climbX, y: climbY, button: "left", clickCount: 1 });
|
||||
const climbed = await climbing;
|
||||
check("the answer is the level stepped up to", climbed.element.tagName === "div" && climbed.element.selector === "#hero", climbed.element);
|
||||
|
||||
// 9. A frame or a shadow host is named, not guessed at.
|
||||
const framePick = views.pick(page.targetId);
|
||||
await wait(200);
|
||||
await move(await at("frame"));
|
||||
await wait(100);
|
||||
const frameNote = await evalIn(`document.querySelector('[data-reasonix-pick="card"]').innerText`);
|
||||
check("an embedded frame says why it cannot be picked", /embedded frame/.test(frameNote), frameNote);
|
||||
await click(await at("frame"));
|
||||
await wait(300);
|
||||
const stillArmed = await evalIn(`!!document.querySelector('[data-reasonix-pick="box"]')`);
|
||||
check("clicking the frame leaves the pick armed", stillArmed === true, stillArmed);
|
||||
void views.cancelPick(page.targetId);
|
||||
const frameAnswer = await framePick;
|
||||
check("and the frame is not reported as an element", frameAnswer.cancelled === true, frameAnswer);
|
||||
|
||||
const shadowPick = views.pick(page.targetId);
|
||||
await wait(200);
|
||||
await move(await at("shadow"));
|
||||
await wait(100);
|
||||
const shadowNote = await evalIn(`document.querySelector('[data-reasonix-pick="card"]').innerText`);
|
||||
check("a shadow host says why it cannot be picked", /shadow root/.test(shadowNote), shadowNote);
|
||||
void views.cancelPick(page.targetId);
|
||||
await shadowPick;
|
||||
|
||||
// 10. A second pick on the same page replaces the first instead of stacking.
|
||||
const firstPick = views.pick(page.targetId);
|
||||
await wait(200);
|
||||
const secondPick = views.pick(page.targetId);
|
||||
await wait(300);
|
||||
const draws = await evalIn(
|
||||
`document.querySelectorAll('[data-reasonix-pick="box"]').length + "/" + document.querySelectorAll('[data-reasonix-pick="card"]').length`,
|
||||
);
|
||||
check("a second pick leaves one picker on the page", draws === "1/1", draws);
|
||||
const firstAnswer = await firstPick;
|
||||
check("the first pick answers as cancelled", firstAnswer.cancelled === true, firstAnswer);
|
||||
void views.cancelPick(page.targetId);
|
||||
await secondPick;
|
||||
|
||||
// 11. Shift-click keeps a set: the click that ends it carries them all.
|
||||
const multi = views.pick(page.targetId);
|
||||
await wait(200);
|
||||
const goAt = await at("go");
|
||||
await move(goAt);
|
||||
await wait(100);
|
||||
view.webContents.sendInputEvent({ type: "mouseDown", x: Math.round(goAt.x), y: Math.round(goAt.y), button: "left", clickCount: 1, modifiers: ["shift"] });
|
||||
view.webContents.sendInputEvent({ type: "mouseUp", x: Math.round(goAt.x), y: Math.round(goAt.y), button: "left", clickCount: 1, modifiers: ["shift"] });
|
||||
await wait(150);
|
||||
const marked = await evalIn(`document.querySelectorAll("[data-reasonix-picked]").length`);
|
||||
check("shift-click keeps the element marked", marked === 1, marked);
|
||||
await click(await at("badge"));
|
||||
const many = await multi;
|
||||
check("the set comes back as several elements", many.elements?.length === 2, many.elements?.map((e) => e.tagName));
|
||||
|
||||
// 12. Verifying the selector after the page changed.
|
||||
const still = await views.verify(page.targetId, "#logo");
|
||||
check("a selector that still exists comes back with a fresh picture", still.found === true && /^data:image\/png/.test(still.shot || ""), still);
|
||||
const gone = await views.verify(page.targetId, "#nope");
|
||||
check("a selector that is gone says so", gone.found === false, gone);
|
||||
|
||||
// 8. The keyboard is the picker's too while it is armed: Enter must not press
|
||||
// a focused button, and Escape is the one key that ends the pick — saying so.
|
||||
const escaping = views.pick(page.targetId);
|
||||
await wait(200);
|
||||
await page.send("Input.dispatchKeyEvent", { type: "keyDown", key: "Enter", code: "Enter", windowsVirtualKeyCode: 13, nativeVirtualKeyCode: 13 });
|
||||
await page.send("Input.dispatchKeyEvent", { type: "keyUp", key: "Enter", code: "Enter", windowsVirtualKeyCode: 13, nativeVirtualKeyCode: 13 });
|
||||
check("Enter does not reach the page", (await evalIn("window.__enter")) === false, await evalIn("window.__enter"));
|
||||
await page.send("Input.dispatchKeyEvent", { type: "keyDown", key: "Escape", code: "Escape", windowsVirtualKeyCode: 27, nativeVirtualKeyCode: 27 });
|
||||
const escaped = await escaping;
|
||||
check("Escape cancels the pick, and says why", escaped.cancelled === true && escaped.reason === "escape", escaped);
|
||||
check("and leaves nothing drawn", (await overlays()).length === 0);
|
||||
|
||||
// 7. A page put away under the picker ends the pick instead of swallowing
|
||||
// the person's clicks on a picture they cannot reach.
|
||||
const hiding = views.pick(page.targetId);
|
||||
await wait(200);
|
||||
views.hide();
|
||||
const hidden = await hiding;
|
||||
check("a page put away cancels the pick, and says why", hidden.cancelled === true && hidden.reason === "hidden", hidden);
|
||||
|
||||
views.hide();
|
||||
page.close();
|
||||
server.close();
|
||||
console.log(fails.length ? `${fails.length} failed` : "all checks passed");
|
||||
app.exit(fails.length ? 1 : 0);
|
||||
});
|
||||
@@ -84,7 +84,7 @@ async function run() {
|
||||
placed: typeof window.reasonixHost?.pathForFile(new File(['x'], 'x.txt')),
|
||||
}))()`);
|
||||
|
||||
const verbs = ["answerBrowserLogin", "captureWindow", "closeWindow", "controlBrowserView", "freezeBrowserView", "globalShortcut", "graphics", "hideBrowserView", "isWindowMaximised", "minimiseWindow", "navigateBrowserView", "onBrowserLoadState", "onBrowserLogin", "openExternal", "openFile", "openWorkspace", "pathForFile", "pickFolder", "platform", "processMemory", "revealLogs", "revealPath", "revealWorkspace", "saveBytes", "saveText", "setGlobalShortcut", "setUnreadBadge", "shell", "showBrowserView", "showTextContextMenu", "titleBar", "toggleMaximiseWindow", "trustBrowserCertificate"];
|
||||
const verbs = ["answerBrowserLogin", "captureWindow", "closeWindow", "controlBrowserView", "freezeBrowserView", "globalShortcut", "graphics", "hideBrowserView", "isWindowMaximised", "minimiseWindow", "navigateBrowserView", "onBrowserLoadState", "onBrowserLogin", "openExternal", "openFile", "openWorkspace", "pathForFile", "pickFolder", "platform", "processMemory", "revealLogs", "revealPath", "revealWorkspace", "saveBytes", "saveText", "setGlobalShortcut", "setUnreadBadge", "shell", "showBrowserView", "showTextContextMenu", "syncTheme", "titleBar", "toggleMaximiseWindow", "trustBrowserCertificate"];
|
||||
check("the bridge exposes verbs and nothing else", JSON.stringify(seen.bridge) === JSON.stringify(verbs), seen.bridge);
|
||||
check("the credential never reaches the page", !seen.cookie.includes("reasonix_token"), seen.cookie);
|
||||
check("the renderer has no node of its own", seen.globals.every((t) => t === "undefined"), seen.globals);
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
"use strict";
|
||||
const assert = require("node:assert/strict");
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const { app, BrowserWindow, nativeTheme } = require("electron");
|
||||
const { GROUND } = require("../src/ground");
|
||||
const { loadPrefs, prefsFile } = require("../src/prefs");
|
||||
const { until, wait } = require("./livekit");
|
||||
|
||||
const home = process.env.REASONIX_HOME;
|
||||
if (!home) throw new Error("Use an isolated REASONIX_HOME for the native theme smoke");
|
||||
fs.mkdirSync(home, { recursive: true });
|
||||
if (!fs.existsSync(path.join(home, "config.toml"))) {
|
||||
fs.writeFileSync(path.join(home, "config.toml"), 'default_model = "smoke/smoke-model"\n[desktop]\nwelcomed = true\n[[providers]]\nname = "smoke"\nkind = "openai"\nbase_url = "http://127.0.0.1:9/v1"\nmodels = ["smoke-model"]\ndefault = "smoke-model"\napi_key_env = "SMOKE_API_KEY"\n');
|
||||
fs.writeFileSync(path.join(home, ".env"), "SMOKE_API_KEY=smoke\n");
|
||||
}
|
||||
process.chdir(home);
|
||||
fs.mkdirSync(path.join(home, "electron-profile"), { recursive: true });
|
||||
app.setPath("userData", path.join(home, "electron-profile"));
|
||||
const startups = new Map();
|
||||
app.on("browser-window-created", (_event, win) => {
|
||||
// Native options settle after creation; load-start still precedes page paint.
|
||||
win.webContents.once("did-start-loading", () => {
|
||||
startups.set(win, { source: nativeTheme.themeSource, background: win.getBackgroundColor(), saved: loadPrefs(prefsFile(app.getPath("userData"))) });
|
||||
});
|
||||
});
|
||||
const { current } = require("../src/main");
|
||||
let untrusted;
|
||||
|
||||
async function run() {
|
||||
const win = await until("Studio window", () => current().win && !current().win.webContents.isLoading() && current().win);
|
||||
const js = source => win.webContents.executeJavaScript(source);
|
||||
await until("real Studio workbench", () => js('!!document.querySelector(".chrome") && !document.getElementById("boot")'));
|
||||
const saved = () => loadPrefs(prefsFile(app.getPath("userData")));
|
||||
async function matches(scheme, source = scheme) {
|
||||
await until(`native ${scheme}/${source} repaint`, async () => nativeTheme.themeSource === source &&
|
||||
win.getBackgroundColor().toLowerCase().endsWith(GROUND[scheme].slice(1)) &&
|
||||
await js(`document.documentElement.dataset.theme === ${JSON.stringify(scheme)}`));
|
||||
console.log(JSON.stringify({ page: scheme, source: nativeTheme.themeSource, background: win.getBackgroundColor() }));
|
||||
}
|
||||
if (process.env.REASONIX_THEME_RESTART === "1") {
|
||||
const startup = startups.get(win);
|
||||
assert.equal(startup.saved["rx-theme"], "scheduled");
|
||||
assert.equal(startup.source, startup.saved["rx-theme-resolved"]);
|
||||
assert.ok(startup.background.toLowerCase().endsWith(GROUND[startup.source].slice(1)), JSON.stringify(startup));
|
||||
const clock = new Date();
|
||||
const actual = clock.getHours() >= 20 || clock.getHours() < 7 ? "dark" : "light";
|
||||
assert.notEqual(actual, startup.source);
|
||||
await matches(actual);
|
||||
assert.equal(await js('localStorage.getItem("rx-theme")'), "scheduled");
|
||||
console.log("ok restart: cached native startup tint, then current local-clock page/native repaint on a new kernel origin");
|
||||
return;
|
||||
}
|
||||
|
||||
nativeTheme.themeSource = "system";
|
||||
const systemDark = nativeTheme.shouldUseDarkColors;
|
||||
console.log(`host OS dark=${systemDark}; platform=${process.platform}`);
|
||||
await js(`(() => {
|
||||
const RealDate = Date;
|
||||
window.__themeClock = { at: RealDate.now(), began: performance.now() };
|
||||
window.Date = class extends RealDate {
|
||||
constructor(...args) { super(...(args.length ? args : [window.__themeClock.at + performance.now() - window.__themeClock.began])); }
|
||||
static now() { return window.__themeClock.at + performance.now() - window.__themeClock.began; }
|
||||
};
|
||||
})()`);
|
||||
async function clock(hour, minute = 0, second = 0) {
|
||||
const at = new Date(2026, 9, 29, hour, minute, second).getTime();
|
||||
await js(`window.__themeClock = { at: ${at}, began: performance.now() }; window.dispatchEvent(new Event("focus"))`);
|
||||
}
|
||||
await js('document.querySelector(\'[data-action="chrome.settings"]\').click()');
|
||||
await until("settings navigation", () => js('!!document.querySelector(\'[data-action="settings.section"][data-value="appearance"]\')'));
|
||||
await js('document.querySelector(\'[data-action="settings.section"][data-value="appearance"]\').click()');
|
||||
await until("appearance modes", () => js('!!document.querySelector(\'[data-action="appearance.scheme"][data-value="scheduled"]\')'));
|
||||
const mode = value => js(`document.querySelector('[data-action="appearance.scheme"][data-value="${value}"]').click()`);
|
||||
async function change(field, value) {
|
||||
await js(`(() => {
|
||||
const input = document.querySelector('[data-action="appearance.schedule"][data-target="${field}"]');
|
||||
Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value").set.call(input, ${JSON.stringify(value)});
|
||||
input.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
input.dispatchEvent(new Event("change", { bubbles: true }));
|
||||
})()`);
|
||||
}
|
||||
await clock(19, 59, 57);
|
||||
await mode("scheduled");
|
||||
await matches("light");
|
||||
await matches("dark");
|
||||
console.log("ok real boundary timeout changes both nativeTheme.themeSource and BrowserWindow background");
|
||||
await clock(6, 59, 58);
|
||||
await matches("dark");
|
||||
await matches("light");
|
||||
await clock(21);
|
||||
await matches("dark");
|
||||
await change("start", "22:00");
|
||||
await matches("light");
|
||||
await change("start", "20:00");
|
||||
await matches("dark");
|
||||
await change("end", "21:00");
|
||||
await matches("light");
|
||||
await change("end", "07:00");
|
||||
await matches("dark");
|
||||
await js('document.querySelector(\'[data-action="theme.activate"][data-value="official-clear-blue"]\').click()');
|
||||
await until("active theme pack", () => js('document.documentElement.dataset.pack === "official-clear-blue"'));
|
||||
await matches("dark");
|
||||
await js('document.querySelector(".appearance-advanced").open = true; document.querySelector(\'[data-action="appearance.contrast"][data-value="strong"]\').click()');
|
||||
await matches("dark");
|
||||
await clock(8);
|
||||
await matches("light");
|
||||
assert.equal(await js('document.documentElement.dataset.pack'), "official-clear-blue");
|
||||
assert.equal(await js('document.documentElement.dataset.contrast'), "strong");
|
||||
console.log("ok start/end edits, focus resume, active pack and contrast keep the native scheme aligned");
|
||||
|
||||
await clock(systemDark ? 8 : 21);
|
||||
await matches(systemDark ? "light" : "dark");
|
||||
await mode("auto");
|
||||
await until("Auto native system following", () => nativeTheme.themeSource === "system");
|
||||
const systemScheme = systemDark ? "dark" : "light";
|
||||
await matches(systemScheme, "system");
|
||||
await js('localStorage.setItem("rx-theme", "scheduled"); localStorage.setItem("rx-weight", "heavy"); window.dispatchEvent(new Event("pagehide"))');
|
||||
assert.equal(saved()["rx-theme"], "scheduled");
|
||||
await matches(systemScheme, "system");
|
||||
await js('localStorage.setItem("rx-theme", "auto")');
|
||||
console.log("ok simulated stale prefs:save cannot override the latest Auto paint or pin system following");
|
||||
await clock(8);
|
||||
await mode("scheduled");
|
||||
await matches("light");
|
||||
await mode("dark");
|
||||
await matches("dark");
|
||||
await js('window.reasonixHost.syncTheme("scheduled", "auto"); window.reasonixHost.syncTheme("light", "dark"); window.reasonixHost.syncTheme("invalid", "light")');
|
||||
await wait(200);
|
||||
await matches("dark");
|
||||
untrusted = new BrowserWindow({ show: false, webPreferences: { preload: path.join(__dirname, "../src/preload.js"), sandbox: true, contextIsolation: true, nodeIntegration: false, partition: "theme-smoke-untrusted" } });
|
||||
await untrusted.loadURL("data:text/html,<title>Untrusted theme reporter</title>");
|
||||
await untrusted.webContents.executeJavaScript('window.reasonixHost.syncTheme("scheduled", "light")');
|
||||
await wait(200);
|
||||
await matches("dark");
|
||||
untrusted.destroy(); untrusted = null;
|
||||
await js('const iframe = document.createElement("iframe"); iframe.id = "theme-subframe"; iframe.srcdoc = "<p>Untrusted subframe</p>"; document.body.append(iframe)');
|
||||
await until("isolated subframe", () => js('!!document.getElementById("theme-subframe").contentDocument?.querySelector("p")'));
|
||||
assert.equal(await js('typeof document.getElementById("theme-subframe").contentWindow.reasonixHost'), "undefined");
|
||||
await js('document.getElementById("theme-subframe").remove()');
|
||||
console.log("ok real untrusted renderer and invalid payloads cannot repaint native chrome; subframes have no bridge");
|
||||
|
||||
await mode("scheduled");
|
||||
const real = new Date();
|
||||
const restartScheme = real.getHours() >= 20 || real.getHours() < 7 ? "light" : "dark";
|
||||
await clock(restartScheme === "dark" ? 21 : 8);
|
||||
await matches(restartScheme);
|
||||
await js('window.dispatchEvent(new Event("pagehide"))');
|
||||
assert.equal(saved()["rx-theme"], "scheduled");
|
||||
assert.equal(saved()["rx-theme-resolved"], restartScheme);
|
||||
const out = process.env.REASONIX_SCREENSHOTS;
|
||||
if (out) {
|
||||
fs.mkdirSync(out, { recursive: true });
|
||||
await js('document.getElementById("set-mode").scrollIntoView({block:"start"})');
|
||||
await wait(350);
|
||||
fs.writeFileSync(path.join(out, "native-scheduled.png"), (await win.webContents.capturePage()).toPNG());
|
||||
}
|
||||
console.log("ok saved opposite-clock native tint; run again with REASONIX_THEME_RESTART=1 to check a complete restart");
|
||||
console.log("note Windows border rendering and changing the host OS preference are not exercised on this host");
|
||||
}
|
||||
|
||||
let exitCode = 0;
|
||||
app.once("will-quit", () => app.exit(exitCode));
|
||||
run().then(() => app.quit()).catch(error => {
|
||||
untrusted?.destroy();
|
||||
console.error(error.stack || error);
|
||||
exitCode = 1;
|
||||
app.quit();
|
||||
});
|
||||
@@ -1152,6 +1152,30 @@ test("only the Studio window reads or writes the preferences", () => {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test("only the Studio main frame reports a validated resolved theme", () => {
|
||||
const handlers = {};
|
||||
const studio = { mainFrame: {} };
|
||||
const browser = { mainFrame: {} };
|
||||
const paints = [];
|
||||
registerPrefs({ on: (name, fn) => (handlers[name] = fn) }, () => "", event => event.sender === studio,
|
||||
() => {}, paint => paints.push(paint));
|
||||
const send = (sender, senderFrame, paint) => handlers["prefs:theme"]({ sender, senderFrame }, paint);
|
||||
send(browser, browser.mainFrame, { mode: "scheduled", scheme: "dark" });
|
||||
send(studio, {}, { mode: "scheduled", scheme: "dark" });
|
||||
send(studio, null, { mode: "scheduled", scheme: "dark" });
|
||||
for (const paint of [null, [], "dark", { mode: "scheduled" }, { mode: "scheduled", scheme: "auto" },
|
||||
{ mode: "other", scheme: "dark" }, { mode: "light", scheme: "dark" }, { mode: "dark", scheme: "light" }]) {
|
||||
send(studio, studio.mainFrame, paint);
|
||||
}
|
||||
assert.deepEqual(paints, []);
|
||||
for (const mode of ["scheduled", "auto", "dark"]) send(studio, studio.mainFrame, { mode, scheme: "dark", extra: "discarded" });
|
||||
send(studio, studio.mainFrame, { mode: "light", scheme: "light" });
|
||||
assert.deepEqual(paints, [
|
||||
{ mode: "scheduled", scheme: "dark" }, { mode: "auto", scheme: "dark" },
|
||||
{ mode: "dark", scheme: "dark" }, { mode: "light", scheme: "light" },
|
||||
]);
|
||||
});
|
||||
|
||||
function elf64(interpreter, machine = 62) {
|
||||
const phoff = 64, phentsize = 56, phnum = interpreter ? 2 : 1;
|
||||
const dataOffset = phoff + phnum * phentsize;
|
||||
@@ -2183,6 +2207,31 @@ test("the window frame takes the stored theme, and auto follows the system", ()
|
||||
assert.equal(themeSourceFor(undefined), "system");
|
||||
});
|
||||
|
||||
test("scheduled grounds and frames follow the painted scheme rather than the opposite OS", () => {
|
||||
const prefs = { "rx-theme": "scheduled" };
|
||||
for (const [scheme, systemDark] of [["dark", false], ["light", true]]) {
|
||||
const painted = { mode: "scheduled", scheme };
|
||||
assert.equal(themeSourceFor(prefs, painted), scheme);
|
||||
assert.equal(groundFor(prefs, systemDark, painted), GROUND[scheme]);
|
||||
const saved = { ...prefs, "rx-theme-resolved": scheme };
|
||||
assert.equal(themeSourceFor(saved), scheme);
|
||||
assert.equal(groundFor(saved, systemDark), GROUND[scheme]);
|
||||
}
|
||||
assert.equal(themeSourceFor({ ...prefs, "rx-theme-resolved": "invalid" }), "system");
|
||||
});
|
||||
|
||||
test("the latest painted mode wins over delayed preference saves without pinning Auto", () => {
|
||||
const stale = { "rx-theme": "scheduled", "rx-theme-resolved": "dark" };
|
||||
const auto = { mode: "auto", scheme: "dark" };
|
||||
assert.equal(themeSourceFor(stale, auto), "system");
|
||||
assert.equal(groundFor(stale, false, auto), GROUND.light);
|
||||
assert.equal(groundFor(stale, true, auto), GROUND.dark);
|
||||
const light = { mode: "light", scheme: "light" };
|
||||
assert.equal(themeSourceFor(stale, light), "light");
|
||||
assert.equal(groundFor(stale, true, light), GROUND.light);
|
||||
assert.equal(themeSourceFor({ "rx-theme": "light" }, { mode: "scheduled", scheme: "dark" }), "dark");
|
||||
});
|
||||
|
||||
const { accelerationOff, shouldDisableGpu, graphicsReport, graphicsHandler, PREF_KEY: GPU_PREF } = require("../src/graphics.js");
|
||||
|
||||
test("hardware acceleration is off only when the saved preference says exactly that", () => {
|
||||
@@ -3756,3 +3805,119 @@ test("only the app's own window is answered, and nothing is read from the asker"
|
||||
assert.deepEqual(processMemoryHandler(app, () => null)({}), []);
|
||||
assert.deepEqual(processMemoryHandler({ getAppMetrics: () => { throw new Error("x"); } }, () => true)({}), []);
|
||||
});
|
||||
|
||||
test("a pick arms the page, answers what it reports, and a page that cannot answer is cancelled", async () => {
|
||||
const Module = require("node:module");
|
||||
const load = Module._load;
|
||||
Module._load = function (request, ...rest) {
|
||||
if (request === "electron") return { WebContentsView: class {}, session: { fromPartition: () => ({}) } };
|
||||
return load.call(this, request, ...rest);
|
||||
};
|
||||
let BrowserViews;
|
||||
try {
|
||||
({ BrowserViews } = require("../src/browserviews.js"));
|
||||
} finally {
|
||||
Module._load = load;
|
||||
}
|
||||
const win = {
|
||||
on() {},
|
||||
isDestroyed: () => false,
|
||||
isFocused: () => false,
|
||||
webContents: Object.assign(new EventEmitter(), { isFocused: () => false }),
|
||||
getContentBounds: () => ({ width: 1200, height: 800 }),
|
||||
contentView: { addChildView() {}, removeChildView() {} },
|
||||
};
|
||||
const views = new BrowserViews({ win, kernelOrigin: "http://127.0.0.1:1" });
|
||||
const element = { tagName: "img", selector: "main > img", rect: { x: 1, y: 2, width: 3, height: 4 } };
|
||||
const armed = [];
|
||||
const recheck = [];
|
||||
const contents = {
|
||||
isDestroyed: () => false,
|
||||
executeJavaScript: async (code) => {
|
||||
armed.push(code);
|
||||
return { cancelled: false, element };
|
||||
},
|
||||
executeJavaScriptInIsolatedWorld: async (_worldId, scripts) => {
|
||||
recheck.push(scripts[0].code);
|
||||
return "img";
|
||||
},
|
||||
};
|
||||
const shots = [];
|
||||
contents.capturePage = async (at) => {
|
||||
shots.push(at);
|
||||
return { isEmpty: () => true, toDataURL: () => "data:image/png;base64,x" };
|
||||
};
|
||||
views.entries.set("t1", { targetId: "t1", view: { webContents: contents, getBounds: () => ({ width: 200, height: 200 }) } });
|
||||
// Picking only reaches the page that is on screen.
|
||||
views.shown = "t1";
|
||||
|
||||
assert.deepEqual(await views.pick("t1"), { cancelled: false, element });
|
||||
assert.match(armed[0], /__reasonixElementPicker/, "the page is armed with the picker's own script");
|
||||
assert.match(recheck[0], /main > img/, "the shell re-finds the selector in its own world");
|
||||
assert.equal(await views.cancelPick("t1"), true);
|
||||
assert.match(armed[1], /picker\.stop\(\)/, "cancelling disarms the page");
|
||||
assert.deepEqual(await views.pick("gone"), { cancelled: true, reason: "gone" }, "a page that is not there picks nothing");
|
||||
assert.equal(await views.cancelPick("gone"), false);
|
||||
|
||||
// A page that navigated out from under the pick, and one that answered
|
||||
// something that is not an element, both say why rather than "nothing".
|
||||
contents.executeJavaScript = async () => {
|
||||
throw new Error("the page went away");
|
||||
};
|
||||
assert.deepEqual(await views.pick("t1"), { cancelled: true, reason: "navigation" });
|
||||
contents.executeJavaScript = async () => "not an answer";
|
||||
assert.deepEqual(await views.pick("t1"), { cancelled: true, reason: "gone" });
|
||||
|
||||
// The page wrote the answer: the shell keeps known fields, clips them, and
|
||||
// treats a shape it cannot read as gone.
|
||||
contents.executeJavaScript = async () => ({
|
||||
cancelled: false,
|
||||
element: {
|
||||
tagName: "img",
|
||||
selector: "main > img",
|
||||
text: "x".repeat(10_000),
|
||||
extra: "drop me",
|
||||
rect: { x: "nope", y: 2, width: 3, height: 4 },
|
||||
attributes: { id: "a", bad: 5 },
|
||||
},
|
||||
});
|
||||
contents.executeJavaScriptInIsolatedWorld = async () => "img";
|
||||
const bounded = await views.pick("t1");
|
||||
assert.equal(bounded.element.text.length, 4000, "an oversized string is clipped");
|
||||
assert.equal("extra" in bounded.element, false, "an unknown field is dropped");
|
||||
assert.equal("rect" in bounded.element, false, "a rect that is not numbers is dropped");
|
||||
assert.deepEqual(bounded.element.attributes, { id: "a" }, "a non-string attribute is dropped");
|
||||
contents.executeJavaScript = async () => ({
|
||||
cancelled: false,
|
||||
element: { tagName: "img", selector: "main > img", attributes: { ["k".repeat(600)]: "v" } },
|
||||
});
|
||||
const longKey = await views.pick("t1");
|
||||
assert.equal(Object.keys(longKey.element.attributes)[0].length, 500, "an oversized attribute key is clipped like its value");
|
||||
contents.executeJavaScript = async () => ({ cancelled: false, element: { tagName: "img" } });
|
||||
assert.deepEqual(await views.pick("t1"), { cancelled: true, reason: "gone" }, "an element without a selector is gone");
|
||||
|
||||
// A label with a quote and a newline still produces a script that parses.
|
||||
contents.executeJavaScript = async (code) => {
|
||||
armed.push(code);
|
||||
return { cancelled: true, reason: "escape" };
|
||||
};
|
||||
await views.pick("t1", { color: 'a"b\nc' });
|
||||
assert.doesNotThrow(() => new Function(armed[armed.length - 1]), "the label is embedded as JSON");
|
||||
|
||||
// A rect the page wrote is clamped to the view or dropped entirely.
|
||||
for (const [bad, why] of [
|
||||
[{ x: -1, y: 0, width: 10, height: 10 }, "a negative origin is kept and clamped"],
|
||||
[{ x: NaN, y: 0, width: 10, height: 10 }, "a NaN rect is dropped"],
|
||||
[{ x: Infinity, y: 0, width: 10, height: 10 }, "an infinite rect is dropped"],
|
||||
]) {
|
||||
contents.executeJavaScript = async () => ({ cancelled: false, element: { tagName: "img", selector: "main > img", rect: bad } });
|
||||
const picked = await views.pick("t1");
|
||||
if (Number.isFinite(bad.x) && bad.x < 0) {
|
||||
assert.equal(picked.element.rect.x, -1, why);
|
||||
assert.deepEqual(shots[shots.length - 1], { x: 0, y: 0, width: 10, height: 10 }, "the origin is clamped to the view");
|
||||
} else {
|
||||
assert.equal("rect" in picked.element, false, why);
|
||||
assert.equal("shot" in picked, false, "no picture is taken for a rect that is not numbers");
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -16,6 +16,17 @@ reviewed: 2026-09-22
|
||||
- An unsent draft takes precedence while the page is open.
|
||||
- No feedback body or screenshots are persisted by this preference.
|
||||
|
||||
## Pane workspace metadata
|
||||
|
||||
- Each pane owns one workspace-metadata read after its initial status settles.
|
||||
Chrome and the composer's workspace chip consume that same result.
|
||||
- A failed initial status still allows the read using the runtime's known root;
|
||||
history loads independently of both status and workspace metadata.
|
||||
- Changing the pane's port or directory reads again and discards superseded
|
||||
responses. Focusing an already-open pane reuses its own result.
|
||||
- Opening the workspace chooser still refreshes its branches and worktree
|
||||
sources; it does not issue another workspace-metadata read.
|
||||
|
||||
## Tests
|
||||
|
||||
`pnpm test` runs one worker per test file on the threads pool. Threads rather
|
||||
|
||||
@@ -39,10 +39,12 @@ node perf/rail.mjs # 左栏会话树:2×8 / 10×50 / 20×200 / 40×500
|
||||
node perf/profile.mjs # CPU 采样,按自耗时列热点(流式那一段)
|
||||
node perf/switchprof.mjs # CPU 采样,量点开一份长会话的那一下
|
||||
node perf/panes.mjs # 并行窗格的代价:两个会话同时流式
|
||||
node perf/rail-toggle-panes.mjs # Rail-fold frame cost with 1/3/6 panes; existing-pane scroll retention and background-output follow
|
||||
node perf/verify.mjs # 行为验证:跟随、块的装卸、切页往返、大会话树
|
||||
node perf/panels.mjs # 两侧栏:收起补间、拖动改宽、上下限、窄屏退场
|
||||
node perf/look.mjs # 外观验证:字号、界面缩放、自定义字体、壁纸上传与调节(含焦点)
|
||||
node perf/measure.mjs # Custom conversation percentage, preset retention, narrow composer controls and populated phone layout
|
||||
node perf/theme-schedule.mjs # Scheduled appearance: daily boundaries, midnight, reload/resume, modes, active packs and DST; THEME_SCHEDULE_SHOTS writes wide/phone light/dark screenshots
|
||||
node perf/pick.mjs # 补全菜单:/ 和 @ 的选中态,深浅两色下各走一遍
|
||||
node perf/models.mjs # 连接面板:网关报出一百多个模型时,这一屏还能不能用
|
||||
node perf/side.mjs # 右栏:端点自己写的一句话有多长,都不该改变栏宽
|
||||
@@ -84,9 +86,18 @@ node perf/crumb.mjs # 标题栏里会话标题紧跟项目名,长名字也不
|
||||
node perf/hoverradius.mjs # 输入框底行的按钮悬停时圆角和底色一致(浅/深)
|
||||
node perf/jump-ends.mjs # 回到顶部/跳到最新按钮一次点击直达两端(中间有展开块也一样)
|
||||
node perf/sessionrows.mjs # 会话行样式:34px、1px 紧凑间距、单行省略;本机/远程、悬浮/焦点、浅/深、窄/宽
|
||||
node perf/recent-sessions.mjs # Recent: global history paging, finished-session discovery and project location (light/dark × wide/390px)
|
||||
node perf/start.mjs # 经工作台标签/边缘把手打开工作台先出入口页(文件/改动/浏览器),不替人开浏览器;标题栏 Browser 按钮仍直达浏览器(浅/深 × 宽/窄)
|
||||
```
|
||||
|
||||
`theme-schedule.mjs` is a manual smoke, not a default guard. For real native
|
||||
effects, build the normal Studio page and host, then run `pnpm theme-live` from
|
||||
`desktop/electron` with an isolated `REASONIX_HOME`.
|
||||
|
||||
Run it again with the same home and `REASONIX_THEME_RESTART=1` to check restart
|
||||
tint and current-clock reconciliation. It reads actual `nativeTheme.themeSource`
|
||||
and `BrowserWindow` background effects, including rejected IPC reporters.
|
||||
|
||||
`lang.mjs` 从仓库外跑时用 `PERF_SRC` 指向 `src/`(它要读固件源码来决定
|
||||
哪些中文不必翻)。`PERF_URL` 可以指向别的地址;`bench.mjs` 和 `scale.mjs` 另收 `PERF_SCALES`、
|
||||
`PERF_DELTAS`,`rail.mjs` 收 `PERF_TREE`(`区数x每区会话数`,逗号分隔),
|
||||
@@ -100,6 +111,13 @@ node perf/start.mjs # 经工作台标签/边缘把手打开工作台先出
|
||||
1440 → 960 → 640 → 420 → 1440px at each zoom.
|
||||
- The native-scrollbar case requires a space-consuming gutter, not an exact
|
||||
pixel width; overlay-scrollbar browsers explicitly skip that case.
|
||||
- `?panes=N` seeds the mock hub's shared runtime/session book. Tree rows and open
|
||||
panes must agree on both transcript path and runtime ID.
|
||||
- `rail-toggle-panes.mjs` selects two existing sessions by path, verifies that the
|
||||
original transcript DOM node is hidden and restored, and checks scroll retention
|
||||
while new output arrives in the background.
|
||||
- The same guard checks that a neighbouring pane following the latest output
|
||||
continues to do so. `RAIL_TOGGLE_SHOTS` saves its before/hidden/returned views.
|
||||
|
||||
## 判据
|
||||
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
import { chromium } from "playwright";
|
||||
import { mkdirSync } from "node:fs";
|
||||
|
||||
const url = process.env.PERF_URL ?? "http://localhost:4399/perf.html";
|
||||
const shots = process.env.SHOTS ?? "/tmp/reasonix-14759-visibility";
|
||||
mkdirSync(shots, { recursive: true });
|
||||
const browser = await chromium.launch();
|
||||
const page = await browser.newPage({ locale: "zh-CN" });
|
||||
const errors = [];
|
||||
page.on("pageerror", (error) => errors.push(error.message));
|
||||
const box = page.locator(".compose textarea");
|
||||
function check(ok, message) {
|
||||
if (!ok) throw new Error(message);
|
||||
}
|
||||
async function stable() {
|
||||
await box.waitFor();
|
||||
await page.addStyleTag({ content: "*, *::before, *::after { transition: none !important; animation: none !important; }" });
|
||||
await page.evaluate(() => document.fonts.ready);
|
||||
}
|
||||
async function hint(on, shot) {
|
||||
const settings = page.locator('[data-action="chrome.settings"]');
|
||||
const menu = page.locator("button.studio-menu");
|
||||
if (await menu.isVisible() && await menu.getAttribute("aria-pressed") !== "true") await menu.click();
|
||||
await settings.click();
|
||||
await page.locator('.prefs-sheet input[data-action-keydown="settings.search"]').fill("placeholder");
|
||||
await page.locator('[role="option"][data-target="composer-hint"]').click();
|
||||
const section = page.locator('[data-setting="composer-hint"]');
|
||||
await section.locator(`[data-value="${on ? "on" : "off"}"]`).click();
|
||||
check(await section.locator(`[data-value="${on ? "on" : "off"}"]`).getAttribute("aria-pressed") === "true", "Setting must reflect the selected visibility");
|
||||
check(await section.locator('[data-apply="immediate"]').count() === 1, "Visibility must be immediate");
|
||||
if (shot) {
|
||||
await section.evaluate((el) => el.scrollIntoView({ block: "center" }));
|
||||
await page.screenshot({ path: `${shots}/${shot}.png` });
|
||||
}
|
||||
await page.locator('button[data-action="settings.close"]').click();
|
||||
}
|
||||
async function visible(on) {
|
||||
await page.waitForFunction((on) => Boolean(document.querySelector(".compose textarea")?.placeholder) === on, on);
|
||||
check(Boolean(await box.getAttribute("aria-label")), "Hidden hint must retain the accessible input label");
|
||||
check(Boolean(await box.getAttribute("aria-describedby")), "Input instructions must remain associated");
|
||||
}
|
||||
try {
|
||||
for (const width of [1440, 390]) {
|
||||
await page.setViewportSize({ width, height: 900 });
|
||||
for (const theme of ["light", "dark"]) {
|
||||
await page.goto(url, { waitUntil: "networkidle" });
|
||||
await stable();
|
||||
if (await page.locator("html").getAttribute("data-theme") !== theme) await page.locator('[data-action="appearance.theme"]').click();
|
||||
await visible(true);
|
||||
check(await box.evaluate((el) => getComputedStyle(el, "::placeholder").opacity) === "1", "Visible guidance must retain full theme opacity");
|
||||
await page.screenshot({ path: `${shots}/${theme}-${width}-on.png` });
|
||||
await hint(false, `${theme}-${width}-settings-off`);
|
||||
await visible(false);
|
||||
await page.screenshot({ path: `${shots}/${theme}-${width}-off.png` });
|
||||
await page.reload({ waitUntil: "networkidle" });
|
||||
await stable();
|
||||
await visible(false);
|
||||
await page.screenshot({ path: `${shots}/${theme}-${width}-off-reloaded.png` });
|
||||
await box.fill("preserved draft");
|
||||
const ink = await box.evaluate((el) => ({ color: getComputedStyle(el).color, opacity: getComputedStyle(el).opacity }));
|
||||
check(ink.opacity === "1", "Entered text must retain full opacity");
|
||||
await hint(true);
|
||||
await visible(true);
|
||||
check(await box.inputValue() === "preserved draft", "Changing hint must not alter the draft");
|
||||
check(await box.evaluate((el) => getComputedStyle(el).color) === ink.color, "Changing hint must not alter entered ink");
|
||||
await box.fill("");
|
||||
await page.reload({ waitUntil: "networkidle" });
|
||||
await stable();
|
||||
await visible(true);
|
||||
console.log(`PASS: ${theme}/${width} default on, Appearance off/on, draft and reload persistence`);
|
||||
}
|
||||
}
|
||||
await page.setViewportSize({ width: 1440, height: 900 });
|
||||
await page.reload({ waitUntil: "networkidle" });
|
||||
await stable();
|
||||
await hint(false);
|
||||
await box.fill("visibility smoke input");
|
||||
check(await box.evaluate((el) => document.activeElement === el), "Typing must focus the composer");
|
||||
await box.evaluate((el) => el.dispatchEvent(new CompositionEvent("compositionstart", { bubbles: true })));
|
||||
await box.press("Enter");
|
||||
check(await page.evaluate(() => !window.__sent?.length), "IME Enter must not submit");
|
||||
await box.evaluate((el) => el.dispatchEvent(new CompositionEvent("compositionend", { bubbles: true })));
|
||||
await page.waitForTimeout(120);
|
||||
await box.press("Enter");
|
||||
await page.waitForFunction(() => window.__sent?.includes("visibility smoke input"));
|
||||
await page.goto(`${url}?queue=3&running=1`, { waitUntil: "networkidle" });
|
||||
await stable();
|
||||
await page.locator(".queue .qi").first().waitFor();
|
||||
await visible(false);
|
||||
check(await page.locator(".queue .qi").count() === 3, "Hiding hint must retain queued content");
|
||||
await hint(true);
|
||||
await visible(true);
|
||||
check(await page.locator(".queue .qi").count() === 3, "Showing hint must retain queued content");
|
||||
await page.screenshot({ path: `${shots}/queue-hint-on.png` });
|
||||
check(errors.length === 0, errors.join("\n"));
|
||||
console.log(`PASS: accessible label, entered ink, focus, IME, submit and queue; screenshots: ${shots}`);
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
@@ -103,6 +103,7 @@ function benchPack(): ThemePack | null {
|
||||
}
|
||||
|
||||
class BenchPort extends MockPort {
|
||||
private driverRunning: boolean | undefined;
|
||||
opensFiles() { return query.has("deliveries") || super.opensFiles(); }
|
||||
revealsFiles() { return query.has("deliveries") || super.revealsFiles(); }
|
||||
async account() {
|
||||
@@ -344,6 +345,8 @@ class BenchPort extends MockPort {
|
||||
}
|
||||
|
||||
feed(ev: WireEvent) {
|
||||
if (ev.kind === "turn_started") this.driverRunning = true;
|
||||
if (ev.kind === "turn_done") this.driverRunning = false;
|
||||
// The driver's frames are the fixture's own frames: a fed approval has to
|
||||
// move /status the way a scripted one does, or a guard reads a card sitting
|
||||
// over a status that still says the turn is running.
|
||||
@@ -421,6 +424,7 @@ class BenchPort extends MockPort {
|
||||
? { turnsUsed: 4, tokensUsed: 128_400, requestsUsed: 9, workDurationMs: 12 * 60_000, noProgressTurns: usage === "blocked" ? 2 : 0, ...(usage === "blocked" ? { stopCause: "goal_stuck", lastReason: "测试一直没有通过,改动没有让失败的数量减少" } : {}) }
|
||||
: undefined;
|
||||
const st = { ...(await super.status()), ...(query.has("running") ? { running: true } : {}), ...(query.has("goal") ? { goal: query.get("goal") ?? "" } : {}), ...(usage ? { goalStatus: usage, goalRuntime } : {}), ...(query.has("fullaccess") ? { toolApprovalMode: "yolo" as const } : {}), ...(query.has("effort") ? { effort: query.get("effort") ?? "" } : {}), ...(query.has("modelref") ? { modelRef: query.get("modelref") ?? "" } : {}), ...(query.has("trustpending") ? { toolApprovalMode: "ask" as const, approvalDefault: { defaulted: true, writesConfined: true, trust: "" as const, trustable: true } } : {}) };
|
||||
st.running = this.driverRunning ?? st.running;
|
||||
if (STATUS_MS > 0) await new Promise((r) => setTimeout(r, STATUS_MS));
|
||||
if (!JOBS) return st;
|
||||
const jobs = Array.from({ length: JOBS }, (_, i) => ({
|
||||
@@ -509,11 +513,14 @@ const TREE_AT = performance.now() + Number(query.get("treems") ?? 0);
|
||||
// ?panes=0 opens the window with no conversation open; ?panes=N opens N.
|
||||
// ?tree=none answers every tree read with no folders.
|
||||
const PANES = query.has("panes") ? Number(query.get("panes")) : 1;
|
||||
const NO_PANES = PANES === 0;
|
||||
|
||||
class BenchHub extends MockHub {
|
||||
readonly feeds = new Map<string, BenchPort>();
|
||||
|
||||
constructor() {
|
||||
super(PANES);
|
||||
}
|
||||
|
||||
// ?search=1 answers every query with two conversations that say it.
|
||||
async searchHistory(q: string) {
|
||||
if (!query.has("search")) return super.searchHistory(q);
|
||||
@@ -524,12 +531,6 @@ class BenchHub extends MockHub {
|
||||
}
|
||||
|
||||
private readonly settingsPort = new BenchPort();
|
||||
private opened = false;
|
||||
|
||||
async open(req: Parameters<MockHub["open"]>[0]) {
|
||||
this.opened = true;
|
||||
return super.open(req);
|
||||
}
|
||||
|
||||
machinePort(): AgentPort {
|
||||
return this.settingsPort;
|
||||
@@ -541,20 +542,6 @@ class BenchHub extends MockHub {
|
||||
return port;
|
||||
}
|
||||
|
||||
runtimes(): Promise<RuntimeView[]> {
|
||||
if (NO_PANES && !this.opened) return Promise.resolve([]);
|
||||
if (PANES <= 1) return super.runtimes();
|
||||
return Promise.resolve(
|
||||
Array.from({ length: PANES }, (_, i) => ({
|
||||
id: `r${i + 1}`,
|
||||
base: i === 0 ? "" : `/rt/r${i + 1}`,
|
||||
root: "~/projects/DeepSeek-Reasonix",
|
||||
name: "DeepSeek-Reasonix",
|
||||
sessionPath: `/sessions/pane-${i + 1}.jsonl`,
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
async moveSessionTo(path: string, target: string) {
|
||||
(window.__moves ??= []).push({ path, target });
|
||||
return super.moveSessionTo(path, target);
|
||||
@@ -574,16 +561,11 @@ class BenchHub extends MockHub {
|
||||
return new Promise((resolve) => setTimeout(() => resolve(answer), archived ? slow : 0));
|
||||
}
|
||||
const wait = TREE_AT - performance.now();
|
||||
if (wait > 0) return new Promise((resolve) => setTimeout(() => resolve(this.tree()), wait));
|
||||
if (wait > 0) return new Promise((resolve) => setTimeout(() => resolve(this.tree(window)), wait));
|
||||
if (query.get("tree") === "none") return Promise.resolve([]);
|
||||
if (!WORKSPACES && query.has("trustpending") && PANES > 1) {
|
||||
return super.tree().then((all) => all.map((ws, i) => i > 0 ? ws : { ...ws, sessions: Array.from({ length: PANES }, (_, k) => ({
|
||||
path: `/sessions/pane-${k + 1}.jsonl`, name: `pane-${k + 1}`, title: `并行会话 ${k + 1}`, turns: 3, runtimeId: `r${k + 1}`,
|
||||
})) }));
|
||||
}
|
||||
if (!WORKSPACES) return super.tree();
|
||||
return Promise.resolve(
|
||||
Array.from({ length: WORKSPACES }, (_, w) => ({
|
||||
if (!WORKSPACES) return super.tree(window);
|
||||
return super.runtimes().then((runtimes) =>
|
||||
this.recentTree(Array.from({ length: WORKSPACES }, (_, w) => ({
|
||||
root: `~/projects/workspace-${w}`,
|
||||
name: `workspace-${w}`,
|
||||
remembered: true,
|
||||
@@ -593,12 +575,13 @@ class BenchHub extends MockHub {
|
||||
name: `w${w}-s${s}`,
|
||||
title: query.has("rows") ? ROW_TITLES[s % ROW_TITLES.length] : `第 ${s} 次会话,标题是内核起的一句话`,
|
||||
turns: (s % 40) + 1,
|
||||
runtimeId: runtimes.find((runtime) => runtime.sessionPath === `/sessions/w${w}-s${s}.jsonl`)?.id,
|
||||
...(query.has("rows") ? {
|
||||
lastActiveAt: Date.now() - (9 + s) * 86_400_000,
|
||||
versions: s % 2 === 1 ? [{ path: `/sessions/w${w}-s${s}-v1.jsonl`, name: `w${w}-s${s}-v1`, title: ROW_TITLES[s % ROW_TITLES.length], turns: 1 }] : undefined,
|
||||
} : {}),
|
||||
}))),
|
||||
})),
|
||||
})), window),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,7 +15,8 @@ try {
|
||||
page.setDefaultTimeout(8000);
|
||||
page.on("pageerror", (error) => failures.push(error.message));
|
||||
await page.goto(`${BASE}?pref=zh&turns=3`, { waitUntil: "networkidle" });
|
||||
const add = page.locator(".projects-heading .projects-add");
|
||||
const add = page.locator('.projects-heading [data-action="workspace.add"]');
|
||||
const recent = page.locator('.projects-heading [data-action="session.recent"]');
|
||||
const more = page.locator(".projects-heading .wsmore");
|
||||
await add.waitFor();
|
||||
await page.addStyleTag({ content: "*{transition:none!important;animation:none!important}" });
|
||||
@@ -29,11 +30,14 @@ try {
|
||||
const addHover = await paint(add);
|
||||
await more.hover();
|
||||
const moreHover = await paint(more);
|
||||
check(`${scheme}: both header actions visibly highlight on hover`,
|
||||
addHover.fill !== rest.fill && moreHover.fill !== rest.fill);
|
||||
await recent.hover();
|
||||
const recentHover = await paint(recent);
|
||||
check(`${scheme}: all header actions visibly highlight on hover`,
|
||||
addHover.fill !== rest.fill && moreHover.fill !== rest.fill && recentHover.fill !== rest.fill);
|
||||
check(`${scheme}: project header actions have the same hover fill and ink`,
|
||||
addHover.fill === moreHover.fill && addHover.ink === moreHover.ink,
|
||||
JSON.stringify({ add: addHover, more: moreHover }));
|
||||
addHover.fill === moreHover.fill && addHover.ink === moreHover.ink
|
||||
&& recentHover.fill === addHover.fill && recentHover.ink === addHover.ink,
|
||||
JSON.stringify({ add: addHover, more: moreHover, recent: recentHover }));
|
||||
await more.click();
|
||||
const menu = page.getByRole("menu", { name: "项目列表的更多操作", exact: true });
|
||||
await menu.waitFor();
|
||||
@@ -50,15 +54,22 @@ try {
|
||||
focused: button === document.activeElement,
|
||||
outline: getComputedStyle(button).outline,
|
||||
}));
|
||||
await page.keyboard.press("Shift+Tab");
|
||||
const recentFocus = await recent.evaluate((button) => ({
|
||||
focused: button === document.activeElement,
|
||||
outline: getComputedStyle(button).outline,
|
||||
}));
|
||||
await page.keyboard.press("Tab");
|
||||
await page.keyboard.press("Tab");
|
||||
const moreFocus = await more.evaluate((button) => ({
|
||||
focused: button === document.activeElement,
|
||||
outline: getComputedStyle(button).outline,
|
||||
}));
|
||||
check(`${scheme}: both header actions retain visible keyboard focus`,
|
||||
addFocus.focused && moreFocus.focused
|
||||
&& !addFocus.outline.includes("none") && !moreFocus.outline.includes("none"),
|
||||
JSON.stringify({ add: addFocus, more: moreFocus }));
|
||||
check(`${scheme}: all header actions retain visible keyboard focus`,
|
||||
addFocus.focused && moreFocus.focused && recentFocus.focused
|
||||
&& !addFocus.outline.includes("none") && !moreFocus.outline.includes("none")
|
||||
&& !recentFocus.outline.includes("none"),
|
||||
JSON.stringify({ add: addFocus, more: moreFocus, recent: recentFocus }));
|
||||
await more.click();
|
||||
await menu.waitFor();
|
||||
await page.locator(".projects-heading-title").click();
|
||||
@@ -73,6 +84,14 @@ try {
|
||||
await page.mouse.move(900, 500);
|
||||
const after = await paint(more);
|
||||
check(`${scheme}: dismissing the menu restores the resting background`, after.fill === rest.fill);
|
||||
await recent.click();
|
||||
const recentDialog = page.getByRole("dialog", { name: "最近的会话", exact: true });
|
||||
await recentDialog.locator(".recent-session").first().waitFor();
|
||||
check(`${scheme}: Recent opens saved session discovery`, await recentDialog.isVisible());
|
||||
await page.keyboard.press("Escape");
|
||||
await recentDialog.waitFor({ state: "hidden" });
|
||||
check(`${scheme}: closing Recent returns focus to its own header entry`,
|
||||
await recent.evaluate((button) => button === document.activeElement));
|
||||
await page.goto(`${BASE}?pref=zh&ws=20&sess=8&turns=3`, { waitUntil: "networkidle" });
|
||||
await add.waitFor();
|
||||
const headerTop = await add.evaluate((button) => button.getBoundingClientRect().top);
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
// median frame with PERF_GUARD panes stays within FRAME_RATIO_MAX of the one-pane median, a pane that
|
||||
// was hidden comes back at the scroll position it left, and the transcript does not inherit the animated
|
||||
// column widths: with a long transcript the fold costs style recalculation of every element under it.
|
||||
import assert from "node:assert/strict";
|
||||
import { mkdir } from "node:fs/promises";
|
||||
import { chromium } from "playwright";
|
||||
|
||||
const CPU = Number(process.env.PERF_CPU ?? 4);
|
||||
@@ -12,6 +14,8 @@ const COUNTS = (process.env.PERF_N ?? "1,3,6").split(",").map(Number);
|
||||
const GUARD = Number(process.env.PERF_GUARD ?? 3);
|
||||
const FRAME_RATIO_MAX = Number(process.env.PERF_FRAME_RATIO ?? 2.5);
|
||||
const URL = process.env.PERF_URL ?? "http://localhost:4399/perf.html";
|
||||
const SHOTS = process.env.RAIL_TOGGLE_SHOTS;
|
||||
if (SHOTS) await mkdir(SHOTS, { recursive: true });
|
||||
const pct = (a, p) => [...a].sort((x, y) => x - y)[Math.min(a.length - 1, Math.floor(a.length * p))];
|
||||
const browser = await chromium.launch();
|
||||
const rows = [];
|
||||
@@ -62,13 +66,41 @@ for (const n of COUNTS) {
|
||||
}
|
||||
rows.push({ panes: n, frames: frames.length, p50: pct(frames, 0.5).toFixed(1), p95: pct(frames, 0.95).toFixed(1), worst: Math.max(...frames).toFixed(1), over50: frames.filter((f) => f > 50).length });
|
||||
if (n === GUARD) {
|
||||
await page.evaluate(() => { document.querySelector(".pane:not([data-off]) .scroll[data-pane]:not([hidden])").scrollTop = 400; });
|
||||
const first = page.locator('.sessrow[data-target="/sessions/pane-1.jsonl"]');
|
||||
const second = page.locator('.sessrow[data-target="/sessions/pane-2.jsonl"]');
|
||||
assert.equal(await first.getAttribute("data-live"), "", "the first row must identify an existing pane");
|
||||
assert.equal(await second.getAttribute("data-live"), "", "the second row must identify another existing pane");
|
||||
assert.equal(await first.getAttribute("aria-selected"), "true", "the measured pane must be the first session");
|
||||
const scroller = await page.locator('.pane:not([data-off]) .scroll[data-pane="flow"]').elementHandle();
|
||||
assert.ok(scroller);
|
||||
await scroller.evaluate((root) => { root.scrollTop = 400; });
|
||||
await page.waitForTimeout(200);
|
||||
await page.locator(".sessrow").nth(1).click();
|
||||
await page.waitForTimeout(500);
|
||||
await page.locator(".sessrow").nth(0).click();
|
||||
await page.waitForTimeout(500);
|
||||
kept = await page.evaluate(() => document.querySelector(".pane:not([data-off]) .scroll[data-pane]:not([hidden])").scrollTop);
|
||||
assert.equal(await scroller.evaluate((root) => root.scrollTop), 400, "the initial reading position must be reachable");
|
||||
if (SHOTS) await page.screenshot({ path: `${SHOTS}/before-hidden.png` });
|
||||
await second.click();
|
||||
await page.waitForFunction(() => document.querySelector('.sessrow[data-target="/sessions/pane-2.jsonl"]')?.getAttribute("aria-selected") === "true");
|
||||
assert.ok(await scroller.evaluate((root) => root.closest(".pane").hasAttribute("data-off")), "the original pane must actually be hidden");
|
||||
assert.equal(await page.locator(".pane").count(), n, "switching existing conversations must not open a new pane");
|
||||
await page.evaluate(() => {
|
||||
window.__feed({ kind: "turn_started" });
|
||||
window.__feed({ kind: "text", text: "Background scroll preservation probe." });
|
||||
window.__feed({ kind: "message" });
|
||||
window.__feed({ kind: "turn_done" });
|
||||
});
|
||||
await page.waitForFunction(() => document.querySelector(".pane:not([data-off]) .flow")?.textContent.includes("Background scroll preservation probe."));
|
||||
if (SHOTS) await page.screenshot({ path: `${SHOTS}/while-hidden.png` });
|
||||
await first.click();
|
||||
await page.waitForFunction(() => document.querySelector('.sessrow[data-target="/sessions/pane-1.jsonl"]')?.getAttribute("aria-selected") === "true");
|
||||
assert.ok(await scroller.evaluate((root) => root === document.querySelector('.pane:not([data-off]) .scroll[data-pane="flow"]')), "scroll-retention precondition: inspect the originally hidden transcript");
|
||||
kept = await scroller.evaluate((root) => root.scrollTop);
|
||||
if (SHOTS) await page.screenshot({ path: `${SHOTS}/after-return.png` });
|
||||
await second.click();
|
||||
await page.waitForFunction(() => {
|
||||
if (document.querySelector('.sessrow[data-target="/sessions/pane-2.jsonl"]')?.getAttribute("aria-selected") !== "true") return false;
|
||||
const root = document.querySelector('.pane:not([data-off]) .scroll[data-pane="flow"]');
|
||||
return root && root.scrollHeight - root.clientHeight - root.scrollTop <= 48;
|
||||
});
|
||||
console.log(`existing pane round trip: scrollTop 400 -> hidden with new output -> ${kept}; following neighbour remains at latest`);
|
||||
}
|
||||
await ctx.close();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { mkdir } from "node:fs/promises";
|
||||
import { chromium } from "playwright";
|
||||
|
||||
const base = process.env.PERF_URL ?? "http://localhost:4399/perf.html";
|
||||
const before = process.env.RECENT_BEFORE_URL;
|
||||
const shots = process.env.RECENT_SHOTS;
|
||||
if (shots) await mkdir(shots, { recursive: true });
|
||||
const browser = await chromium.launch();
|
||||
const errors = [];
|
||||
async function settle(page, selector) {
|
||||
await page.locator(selector).evaluateAll((elements) => Promise.all(elements.flatMap((element) => {
|
||||
element.getBoundingClientRect();
|
||||
return element.getAnimations().map((animation) => animation.finished.catch(() => {}));
|
||||
})));
|
||||
}
|
||||
async function showRail(page) {
|
||||
if (await page.locator(".rail[inert]").count()) {
|
||||
await page.locator('[data-action="chrome.rail"]:not(.railveil)').filter({ visible: true }).first().click();
|
||||
}
|
||||
await page.locator(".rail:not([inert]) .projects-header").waitFor();
|
||||
await settle(page, ".app, .rail");
|
||||
}
|
||||
try {
|
||||
for (const scheme of ["light", "dark"]) {
|
||||
for (const [size, width] of [["wide", 1440], ["phone", 390]]) {
|
||||
const page = await browser.newPage({ viewport: { width, height: 900 }, colorScheme: scheme, reducedMotion: "reduce" });
|
||||
page.on("pageerror", (error) => errors.push(error.message));
|
||||
const tag = `${scheme}-${size}`;
|
||||
if (before && shots) {
|
||||
await page.goto(`${before}?pref=en&rows=1&ws=2&sess=180&turns=2`, { waitUntil: "networkidle" });
|
||||
await showRail(page);
|
||||
await page.screenshot({ path: `${shots}/before-${tag}.png` });
|
||||
}
|
||||
await page.goto(`${base}?pref=en&rows=1&ws=2&sess=180&turns=2`, { waitUntil: "networkidle" });
|
||||
await showRail(page);
|
||||
if (shots) await page.screenshot({ path: `${shots}/after-heading-${tag}.png` });
|
||||
await page.locator('.wsrow[data-target="~/projects/workspace-1"]').click();
|
||||
await page.locator('.studio-session-segments [data-value="live"]').click();
|
||||
const recent = page.getByRole("button", { name: "Recent sessions", exact: true });
|
||||
const hit = await recent.evaluate((element) => {
|
||||
const r = element.getBoundingClientRect();
|
||||
return r.left >= 0 && r.right <= innerWidth && element.contains(document.elementFromPoint(r.x + r.width / 2, r.y + r.height / 2));
|
||||
});
|
||||
assert.ok(hit, `${tag}: Recent is reachable in the project heading`);
|
||||
await recent.click();
|
||||
const dialog = page.getByRole("dialog", { name: "Recent sessions", exact: true });
|
||||
await dialog.locator(".recent-session").first().waitFor();
|
||||
await settle(page, ".pickveil");
|
||||
assert.equal(await dialog.evaluate((element) => getComputedStyle(element).opacity), "1", "the dialog is actually painted");
|
||||
assert.equal(await dialog.locator(".recent-session").count(), 50);
|
||||
const bounds = await dialog.locator(".pickcard").boundingBox();
|
||||
assert.ok(bounds && bounds.x >= 0 && bounds.x + bounds.width <= width && bounds.y >= 0 && bounds.y + bounds.height <= 900, `${tag}: dialog stays inside the viewport`);
|
||||
if (shots) await page.screenshot({ path: `${shots}/after-recent-${tag}.png` });
|
||||
for (let i = 1; i <= 6; i++) {
|
||||
await dialog.getByRole("button", { name: "Next page", exact: true }).click();
|
||||
await dialog.getByText(`Page ${i + 1}`, { exact: true }).waitFor();
|
||||
await dialog.locator('.picklist[aria-busy="false"] .recent-session').first().waitFor();
|
||||
}
|
||||
assert.equal(await dialog.locator(".recent-session").count(), 50, "history past 300 remains paged rather than hidden");
|
||||
const selected = dialog.locator('.recent-session[data-target="/sessions/w1-s150.jsonl"]');
|
||||
await selected.click();
|
||||
await dialog.waitFor({ state: "detached" });
|
||||
const located = page.locator('.rail .sessrow[data-target="/sessions/w1-s150.jsonl"]');
|
||||
await located.waitFor();
|
||||
await page.waitForFunction(() => document.querySelector('.rail .sessrow[data-target="/sessions/w1-s150.jsonl"]')?.getAttribute("aria-selected") === "true");
|
||||
assert.equal(await page.locator('.studio-session-segments [data-value="all"]').getAttribute("aria-pressed"), "true");
|
||||
assert.equal(await page.locator('.wsrow[data-target="~/projects/workspace-1"]').getAttribute("aria-expanded"), "true");
|
||||
if (size === "wide") {
|
||||
assert.ok(await located.evaluate((element) => element === document.activeElement), `${tag}: selection focuses the session in its project`);
|
||||
} else {
|
||||
assert.equal(await page.locator(".app").getAttribute("data-rail"), "off", "phone navigation reveals the conversation");
|
||||
await showRail(page);
|
||||
assert.ok(await located.isVisible(), "reopening the phone rail reveals the selected session in its expanded project");
|
||||
const selectedBounds = await located.boundingBox();
|
||||
assert.ok(selectedBounds && selectedBounds.x >= 0 && selectedBounds.x + selectedBounds.width <= width, "the selected phone row is on screen, not covered by a closing rail");
|
||||
}
|
||||
if (shots) await page.screenshot({ path: `${shots}/after-located-${tag}.png` });
|
||||
console.log(`${tag}: heading hit target, 50-row global pages beyond 300, viewport containment, finished-session project location`);
|
||||
await page.close();
|
||||
}
|
||||
}
|
||||
const page = await browser.newPage({ viewport: { width: 1440, height: 900 } });
|
||||
await page.goto(`${base}?pref=en&turns=2`, { waitUntil: "networkidle" });
|
||||
await showRail(page);
|
||||
await page.evaluate(() => window.__feed({ kind: "turn_started" }));
|
||||
await page.locator('.studio-session-segments [data-value="live"]').click();
|
||||
await page.locator('.rail .sessrow[data-target="/sessions/mock.jsonl"]').waitFor();
|
||||
await page.evaluate(() => window.__feed({ kind: "turn_done" }));
|
||||
await page.locator('.rail .sessrow[data-target="/sessions/mock.jsonl"]').waitFor({ state: "detached" });
|
||||
await page.getByRole("button", { name: "Recent sessions", exact: true }).click();
|
||||
await page.locator('.recent-session[data-target="/sessions/mock.jsonl"]').click();
|
||||
await page.locator('.rail .sessrow[data-target="/sessions/mock.jsonl"]').waitFor();
|
||||
console.log("running -> finished -> Recent -> existing pane: passed");
|
||||
await page.close();
|
||||
assert.deepEqual(errors, [], "no page errors");
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
@@ -36,8 +36,9 @@ try {
|
||||
<time class="sessmeta sessage">9 天前</time>
|
||||
<span class="sessquick"><button data-action="session.archive">归档</button></span>
|
||||
</div>
|
||||
<div class="sessrow sesscopy">
|
||||
<div class="sessrow sesscopy" tabindex="0">
|
||||
<span class="sesstitle" title="早先版本 · 4 轮"><span>早先版本 · 4 轮</span></span>
|
||||
<button class="wsdel" data-action="session.delete" aria-label="删除该会话"><svg viewBox="0 0 16 16"><path d="M4 4l8 8M12 4l-8 8" /></svg></button>
|
||||
</div>
|
||||
</div>
|
||||
</div>`);
|
||||
@@ -96,7 +97,26 @@ try {
|
||||
assert.ok(contained, "quick actions fit inside the compact row");
|
||||
}
|
||||
}
|
||||
console.log(`${scheme}/${width}: 34px rows, 1px spacing, single-line ellipsis, hover/focus stable`);
|
||||
// An earlier version has no row menu: its × is the only way a pointer deletes it.
|
||||
for (const how of ["hover", "focus"]) {
|
||||
await page.mouse.move(width + 200, 400);
|
||||
if (how === "hover") await page.locator(".sesscopy").hover();
|
||||
else await page.locator(".sesscopy").focus();
|
||||
await page.waitForFunction(() => getComputedStyle(document.querySelector(".sesscopy .wsdel")).opacity === "1", null, { timeout: 1000 }).catch(() => {});
|
||||
const del = await page.evaluate(() => {
|
||||
const row = document.querySelector(".sesscopy");
|
||||
const button = row.querySelector(".wsdel");
|
||||
const box = button.getBoundingClientRect();
|
||||
const edge = row.getBoundingClientRect();
|
||||
return {
|
||||
shown: box.width > 0 && box.height > 0 && getComputedStyle(button).opacity === "1",
|
||||
inside: box.top >= edge.top && box.bottom <= edge.bottom && box.left >= edge.left && box.right <= edge.right,
|
||||
};
|
||||
});
|
||||
assert.ok(del.shown, `${scheme}/${width}/${how}: an earlier version shows its delete button`);
|
||||
assert.ok(del.inside, `${scheme}/${width}/${how}: the delete button sits inside its row`);
|
||||
}
|
||||
console.log(`${scheme}/${width}: 34px rows, 1px spacing, single-line ellipsis, hover/focus stable, an earlier version's × shows`);
|
||||
await page.close();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { mkdir } from "node:fs/promises";
|
||||
import { chromium } from "playwright";
|
||||
|
||||
const BASE = process.env.PERF_URL ?? "http://localhost:4399/perf.html";
|
||||
const SHOTS = process.env.THEME_SCHEDULE_SHOTS;
|
||||
if (SHOTS) await mkdir(SHOTS, { recursive: true });
|
||||
const browser = await chromium.launch();
|
||||
const errors = [];
|
||||
try {
|
||||
for (const width of [1440, 390]) {
|
||||
const page = await browser.newPage({ viewport: { width, height: 1000 }, timezoneId: "America/New_York", reducedMotion: "reduce" });
|
||||
page.on("pageerror", (error) => errors.push(error.message));
|
||||
await page.clock.install({ time: new Date("2026-10-29T19:58:00-04:00") });
|
||||
await page.goto(`${BASE}?pref=en&turns=0`);
|
||||
await page.waitForSelector(".app");
|
||||
await page.keyboard.press("Meta+Comma");
|
||||
await page.getByRole("tab", { name: /Appearance/ }).click();
|
||||
await page.locator('[data-action="appearance.scheme"][data-value="scheduled"]').click();
|
||||
const start = page.getByLabel("Dark mode starts", { exact: true });
|
||||
const end = page.getByLabel("Light mode resumes", { exact: true });
|
||||
assert.equal(await start.inputValue(), "20:00");
|
||||
assert.equal(await end.inputValue(), "07:00");
|
||||
const boxes = await page.locator('#set-mode input[type="time"]').evaluateAll((inputs) => inputs.map((input) => {
|
||||
const box = input.getBoundingClientRect();
|
||||
return { left: box.left, right: box.right, width: box.width };
|
||||
}));
|
||||
assert.ok(boxes.every((box) => box.width > 0 && box.left >= 0 && box.right <= width));
|
||||
await page.clock.pauseAt(new Date("2026-10-29T19:59:59-04:00"));
|
||||
assert.equal(await page.locator("html").getAttribute("data-theme"), "light");
|
||||
assert.equal(await start.evaluate((input) => getComputedStyle(input).colorScheme), "light");
|
||||
if (SHOTS) {
|
||||
await page.locator("#set-mode").scrollIntoViewIfNeeded();
|
||||
await page.screenshot({ path: `${SHOTS}/after-light-${width}.png`, animations: "disabled" });
|
||||
}
|
||||
await page.clock.runFor(1000);
|
||||
assert.equal(await page.locator("html").getAttribute("data-theme"), "dark");
|
||||
assert.equal(await start.evaluate((input) => getComputedStyle(input).colorScheme), "dark");
|
||||
await page.clock.runFor(1000);
|
||||
if (SHOTS) await page.screenshot({ path: `${SHOTS}/after-dark-${width}.png`, animations: "disabled" });
|
||||
await page.clock.fastForward("03:59:58");
|
||||
assert.equal(await page.locator("html").getAttribute("data-theme"), "dark");
|
||||
await page.clock.runFor(1000);
|
||||
assert.equal(await page.locator("html").getAttribute("data-theme"), "dark");
|
||||
await page.clock.fastForward("07:00:00");
|
||||
assert.equal(await page.locator("html").getAttribute("data-theme"), "light");
|
||||
await start.fill("21:30");
|
||||
await end.fill("06:15");
|
||||
await page.clock.resume();
|
||||
await page.reload();
|
||||
await page.waitForSelector(".app");
|
||||
await page.keyboard.press("Meta+Comma");
|
||||
await page.getByRole("tab", { name: /Appearance/ }).click();
|
||||
assert.equal(await start.inputValue(), "21:30");
|
||||
assert.equal(await end.inputValue(), "06:15");
|
||||
assert.equal(await page.locator('[data-value="scheduled"]').getAttribute("aria-pressed"), "true");
|
||||
await page.clock.setSystemTime(new Date("2026-10-30T23:00:00-04:00"));
|
||||
await page.evaluate(() => window.dispatchEvent(new Event("focus")));
|
||||
assert.equal(await page.locator("html").getAttribute("data-theme"), "dark");
|
||||
await page.clock.setSystemTime(new Date("2026-10-31T09:00:00-04:00"));
|
||||
await page.evaluate(() => document.dispatchEvent(new Event("visibilitychange")));
|
||||
assert.equal(await page.locator("html").getAttribute("data-theme"), "light");
|
||||
await page.locator('[data-action="appearance.scheme"][data-value="dark"]').click();
|
||||
assert.equal(await start.count(), 0);
|
||||
await page.emulateMedia({ colorScheme: "light" });
|
||||
assert.equal(await page.locator("html").getAttribute("data-theme"), "dark");
|
||||
await page.locator('[data-action="appearance.scheme"][data-value="auto"]').click();
|
||||
assert.equal(await page.locator("html").getAttribute("data-theme"), "light");
|
||||
await page.emulateMedia({ colorScheme: "dark" });
|
||||
await page.waitForFunction(() => document.documentElement.dataset.theme === "dark");
|
||||
console.log(`ok ${width}px: exact boundaries, midnight, saved controls/reload, focus/visibility resume, explicit/system modes`);
|
||||
await page.close();
|
||||
}
|
||||
const page = await browser.newPage({ viewport: { width: 1440, height: 1000 }, timezoneId: "America/New_York", reducedMotion: "reduce" });
|
||||
page.on("pageerror", (error) => errors.push(error.message));
|
||||
await page.clock.install({ time: new Date("2026-11-01T00:59:00-04:00") });
|
||||
await page.goto(`${BASE}?pref=zh&turns=0&pack=sky`);
|
||||
await page.waitForSelector(".app");
|
||||
await page.keyboard.press("Meta+Comma");
|
||||
await page.getByRole("tab", { name: /外观/ }).click();
|
||||
await page.locator('[data-action="appearance.scheme"][data-value="scheduled"]').click();
|
||||
await page.getByLabel("深色开始时间", { exact: true }).fill("01:30");
|
||||
await page.getByLabel("恢复浅色时间", { exact: true }).fill("01:45");
|
||||
await page.clock.pauseAt(new Date("2026-11-01T01:29:59-04:00"));
|
||||
await page.clock.runFor(1000);
|
||||
assert.equal(await page.locator("html").getAttribute("data-theme"), "dark");
|
||||
assert.equal(await page.locator("html").getAttribute("data-pack"), "bench-sky");
|
||||
assert.equal(await page.evaluate(() => getComputedStyle(document.documentElement).getPropertyValue("--page").trim()), "#080D10");
|
||||
await page.clock.fastForward("15:00");
|
||||
assert.equal(await page.locator("html").getAttribute("data-theme"), "light");
|
||||
await page.clock.fastForward("45:00");
|
||||
assert.equal(await page.locator("html").getAttribute("data-theme"), "dark");
|
||||
await page.clock.fastForward("15:00");
|
||||
assert.equal(await page.locator("html").getAttribute("data-theme"), "light");
|
||||
assert.equal(await page.evaluate(() => getComputedStyle(document.documentElement).getPropertyValue("--page").trim()), "#F2F6F8");
|
||||
await page.getByLabel("深色开始时间", { exact: true }).fill("02:30");
|
||||
await page.getByLabel("恢复浅色时间", { exact: true }).fill("04:00");
|
||||
await page.clock.setSystemTime(new Date("2026-03-08T01:59:59-05:00"));
|
||||
await page.evaluate(() => window.dispatchEvent(new Event("focus")));
|
||||
assert.equal(await page.locator("html").getAttribute("data-theme"), "light");
|
||||
await page.clock.fastForward(1000);
|
||||
assert.equal(await page.locator("html").getAttribute("data-theme"), "dark");
|
||||
await page.clock.fastForward("01:00:00");
|
||||
assert.equal(await page.locator("html").getAttribute("data-theme"), "light");
|
||||
console.log("ok Chinese controls, active pack repaint, skipped and repeated DST hour transitions");
|
||||
await page.close();
|
||||
assert.deepEqual(errors, []);
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
@@ -109,6 +109,7 @@ export const ACTIONS: UIAction[] = [
|
||||
{ id: "appearance.session-quick-actions", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "appearance.new-answer-marker", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "appearance.plan-progress", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "appearance.composer-hint", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "appearance.graphics", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "window.global-shortcut", kind: "shell-native", target: "none", proof: "browser" },
|
||||
{ id: "appearance.session-tabs", kind: "view", target: "none", proof: "interaction" },
|
||||
@@ -125,6 +126,10 @@ export const ACTIONS: UIAction[] = [
|
||||
{ id: "workspace.worktree-toggle", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "workspace.search", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "session.filter", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "session.recent", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "session.recent-environment", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "session.recent-page", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "session.recent-retry", kind: "repeatable", target: "none", proof: "interaction" },
|
||||
{ id: "session.more", kind: "repeatable", target: "none", proof: "interaction" },
|
||||
{ id: "session.selection", kind: "view", target: "optional", proof: "interaction" },
|
||||
{ id: "session.batch-archive", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
|
||||
@@ -149,6 +154,7 @@ export const ACTIONS: UIAction[] = [
|
||||
{ id: "chrome.receipt-line", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "chrome.keep-awake", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "composer.send-shortcut", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "composer.running-message-intent", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "transcript.fold", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "transcript.fold-preset", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "transcript.fold-preview", kind: "view", target: "none", proof: "interaction" },
|
||||
@@ -511,6 +517,7 @@ export const ACTIONS: UIAction[] = [
|
||||
{ id: "versions.notes", kind: "repeatable", target: "none", proof: "interaction" },
|
||||
{ id: "versions.notes-retry", kind: "repeatable", target: "none", proof: "interaction" },
|
||||
{ id: "versions.notes-close", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "versions.full-installer", kind: "repeatable", target: "none", proof: "interaction" },
|
||||
{ id: "versions.notes-lang", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "shell.executor", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
|
||||
{ id: "shell.custom-path", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
|
||||
@@ -562,11 +569,7 @@ export const ACTIONS: UIAction[] = [
|
||||
// different capability from changing its opacity, so the four sliders share
|
||||
// one id and say which parameter on data-value.
|
||||
//
|
||||
// These stay apart on purpose. theme.activate installs a palette pack;
|
||||
// appearance.scheme is light, dark or follow-the-system. wallpaper.change
|
||||
// and wallpaper.remove decide which image there is; appearance.background is
|
||||
// how the image that is already there is drawn. And weight and contrast are
|
||||
// two different things a reader wants, however alike they look in CSS.
|
||||
// A pack supplies colours; the scheme decides when its light or dark set is used.
|
||||
{ id: "appearance.language", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
|
||||
{ id: "appearance.zoom", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
|
||||
{ id: "web-access.backend", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
|
||||
@@ -591,6 +594,7 @@ export const ACTIONS: UIAction[] = [
|
||||
// Kept where the window keeps them: these are this browser's, and no kernel
|
||||
// state moves.
|
||||
{ id: "appearance.scheme", kind: "view", target: "none", proof: "browser" },
|
||||
{ id: "appearance.schedule", kind: "view", target: "entity", proof: "browser" },
|
||||
{ id: "appearance.weight", kind: "view", target: "none", proof: "browser" },
|
||||
{ id: "appearance.contrast", kind: "view", target: "none", proof: "browser" },
|
||||
{ id: "appearance.effects", kind: "view", target: "none", proof: "browser" },
|
||||
|
||||
@@ -790,7 +790,12 @@ export const EN: Record<string, string> = {
|
||||
"内置": "Built in",
|
||||
"第三方": "Third party",
|
||||
"{n} 个已装": "{n} installed",
|
||||
"跟随系统时会随系统主题切换;手动选择后将保持固定。": "Following the system switches with it; once you choose a theme yourself, it stays fixed.",
|
||||
"跟随系统时会随系统主题切换;浅色和深色保持固定;定时按本机时间每天切换。": "Follow the system switches with your OS; Light and Dark stay fixed; Scheduled switches daily using this device's clock.",
|
||||
"定时": "Scheduled",
|
||||
"深色开始时间": "Dark mode starts",
|
||||
"恢复浅色时间": "Light mode resumes",
|
||||
"每天按本机时间切换,可跨午夜;开始时进入深色,结束时恢复浅色。": "Switches daily in local time, including overnight: dark at the start, light at the end.",
|
||||
"开始和结束时间相同时全天使用浅色。": "Equal start and end times keep light mode all day.",
|
||||
|
||||
// ── 连接与来源 ───────────────────────────────────────────────────
|
||||
"添加模型来源": "Add a model source",
|
||||
|
||||
@@ -104,6 +104,10 @@ export const EN_COMPOSER: Record<string, string> = {
|
||||
"停下": "Stop",
|
||||
"发送": "Send",
|
||||
"插话": "Steer",
|
||||
"排队发送": "Queue",
|
||||
"点按排队 · 回车换行": "Tap Queue · Enter for a new line",
|
||||
"{key} 排队 · Enter 换行": "{key} to queue · Enter for a new line",
|
||||
"Enter 排队 · Shift+Enter 换行": "Enter to queue · Shift+Enter for a new line",
|
||||
"正在停止…": "Stopping…",
|
||||
"正在发送…": "Sending…",
|
||||
"描述任务、问题或要改的内容…": "Describe a task, question, or change…",
|
||||
|
||||
@@ -102,6 +102,11 @@ export const EN_SETTINGS: Record<string, string> = {
|
||||
"开启(15 分钟后)": "On (after 15 minutes)",
|
||||
"保持打开": "Keep open",
|
||||
"发送快捷键": "Send shortcut",
|
||||
"运行中发送消息": "Messages sent while running",
|
||||
"选择运行中发送消息的默认方式。仅保存在此设备,不改变已排队的消息。": "Choose the default for messages sent during a run. Saved on this device only; messages already queued are unchanged.",
|
||||
"下个工具边界插话": "Steer at the next tool boundary",
|
||||
"整轮结束后发送": "Send after the whole run",
|
||||
"可在待发送队列中选择“立即发送”来停止当前回复并开始新一轮。暂停、编辑和取回仍然可用。": "Use Send now in the queue to stop the current response and start a new turn. Hold, edit, and take back remain available.",
|
||||
"用于任务输入、待发送消息编辑与历史消息重发。输入法确认候选词时不会发送。": "Applies to task input, editing queued messages, and resending past messages. Confirming an input-method candidate never sends.",
|
||||
"Enter 发送": "Enter to send",
|
||||
"{key} 发送": "{key} to send",
|
||||
@@ -1153,6 +1158,8 @@ export const EN_SETTINGS: Record<string, string> = {
|
||||
"会话中各部分默认展开还是收起。失败的步骤与正在运行的操作可以单独处理;已手动展开或收起的块保持原样。": "Whether each part of a conversation starts open or folded. Failed steps and work still running can be handled on their own; a block you have already opened or closed stays as it is.",
|
||||
"收起时显示计划进度": "Show collapsed plan progress",
|
||||
"在收起的计划卡片下方显示进度条,随已完成步骤更新。默认关闭,仅保存在本机。": "Show a progress bar below the collapsed plan card, updated as steps complete. Off by default; saved only on this machine.",
|
||||
"输入框提示": "Composer hint",
|
||||
"在空白任务输入框中显示任务和排队提示。默认显示,仅保存在本机,不影响输入和发送。": "Show task and queue guidance in the empty composer. On by default; saved only on this machine. Does not affect typing or sending.",
|
||||
"计划进度": "Plan progress",
|
||||
"折叠预设": "Folding preset",
|
||||
"精简": "Compact",
|
||||
|
||||
@@ -6,6 +6,13 @@ export const EN_WINDOW: Record<string, string> = {
|
||||
"打开项目": "Open project",
|
||||
"项目列表的更多操作": "More project list actions",
|
||||
"项目与会话": "Projects and conversations",
|
||||
"最近的会话": "Recent sessions",
|
||||
"按最近活动排列,包含已完成的会话。选择后定位到所属项目。": "Ordered by last activity, including finished sessions. Select one to locate its project.",
|
||||
"搜索最近会话 / 项目": "Search recent sessions or projects",
|
||||
"此运行环境不支持最近会话,请更新 Reasonix": "This environment does not support recent sessions. Update Reasonix.",
|
||||
"正在读取会话…": "Reading sessions…",
|
||||
"最近": "Recent",
|
||||
"第 {n} 页": "Page {n}",
|
||||
"选择本机文件夹": "Choose a local folder",
|
||||
"打开第一个项目": "Open your first project",
|
||||
"展开所有项目": "Expand all projects",
|
||||
|
||||
@@ -53,6 +53,7 @@ export interface HostPort {
|
||||
toggleMaximiseWindow(): void;
|
||||
isWindowMaximised(): Promise<boolean>;
|
||||
closeWindow(): void;
|
||||
syncTheme(mode: string, scheme: "light" | "dark"): void;
|
||||
/** Whether this shell can hand back a picture of its own window. */
|
||||
capturesWindow(): boolean;
|
||||
/** A PNG of this window as drawn now; null when the shell cannot or the
|
||||
@@ -189,6 +190,7 @@ interface ElectronBridge {
|
||||
toggleMaximiseWindow(): Promise<void>;
|
||||
isWindowMaximised(): Promise<boolean>;
|
||||
closeWindow(): Promise<void>;
|
||||
syncTheme?(mode: string, scheme: "light" | "dark"): void;
|
||||
captureWindow?(): Promise<Uint8Array | null>;
|
||||
graphics?(): Promise<GraphicsInfo | null>;
|
||||
globalShortcut?(): Promise<GlobalShortcutInfo | null>;
|
||||
@@ -250,6 +252,9 @@ class ElectronHost implements HostPort {
|
||||
closeWindow() {
|
||||
void this.api.closeWindow();
|
||||
}
|
||||
syncTheme(mode: string, scheme: "light" | "dark") {
|
||||
this.api.syncTheme?.(mode, scheme);
|
||||
}
|
||||
capturesWindow() {
|
||||
return typeof this.api.captureWindow === "function";
|
||||
}
|
||||
@@ -370,6 +375,7 @@ class BrowserHost implements HostPort {
|
||||
captureWindow() {
|
||||
return Promise.resolve(null);
|
||||
}
|
||||
syncTheme() {}
|
||||
showTextContextMenu() {
|
||||
return Promise.resolve(null);
|
||||
}
|
||||
|
||||
@@ -49,6 +49,23 @@ it("keeps local project moves and host-qualified pin/archive calls on their owni
|
||||
} finally { vi.unstubAllGlobals(); }
|
||||
});
|
||||
|
||||
it("carries the same global recent-page query to local and remote tree owners", async () => {
|
||||
const fetch = vi.fn().mockResolvedValue({ ok: true, json: async () => [] });
|
||||
vi.stubGlobal("fetch", fetch);
|
||||
try {
|
||||
const hub = new SseHub();
|
||||
const window = { order: "recent" as const, scope: "active" as const, offset: 350, limit: 50, hideLegacy: true, q: "release plan" };
|
||||
await hub.tree(window);
|
||||
await hub.remoteTree("gpu box", window);
|
||||
const paths = fetch.mock.calls.map(([path]) => new URL(path, "http://localhost"));
|
||||
expect(paths[0].pathname).toBe("/tree");
|
||||
expect(paths[1].pathname).toBe("/remotes/gpu%20box/tree");
|
||||
for (const path of paths) {
|
||||
expect(Object.fromEntries(path.searchParams)).toMatchObject({ order: "recent", scope: "active", offset: "350", limit: "50", hideLegacy: "1", q: "release plan" });
|
||||
}
|
||||
} finally { vi.unstubAllGlobals(); }
|
||||
});
|
||||
|
||||
// The same defect SseHttp was fixed for, still standing in the hub port: asking
|
||||
// for JSON first threw away every answer that was not an envelope, and what
|
||||
// reached the window was "this request did not reach the kernel (HTTP 502)" —
|
||||
|
||||
@@ -111,6 +111,8 @@ export interface TreeWorkspace {
|
||||
countsComplete?: boolean;
|
||||
pendingMetadata?: number;
|
||||
failedMetadata?: number;
|
||||
// Echoed only by kernels that serve globally ordered recent pages.
|
||||
recentOffset?: number;
|
||||
root: string;
|
||||
name: string;
|
||||
workspaceRoots?: string[];
|
||||
@@ -125,8 +127,7 @@ export interface TreeWorkspace {
|
||||
// paging sends neither, and then `sessions` is all there is.
|
||||
total?: number;
|
||||
archived?: number;
|
||||
// How many rows the request's scope and word match that `sessions` does not
|
||||
// carry. Absent on a kernel that predates it, and then only `total` says.
|
||||
// Matching rows after this page/window. Absent on kernels predating paging.
|
||||
remaining?: number;
|
||||
sessions: TreeSession[];
|
||||
}
|
||||
@@ -135,14 +136,18 @@ export function workspaceContains(workspace: TreeWorkspace, root: string | undef
|
||||
return root !== undefined && (workspace.root === root || !!workspace.workspaceRoots?.includes(root));
|
||||
}
|
||||
|
||||
// How much of each folder tree() builds: `limit` for all of them (0 is every
|
||||
// row), `limits` overriding it per folder root.
|
||||
// The sessions a tree request counts, independently of its ordering.
|
||||
export type TreeScope = "" | "active" | "archived";
|
||||
|
||||
// `scope` says which rows the window counts (active ones, only the archived, or
|
||||
// every row) and `q` keeps them to those whose title or name contains it.
|
||||
// Ordinary windows bound each project; recent windows page across all projects.
|
||||
// `q` filters titles/names and project names before either window is selected.
|
||||
export interface TreeWindow {
|
||||
retryMetadata?: boolean;
|
||||
order?: "recent";
|
||||
// Recent pages are global across projects, unlike each project's tree window.
|
||||
offset?: number;
|
||||
hideLegacy?: boolean;
|
||||
// 0 means all rows; `limits` overrides this per project in ordinary windows.
|
||||
limit?: number;
|
||||
limits?: Record<string, number>;
|
||||
scope?: TreeScope;
|
||||
@@ -152,6 +157,9 @@ export interface TreeWindow {
|
||||
export function treeQuery(window: TreeWindow | undefined): string {
|
||||
const q = new URLSearchParams();
|
||||
if (window?.retryMetadata) q.set("retryMetadata", "1");
|
||||
if (window?.order) q.set("order", window.order);
|
||||
if (window?.offset !== undefined) q.set("offset", String(window.offset));
|
||||
if (window?.hideLegacy) q.set("hideLegacy", "1");
|
||||
if (window?.limit !== undefined) q.set("limit", String(window.limit));
|
||||
if (window?.limits && Object.keys(window.limits).length > 0) q.set("limits", JSON.stringify(window.limits));
|
||||
if (window?.scope) q.set("scope", window.scope);
|
||||
@@ -222,7 +230,7 @@ export interface HubPort extends SharePort {
|
||||
openRemote(req: { host: string; workspace?: string; sessionPath?: string; agentComposition?:string }): Promise<RuntimeView>;
|
||||
// The far machine's own workspaces, or null while nothing is open on it:
|
||||
// there is no kernel over there to ask until a pane puts one there.
|
||||
remoteTree(host: string): Promise<TreeWorkspace[] | null>;
|
||||
remoteTree(host: string, window?: TreeWindow): Promise<TreeWorkspace[] | null>;
|
||||
// Erases a conversation on that machine; its kernel decides whether it may.
|
||||
removeRemoteSession(host: string, path: string): Promise<void>;
|
||||
// Folders on the far machine, read over the link alone — no kernel has to be
|
||||
@@ -475,8 +483,8 @@ export class SseHub implements HubPort {
|
||||
await this.post<void>(`/remotes/${encodeURIComponent(host)}/sessions/remove`, { path });
|
||||
}
|
||||
|
||||
async remoteTree(host: string) {
|
||||
const path = `/remotes/${encodeURIComponent(host)}/tree`;
|
||||
async remoteTree(host: string, window?: TreeWindow) {
|
||||
const path = `/remotes/${encodeURIComponent(host)}/tree` + treeQuery(window);
|
||||
const res = await fetch(path, { credentials: "same-origin" });
|
||||
if (!res.ok) await SseHub.fail(path, res);
|
||||
return (await res.json()) as TreeWorkspace[];
|
||||
|
||||
@@ -16,3 +16,16 @@ it("keeps session placement and pin/archive flags in the same mock tree", async
|
||||
expect(archived.map((s) => s.path)).toEqual([second.path, first.path]);
|
||||
expect(archived[0]).toMatchObject({ archived: true, pinned: false });
|
||||
});
|
||||
|
||||
it("retains seeded history while closing and reopening its pane with a fresh identity", async () => {
|
||||
const hub = new MockHub(3);
|
||||
const original = (await hub.runtimes())[1];
|
||||
await hub.close(original.id);
|
||||
const closed = (await hub.tree()).flatMap((workspace) => workspace.sessions).find((session) => session.path === original.sessionPath);
|
||||
expect(closed).toBeDefined();
|
||||
expect(closed?.runtimeId).toBeUndefined();
|
||||
const reopened = await hub.open({ root: original.root, sessionPath: original.sessionPath });
|
||||
expect(reopened.id).not.toBe(original.id);
|
||||
expect((await hub.runtimes()).find((runtime) => runtime.id === reopened.id)?.sessionPath).toBe(original.sessionPath);
|
||||
expect((await hub.tree()).flatMap((workspace) => workspace.sessions).find((session) => session.path === original.sessionPath)?.runtimeId).toBe(reopened.id);
|
||||
});
|
||||
@@ -12,13 +12,24 @@ export class MockHub implements HubPort {
|
||||
private readonly machine = { configured: false };
|
||||
private readonly settings = new MockPort();
|
||||
private readonly remoteSettings = new Map<string, MockPort>();
|
||||
private readonly views: RuntimeView[] = [
|
||||
{ id: "r1", base: "", root: "~/projects/DeepSeek-Reasonix", name: "DeepSeek-Reasonix", sessionPath: "/sessions/mock.jsonl" },
|
||||
];
|
||||
private readonly views: RuntimeView[];
|
||||
private readonly seeded: TreeSession[];
|
||||
private readonly roots = ["~/projects/DeepSeek-Reasonix", "~/projects/my-website"];
|
||||
private seq = 1;
|
||||
private seq: number;
|
||||
private readonly flags = new Map<string, Pick<TreeSession, "pinned" | "archived" | "unread">>();
|
||||
|
||||
constructor(panes = 1) {
|
||||
this.seeded = panes > 1 ? Array.from({ length: panes }, (_, i) => ({
|
||||
path: `/sessions/pane-${i + 1}.jsonl`, name: `pane-${i + 1}`, title: `并行会话 ${i + 1}`, turns: 3,
|
||||
})) : [];
|
||||
const initial = panes === 1 ? [{ path: "/sessions/mock.jsonl" }] : this.seeded;
|
||||
this.views = initial.map((session, i) => ({
|
||||
id: `r${i + 1}`, base: i === 0 ? "" : `/rt/r${i + 1}`, root: this.roots[0],
|
||||
name: "DeepSeek-Reasonix", sessionPath: session.path,
|
||||
}));
|
||||
this.seq = this.views.length;
|
||||
}
|
||||
|
||||
private decorate(tree: TreeWorkspace[], host = ""): TreeWorkspace[] {
|
||||
return tree.map((ws) => ({ ...ws, sessions: ws.sessions.map((session) => ({ ...session,
|
||||
...this.flags.get(JSON.stringify([host, session.path])) })) }));
|
||||
@@ -68,9 +79,9 @@ export class MockHub implements HubPort {
|
||||
|
||||
// The fixture's own far machine: one workspace with a conversation in it, so
|
||||
// the tree under a connected host can be designed without one.
|
||||
remoteTree(host: string) {
|
||||
remoteTree(host: string, window?: TreeWindow) {
|
||||
if (host !== "gpu-box") return Promise.resolve(null);
|
||||
return Promise.resolve<TreeWorkspace[]>(this.decorate([
|
||||
return Promise.resolve<TreeWorkspace[]>(this.recentTree(this.decorate([
|
||||
{
|
||||
root: "/srv/training",
|
||||
name: "training",
|
||||
@@ -80,7 +91,7 @@ export class MockHub implements HubPort {
|
||||
],
|
||||
},
|
||||
{ root: "/srv/scratch", name: "scratch", sessions: [] },
|
||||
], host));
|
||||
], host), window));
|
||||
}
|
||||
|
||||
async openRemote(req: { host: string; workspace?: string; sessionPath?: string; agentComposition?: string }) {
|
||||
@@ -183,25 +194,41 @@ export class MockHub implements HubPort {
|
||||
|
||||
onTreeChanged(_listener: () => void) { return () => {}; }
|
||||
|
||||
tree(_window?: TreeWindow) {
|
||||
tree(window?: TreeWindow) {
|
||||
const open = new Map(this.views.filter((v) => v.sessionPath).map((v) => [v.sessionPath!, v.id]));
|
||||
return Promise.resolve(
|
||||
this.decorate(this.roots.map<TreeWorkspace>((root, i) => ({
|
||||
this.recentTree(this.decorate(this.roots.map<TreeWorkspace>((root, i) => ({
|
||||
root,
|
||||
name: root.split("/").pop() ?? root,
|
||||
open: this.views.some((v) => v.root === root),
|
||||
remembered: true,
|
||||
sessions: this.placed(root,
|
||||
i === 0
|
||||
? [
|
||||
? this.seeded.length ? this.seeded.map((session) => ({ ...session, runtimeId: open.get(session.path) })) : [
|
||||
{ path: "/sessions/mock.jsonl", name: "mock", title: "并行会话演示", turns: 3, runtimeId: open.get("/sessions/mock.jsonl") },
|
||||
{ path: "/sessions/older.jsonl", name: "older", title: "上一次的会话", turns: 12, runtimeId: open.get("/sessions/older.jsonl"), legacy: true },
|
||||
]
|
||||
: [{ path: "/sessions/site.jsonl", name: "site", title: "站点改版", turns: 5, runtimeId: open.get("/sessions/site.jsonl") }]),
|
||||
}))),
|
||||
}))), window),
|
||||
);
|
||||
}
|
||||
|
||||
protected recentTree(tree: TreeWorkspace[], window?: TreeWindow): TreeWorkspace[] {
|
||||
if (window?.order !== "recent") return tree;
|
||||
const q = window.q?.trim().toLowerCase() || "";
|
||||
const rows = tree.flatMap((ws, workspace) => ws.sessions
|
||||
.filter((session) => !session.archived && !(window.hideLegacy && !q && session.legacy) &&
|
||||
(!q || ws.name.toLowerCase().includes(q) || (session.title || session.name).toLowerCase().includes(q)))
|
||||
.map((session) => ({ workspace, session })))
|
||||
.sort((a, b) => (b.session.lastActiveAt ?? 0) - (a.session.lastActiveAt ?? 0) || (a.session.path < b.session.path ? -1 : a.session.path > b.session.path ? 1 : 0));
|
||||
const offset = window.offset ?? 0;
|
||||
const end = window.limit === 0 ? rows.length : offset + (window.limit ?? 50);
|
||||
return tree.map((ws, workspace) => ({ ...ws, recentOffset: offset,
|
||||
remaining: rows.slice(end).filter((row) => row.workspace === workspace).length,
|
||||
sessions: rows.slice(offset, end).filter((row) => row.workspace === workspace).map((row) => row.session),
|
||||
}));
|
||||
}
|
||||
|
||||
addWorkspace(path: string) {
|
||||
if (!this.roots.includes(path)) this.roots.push(path);
|
||||
return Promise.resolve<TreeWorkspace>({ root: path, name: path.split("/").pop() ?? path, remembered: true, sessions: [] });
|
||||
|
||||
@@ -1,8 +1,28 @@
|
||||
// @vitest-environment jsdom
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { liveGraceMinutes, LIVE_GRACE_CHOICES, onLiveGraceChange, onOpenSessionLimitChange, openSessionLimit, setLiveGraceMinutes, setOpenSessionLimit, onPlanProgressChange, setShowsPlanProgress, showsPlanProgress } from "./prefs";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { liveGraceMinutes, LIVE_GRACE_CHOICES, onLiveGraceChange, onOpenSessionLimitChange, openSessionLimit, runningMessageIntent, setLiveGraceMinutes, setOpenSessionLimit, setRunningMessageIntent, onPlanProgressChange, setShowsPlanProgress, showsPlanProgress } from "./prefs";
|
||||
|
||||
beforeEach(() => localStorage.clear());
|
||||
afterEach(() => { vi.restoreAllMocks(); setRunningMessageIntent("steer"); localStorage.clear(); });
|
||||
|
||||
describe("default intent for messages sent during a run", () => {
|
||||
it("defaults to steering and reads only the offered follow-up choice", () => {
|
||||
expect(runningMessageIntent()).toBe("steer");
|
||||
for (const raw of ["", "queued", "invalid"]) {
|
||||
localStorage.setItem("rx-running-message-intent", raw);
|
||||
expect(runningMessageIntent()).toBe("steer");
|
||||
}
|
||||
setRunningMessageIntent("followup");
|
||||
expect(runningMessageIntent()).toBe("followup");
|
||||
expect(localStorage.getItem("rx-running-message-intent")).toBe("followup");
|
||||
});
|
||||
|
||||
it("holds the choice for this window if storage cannot be written", () => {
|
||||
vi.spyOn(Storage.prototype, "setItem").mockImplementation(() => { throw new Error("unavailable"); });
|
||||
setRunningMessageIntent("followup");
|
||||
expect(runningMessageIntent()).toBe("followup");
|
||||
});
|
||||
});
|
||||
|
||||
describe("collapsed plan progress on this machine", () => {
|
||||
it("is off by default and only accepts an explicit on preference", () => {
|
||||
|
||||
@@ -160,6 +160,33 @@ export function setNavRailMode(mode: NavRailMode): void {
|
||||
navRailListeners.forEach((fn) => fn());
|
||||
}
|
||||
|
||||
const COMPOSER_HINT_KEY = "rx-composer-hint";
|
||||
const composerHintListeners = new Set<() => void>();
|
||||
|
||||
export function showsComposerHint(): boolean {
|
||||
try {
|
||||
return localStorage.getItem(COMPOSER_HINT_KEY) !== "off";
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
export function onComposerHintChange(fn: () => void): () => void {
|
||||
composerHintListeners.add(fn);
|
||||
return () => {
|
||||
composerHintListeners.delete(fn);
|
||||
};
|
||||
}
|
||||
|
||||
export function setShowsComposerHint(on: boolean): void {
|
||||
try {
|
||||
localStorage.setItem(COMPOSER_HINT_KEY, on ? "on" : "off");
|
||||
} catch {
|
||||
/* unavailable storage keeps the default */
|
||||
}
|
||||
composerHintListeners.forEach((fn) => fn());
|
||||
}
|
||||
|
||||
const PLAN_PROGRESS_KEY = "rx-plan-progress";
|
||||
const planProgressListeners = new Set<() => void>();
|
||||
|
||||
@@ -515,6 +542,40 @@ export function useSendShortcut(): SendShortcut {
|
||||
return useSyncExternalStore(subscribeSendShortcut, sendShortcut, sendShortcut);
|
||||
}
|
||||
|
||||
export type RunningMessageIntent = "steer" | "followup";
|
||||
const RUNNING_MESSAGE_KEY = "rx-running-message-intent";
|
||||
const runningMessageListeners = new Set<() => void>();
|
||||
let transientRunningMessage: RunningMessageIntent = "steer";
|
||||
let runningMessageStorageFailed = false;
|
||||
|
||||
export function runningMessageIntent(): RunningMessageIntent {
|
||||
if (runningMessageStorageFailed) return transientRunningMessage;
|
||||
try {
|
||||
return localStorage.getItem(RUNNING_MESSAGE_KEY) === "followup" ? "followup" : "steer";
|
||||
} catch {
|
||||
return transientRunningMessage;
|
||||
}
|
||||
}
|
||||
|
||||
export function setRunningMessageIntent(next: RunningMessageIntent): void {
|
||||
transientRunningMessage = next;
|
||||
try { localStorage.setItem(RUNNING_MESSAGE_KEY, next); runningMessageStorageFailed = false; } catch { runningMessageStorageFailed = true; }
|
||||
runningMessageListeners.forEach((fn) => fn());
|
||||
}
|
||||
|
||||
function subscribeRunningMessage(fn: () => void): () => void {
|
||||
runningMessageListeners.add(fn);
|
||||
const storage = (event: StorageEvent) => {
|
||||
if (event.key === RUNNING_MESSAGE_KEY || event.key === null) fn();
|
||||
};
|
||||
window.addEventListener("storage", storage);
|
||||
return () => { runningMessageListeners.delete(fn); window.removeEventListener("storage", storage); };
|
||||
}
|
||||
|
||||
export function useRunningMessageIntent(): RunningMessageIntent {
|
||||
return useSyncExternalStore(subscribeRunningMessage, runningMessageIntent, runningMessageIntent);
|
||||
}
|
||||
|
||||
// The sidebar remains the default session switcher. Tabs are a local display
|
||||
// choice and do not change which sessions stay open in the kernel.
|
||||
const SESSION_TABS_KEY = "rx-show-session-tabs";
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
export interface ThemeSchedule { start: string; end: string }
|
||||
|
||||
const KEY = "rx-theme-schedule";
|
||||
const DEFAULT: ThemeSchedule = { start: "20:00", end: "07:00" };
|
||||
const listeners = new Set<() => void>();
|
||||
let cached = DEFAULT;
|
||||
let lastRaw: string | null | undefined;
|
||||
let volatile: ThemeSchedule | null = null;
|
||||
|
||||
function valid(value: unknown): value is ThemeSchedule {
|
||||
if (!value || typeof value !== "object") return false;
|
||||
const { start, end } = value as ThemeSchedule;
|
||||
return typeof start === "string" && typeof end === "string" &&
|
||||
/^([01]\d|2[0-3]):[0-5]\d$/.test(start) && /^([01]\d|2[0-3]):[0-5]\d$/.test(end);
|
||||
}
|
||||
|
||||
export function themeSchedule(): ThemeSchedule {
|
||||
if (volatile) return volatile;
|
||||
try {
|
||||
const raw = localStorage.getItem(KEY);
|
||||
if (raw !== lastRaw) {
|
||||
lastRaw = raw;
|
||||
cached = DEFAULT;
|
||||
try {
|
||||
const value: unknown = JSON.parse(raw ?? "null");
|
||||
if (valid(value)) cached = { start: value.start, end: value.end };
|
||||
} catch { /* unreadable preferences keep the default */ }
|
||||
}
|
||||
return cached;
|
||||
} catch {
|
||||
return cached;
|
||||
}
|
||||
}
|
||||
|
||||
export function onThemeScheduleChange(fn: () => void): () => void {
|
||||
listeners.add(fn);
|
||||
return () => { listeners.delete(fn); };
|
||||
}
|
||||
|
||||
export function setThemeSchedule(next: ThemeSchedule): void {
|
||||
if (!valid(next)) return;
|
||||
cached = { start: next.start, end: next.end };
|
||||
try {
|
||||
lastRaw = JSON.stringify(cached);
|
||||
localStorage.setItem(KEY, lastRaw);
|
||||
volatile = null;
|
||||
} catch {
|
||||
volatile = cached;
|
||||
}
|
||||
listeners.forEach((fn) => fn());
|
||||
}
|
||||
@@ -1690,6 +1690,7 @@ A sub-agent row, by the list's own width:
|
||||
- Project headings have no separate chevron. Clicking a heading or using the tree arrow keys toggles its conversations; known empty projects do not advertise expansion. The heading toolbar switches all projects together, while search reveals matching conversations independently of saved folds.
|
||||
- The project toolbar keeps collapse/expand beside the label and open-project plus and overflow at the right. The plus opens the common local/SSH location picker; project-row chat icons create conversations. Empty lists retain a text entry.
|
||||
- Each project previews five recent sessions and keeps an older active/opening session visible. Search and explicit scopes show their matches. Show more reveals loaded rows and uses the existing bounded kernel paging path.
|
||||
- Recent stays in the project toolbar rather than adding a fifth session segment. Its modal shows session identity, owning project and activity age. A separate page-status line keeps the three footer buttons on one row at phone widths.
|
||||
- Time metadata comes only from canonical last-active timestamps. Loaded/total session counts remain available to assistive technology without visible badges.
|
||||
- The sidebar defaults to 316px, respects saved widths and retains its existing 232px floor. Folder glyphs, titles, action slots and conversation indents align across environments. Hover actions reserve their space.
|
||||
- The diagonal toolbar glyphs reuse Tabler's MIT-licensed [collapse][tabler-collapse] and [expand][tabler-expand] paths through the existing Studio icon component.
|
||||
|
||||
@@ -59,6 +59,15 @@ and at every contrast step, so nothing paints differently until a pack sets one.
|
||||
- The running compose ring, call lines, pips, carets and the delegated nest keep
|
||||
`net` and `deleg`.
|
||||
|
||||
## Composer guidance
|
||||
|
||||
- Composer guidance keeps its theme-owned `--ghost` ink at full opacity. The
|
||||
Appearance visibility preference suppresses the placeholder text at its
|
||||
choice owner, not by lowering contrast or changing shared neutral inks.
|
||||
- `perf/composer-placeholder.mjs` checks the default-on preference, live
|
||||
visibility changes and reload persistence through Appearance, alongside
|
||||
entered text, focus, IME, submission and queued guidance.
|
||||
|
||||
## Elevation
|
||||
|
||||
| Pack key | Variable | Default | Job |
|
||||
@@ -337,8 +346,9 @@ pack states a shape, never a number the layout has to agree with.
|
||||
colour cannot be added to it.
|
||||
- The neutral ink the roles set on buttons would land on a primary action's
|
||||
fill, where the component pairs `--accent-fg` with `--accent`; an enabled
|
||||
`[data-primary]` button therefore keeps `--accent-fg`, and nothing else is
|
||||
recoloured.
|
||||
`[data-primary]` button therefore keeps `--accent-fg` and carries its
|
||||
`--accent` fill with it, so a container with no fill rule of its own never
|
||||
shows light text on no fill. Nothing else is recoloured.
|
||||
|
||||
## Weight and contrast
|
||||
|
||||
|
||||
@@ -2617,6 +2617,7 @@
|
||||
.prefs-nav::-webkit-scrollbar { display: none }
|
||||
.prefs-nav .navsec, .prefs-nav .nv { display: none }
|
||||
.prefs-nav button, .prefs-nav button.prefs-page { flex: none; min-height: 32px; padding: 6px 10px }
|
||||
.prefs-nav .nm { flex: none; min-width: 0 }
|
||||
.prefs-nav button::before {
|
||||
left: 50%; top: auto; bottom: 0; width: 0; height: 2px;
|
||||
transform: translateX(-50%); transition: width var(--t-card) var(--ease-spring);
|
||||
@@ -2628,7 +2629,7 @@
|
||||
@media (max-width: 560px) {
|
||||
.prefs { padding: 0 }
|
||||
.prefs-sheet { border-inline: 0; border-bottom: 0; border-radius: 0 }
|
||||
.prefs-hd { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 10px; padding: 12px 14px }
|
||||
.prefs-hd { display: grid; grid-template-columns: minmax(0, 1fr) auto auto; gap: 10px; padding: 12px 14px }
|
||||
.prefs-find { grid-column: 1 / -1; grid-row: 2; width: 100%; max-width: none; margin-left: 0 }
|
||||
.prefs-found { right: 14px; width: calc(100% - 28px) }
|
||||
.prefs-main { --main-pad-b: 40px; padding: 20px 14px var(--main-pad-b) }
|
||||
@@ -5027,6 +5028,8 @@
|
||||
}
|
||||
.fontown::placeholder { color: var(--ghost) }
|
||||
.fontown:focus-visible { outline: 2px solid var(--focus); outline-offset: -1px }
|
||||
.fontown[type="time"] { color-scheme: light }
|
||||
:root[data-theme="dark"] .fontown[type="time"] { color-scheme: dark }
|
||||
|
||||
.fontsample {
|
||||
padding: 7px 10px; border-radius: var(--r-sm); background: var(--overlay);
|
||||
|
||||
@@ -6,4 +6,4 @@
|
||||
.prefs-col :is(.v, .n, text, .composition-builtin-description) { font-weight: var(--w-reg); }
|
||||
.prefs-col :is(.cur, div.v) { font-size: var(--fs-display); font-weight: var(--w-semi); }
|
||||
.prefs-col .mi-mono { font-size: var(--fs-meta); }
|
||||
.prefs-col :is(.btn, .act)[data-primary]:not(:disabled) { color: var(--accent-fg); }
|
||||
.prefs-col :is(.btn, .act)[data-primary]:not(:disabled) { color: var(--accent-fg); background: var(--accent); border-color: var(--accent); }
|
||||
@@ -25,4 +25,5 @@ it("only ever names a neutral text colour from the two tiers, bar a primary acti
|
||||
it("lets an enabled primary action keep the foreground its fill is paired with", () => {
|
||||
const rule = css.match(/\.prefs-col :is\(\.btn, \.act\)\[data-primary\]:not\(:disabled\)\s*\{([^}]*)\}/)?.[1] ?? "";
|
||||
expect(rule).toMatch(/color:\s*var\(--accent-fg\)/);
|
||||
expect(rule, "the foreground must travel with its fill, or a container without a fill rule shows light text on nothing").toMatch(/background:\s*var\(--accent\)/);
|
||||
});
|
||||
@@ -1682,6 +1682,7 @@ body { background: var(--page) !important; }
|
||||
.onb-foot { flex: none; padding: 14px 24px 18px; text-align: center; color: var(--ghost); font: 500 calc(10px * var(--fs-scale, 1))/1 var(--ui); }
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.studio-brand-lockup { padding-left: 14px; }
|
||||
.studio-brand-sub { display: none; }
|
||||
.flow, .flow:not([data-empty]) { width: calc(100% - 20px); margin-inline: auto; padding-inline: 0; }
|
||||
.compose { width: calc(100% - 20px); margin-inline: auto; }
|
||||
@@ -1936,7 +1937,6 @@ button, input, select, textarea { font-size: calc(13.3333px * var(--fs-scale, 1)
|
||||
@keyframes studio-session-run { 55%, 100% { transform: translateX(110%); } }
|
||||
@keyframes studio-session-wait { 50% { background: color-mix(in srgb, var(--warn) 8%, transparent); } }
|
||||
@keyframes studio-session-done { from { background: color-mix(in srgb, var(--ok) 18%, transparent); } }
|
||||
.rail .sessrow > .wsdel { display: none; }
|
||||
.rail .session-more {
|
||||
position: absolute; top: 7px; right: 4px;
|
||||
width: 27px;
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
.projects-heading-title { display: flex; align-items: center; gap: 6px; }
|
||||
.projects-add { display: grid; place-items: center; width: 26px; height: 26px; border: 0; border-radius: 6px; padding: 0; background: transparent; color: var(--muted); cursor: pointer; }
|
||||
.projects-add .studio-icon { width: 16px; height: 16px; }
|
||||
.projects-add.projects-recent { width: auto; padding-inline: 6px; font: inherit; }
|
||||
.projects-add:hover:not(:disabled), .projects-heading .wsmore:is(:hover, [aria-expanded="true"]) { background: var(--overlay); color: var(--text); }
|
||||
.projects-add:focus-visible { outline: 2px solid var(--focus); outline-offset: -1px; }
|
||||
.projects-add:disabled { opacity: .4; cursor: default; }
|
||||
@@ -63,3 +64,15 @@
|
||||
@media (hover: none) { .rowbtns { opacity: 1; } }
|
||||
.studio-session-segments button { font-size: calc(12px * var(--fs-scale, 1)); }
|
||||
.studio-session-segments b { font-size: calc(10px * var(--fs-scale, 1)); }
|
||||
.recent-description { margin: 0 0 12px; color: var(--muted); font: var(--w-reg) var(--fs-meta)/1.5 var(--ui); }
|
||||
.recent-machine { display: flex; align-items: center; gap: 8px; margin-bottom: 12px; color: var(--muted); }
|
||||
.recent-machine select { flex: 1; min-width: 0; padding: 5px 8px; color: var(--text); background: var(--surface); border: 1px solid var(--border); border-radius: var(--r-xs); font: var(--w-reg) var(--fs-ui) var(--ui); }
|
||||
.recent-sessions .picklist { min-height: 160px; }
|
||||
.recent-session { min-height: 48px; }
|
||||
.recent-session .recent-session-text { display: flex; flex: 1; flex-direction: column; gap: 3px; }
|
||||
.recent-session-text b, .recent-session-text small { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.recent-session-text b { color: var(--text); font-weight: var(--w-med); }
|
||||
.recent-session-text small { color: var(--muted); font-size: var(--fs-micro); }
|
||||
.recent-session .sessage { flex: none; margin-left: auto; color: var(--muted); }
|
||||
.recent-sessions .pickact { flex-wrap: wrap; }
|
||||
.recent-page { order: -1; flex-basis: 100%; text-align: center; white-space: nowrap; color: var(--muted); font: var(--w-reg) var(--fs-meta)/1.4 var(--ui); }
|
||||
@@ -84,6 +84,7 @@ const Settings = lazy(async () => {
|
||||
|
||||
const NO_REPORT: PaneReport = {
|
||||
status: null,
|
||||
workspace: null,
|
||||
title: "",
|
||||
steer: 0,
|
||||
run: "idle",
|
||||
@@ -561,6 +562,7 @@ export function App({ hub }: { hub: HubPort }) {
|
||||
host={runtimes.find((rt) => rt.id === active)?.host}
|
||||
port={activePort}
|
||||
status={report.status}
|
||||
workspace={report.workspace}
|
||||
title={tabs.find((tab) => tab.rt.id === active)?.title ?? report.title}
|
||||
steer={report.steer}
|
||||
account={account}
|
||||
|
||||
@@ -19,12 +19,14 @@ import { OpenSessionLimitSection } from "./OpenSessionLimitSection";
|
||||
import { LiveStreamBudgetSection } from "./LiveStreamBudgetSection";
|
||||
import { NewAnswerSection } from "./NewAnswerSection";
|
||||
import { PlanProgressSection } from "./PlanProgressSection";
|
||||
import { ComposerHintSection } from "./ComposerHintSection";
|
||||
import { SessionQuickSection } from "./SessionQuickSection";
|
||||
import { NavEntriesSection } from "./NavEntriesSection";
|
||||
import { NavRailSection } from "./NavRailSection";
|
||||
import { GraphicsSection } from "./GraphicsSection";
|
||||
import { GlobalShortcutSection } from "./GlobalShortcutSection";
|
||||
import { SendShortcut } from "./SendShortcut";
|
||||
import { RunningMessageSetting } from "./RunningMessageSetting";
|
||||
import { KeysSheet } from "./KeysSheet";
|
||||
import { SessionTabsSetting } from "./SessionTabsSetting";
|
||||
import { EscStopSetting } from "./EscStopSetting";
|
||||
@@ -34,6 +36,7 @@ import { ThemeRemove } from "./ThemeRemove";
|
||||
import { WallpaperSceneImport } from "./WallpaperSceneImport";
|
||||
import { WallpaperVolumeSlider } from "./WallpaperVolumeSlider";
|
||||
import { useThemeInventory } from "./useThemeInventory";
|
||||
import { ThemeScheduleFields } from "./ThemeScheduleFields";
|
||||
|
||||
// "" follows the machine; the rest are explicit, the same shape the light/dark
|
||||
// control uses.
|
||||
@@ -48,6 +51,7 @@ export const SCHEMES: [string, string][] = [
|
||||
["auto", "跟随系统"],
|
||||
["light", "浅色"],
|
||||
["dark", "深色"],
|
||||
["scheduled", "定时"],
|
||||
];
|
||||
|
||||
interface Props {
|
||||
@@ -233,7 +237,7 @@ export function Appearance({ port, theme, onTheme, contrast, onContrast, weight,
|
||||
<div className="grp-hd">
|
||||
<h3>{t("明暗")}</h3>
|
||||
</div>
|
||||
<p className="hint">{t("跟随系统时会随系统主题切换;手动选择后将保持固定。")}</p>
|
||||
<p className="hint">{t("跟随系统时会随系统主题切换;浅色和深色保持固定;定时按本机时间每天切换。")}</p>
|
||||
<div className="grp-items">
|
||||
<div className="seg" data-text role="group" aria-label={t("明暗")}>
|
||||
{SCHEMES.map(([id, name]) => (
|
||||
@@ -242,6 +246,7 @@ export function Appearance({ port, theme, onTheme, contrast, onContrast, weight,
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{theme === "scheduled" && <ThemeScheduleFields />}
|
||||
</div>
|
||||
<ApplyNote id="mode" />
|
||||
</section>
|
||||
@@ -570,9 +575,11 @@ export function Appearance({ port, theme, onTheme, contrast, onContrast, weight,
|
||||
<NewAnswerSection />
|
||||
<GraphicsSection />
|
||||
<SendShortcut />
|
||||
<ComposerHintSection />
|
||||
<RunningMessageSetting />
|
||||
<EscStopSetting />
|
||||
<GlobalShortcutSection />
|
||||
<KeysSheet />
|
||||
<EscStopSetting />
|
||||
<PageSleepSetting />
|
||||
<SessionTabsSetting />
|
||||
<Folding />
|
||||
|
||||
@@ -28,6 +28,7 @@ interface Props {
|
||||
// controls that need a session simply do nothing until one is focused.
|
||||
port: AgentPort | null;
|
||||
status: SessionStatus | null;
|
||||
workspace: WorkspaceInfo | null;
|
||||
title?: string;
|
||||
steer: number;
|
||||
account: AccountState | null;
|
||||
@@ -44,7 +45,7 @@ interface Props {
|
||||
onError?: (e: unknown) => void;
|
||||
}
|
||||
|
||||
export function Chrome({ port, status, title, steer, account, host, theme, onTheme, onFind, view, rows, surfaces, onView, hub, onError }: Props) {
|
||||
export function Chrome({ port, status, workspace: ws, title, steer, account, host, theme, onTheme, onFind, view, rows, surfaces, onView, hub, onError }: Props) {
|
||||
const root = status?.workspaceRoot || status?.cwd || "";
|
||||
const project = root ? base(root) : "—";
|
||||
// The folder mark opens the project itself. Only a shell that can enter a
|
||||
@@ -56,13 +57,6 @@ export function Chrome({ port, status, title, steer, account, host, theme, onThe
|
||||
const openProject = () => {
|
||||
if (hub && root) void hub.openWorkspace(root).catch((e) => onError?.(e));
|
||||
};
|
||||
const [ws, setWs] = useState<WorkspaceInfo | null>(null);
|
||||
useEffect(() => {
|
||||
let current = true;
|
||||
setWs(null);
|
||||
if (port) port.workspaces().then((value) => { if (current) setWs(value); }).catch(() => { if (current) setWs(null); });
|
||||
return () => { current = false; };
|
||||
}, [port, root]);
|
||||
|
||||
const gathered = useFolded("bar");
|
||||
const [more, setMore] = useState(false);
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
// @vitest-environment jsdom
|
||||
import { cleanup, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import "./testkit";
|
||||
import { Composer } from "./Composer";
|
||||
import { ComposerHintSection } from "./ComposerHintSection";
|
||||
import { MockPort } from "../port/mock";
|
||||
import { boot, STORAGE } from "../i18n";
|
||||
import { onComposerHintChange, setShowsComposerHint, showsComposerHint } from "../state/prefs";
|
||||
|
||||
beforeEach(() => { localStorage.clear(); localStorage.setItem(STORAGE, "zh"); boot(); });
|
||||
afterEach(() => { cleanup(); localStorage.clear(); vi.restoreAllMocks(); });
|
||||
|
||||
function draw(queued = false) {
|
||||
const onSubmit = vi.fn(async () => true);
|
||||
const view = render(<Composer port={new MockPort()} status={null} running={queued} queued={queued} onSubmit={onSubmit} onChanged={vi.fn()} onError={vi.fn()} />);
|
||||
return { ...view, onSubmit, box: screen.getByRole<HTMLTextAreaElement>("combobox", { name: "任务输入" }) };
|
||||
}
|
||||
|
||||
describe("composer hint visibility", () => {
|
||||
it("defaults on, accepts only explicit off, and tolerates unavailable storage", () => {
|
||||
expect(showsComposerHint()).toBe(true);
|
||||
for (const value of ["", "on", "false", "garbage"]) {
|
||||
localStorage.setItem("rx-composer-hint", value);
|
||||
expect(showsComposerHint()).toBe(true);
|
||||
}
|
||||
setShowsComposerHint(false);
|
||||
expect(showsComposerHint()).toBe(false);
|
||||
const get = vi.spyOn(Storage.prototype, "getItem").mockImplementation(() => { throw new Error("blocked"); });
|
||||
const set = vi.spyOn(Storage.prototype, "setItem").mockImplementation(() => { throw new Error("blocked"); });
|
||||
expect(showsComposerHint()).toBe(true);
|
||||
expect(() => setShowsComposerHint(false)).not.toThrow();
|
||||
get.mockRestore();
|
||||
set.mockRestore();
|
||||
});
|
||||
|
||||
it("notifies mounted subscribers and removes them on unsubscribe", () => {
|
||||
const heard = vi.fn();
|
||||
const stop = onComposerHintChange(heard);
|
||||
setShowsComposerHint(false);
|
||||
stop();
|
||||
setShowsComposerHint(true);
|
||||
expect(heard).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it.each([false, true])("applies immediately and persists on remount (queued=%s)", async (queued) => {
|
||||
const settings = render(<ComposerHintSection />);
|
||||
const view = draw(queued);
|
||||
expect(view.box.placeholder.length).toBeGreaterThan(0);
|
||||
await userEvent.click(within(settings.container).getByRole("button", { name: "关闭" }));
|
||||
expect(view.box.placeholder).toBe("");
|
||||
expect(view.box.getAttribute("aria-label")).toBe("任务输入");
|
||||
expect(view.box.getAttribute("aria-describedby")).toBeTruthy();
|
||||
expect(localStorage.getItem("rx-composer-hint")).toBe("off");
|
||||
view.unmount();
|
||||
const remounted = draw(queued);
|
||||
expect(remounted.box.placeholder).toBe("");
|
||||
await userEvent.click(within(settings.container).getByRole("button", { name: "显示" }));
|
||||
await waitFor(() => expect(remounted.box.placeholder.length).toBeGreaterThan(0));
|
||||
expect(localStorage.getItem("rx-composer-hint")).toBe("on");
|
||||
});
|
||||
|
||||
it("keeps entered text, focus, IME confirmation and submission when hidden", async () => {
|
||||
localStorage.setItem("rx-composer-hint", "off");
|
||||
const { box, onSubmit } = draw();
|
||||
await userEvent.type(box, "implement the change");
|
||||
expect(box.value).toBe("implement the change");
|
||||
expect(document.activeElement).toBe(box);
|
||||
fireEvent.compositionStart(box);
|
||||
fireEvent.keyDown(box, { key: "Enter", isComposing: true });
|
||||
expect(onSubmit).not.toHaveBeenCalled();
|
||||
fireEvent.compositionEnd(box);
|
||||
vi.spyOn(Date, "now").mockReturnValue(Date.now() + 120);
|
||||
fireEvent.keyDown(box, { key: "Enter" });
|
||||
await waitFor(() => expect(onSubmit).toHaveBeenCalledWith("implement the change"));
|
||||
});
|
||||
});
|
||||
@@ -1,9 +1,9 @@
|
||||
import { useCallback, useEffect, useId, useRef, useState, type ReactNode } from "react";
|
||||
import { useCallback, useEffect, useId, useRef, useState, useSyncExternalStore, type ReactNode } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { t } from "../i18n";
|
||||
import { reason } from "../i18n/kernel";
|
||||
import { keepPasteAsFile, PasteKeep, type PasteChip } from "./pastekeep";
|
||||
import type { AgentPort, ChipCall, ModelEntry, SessionStatus, WorkspaceGit } from "../port/port";
|
||||
import type { AgentPort, ChipCall, ModelEntry, SessionStatus, WorkspaceGit, WorkspaceInfo } from "../port/port";
|
||||
import { Picker } from "./Menu";
|
||||
import { BranchChip } from "./BranchChip";
|
||||
import { WorktreeChip } from "./WorktreeChip";
|
||||
@@ -18,7 +18,7 @@ import { ChipMirror, useSkillChips } from "./ChipMirror";
|
||||
import { useIme } from "./ime";
|
||||
import { usePlaceholderFit } from "./placeholderfit";
|
||||
import { useFitHeight } from "./fitHeight";
|
||||
import { useSendShortcut } from "../state/prefs";
|
||||
import { onComposerHintChange, showsComposerHint, useRunningMessageIntent, useSendShortcut } from "../state/prefs";
|
||||
import { sendAria, sendHint, sendsOnKey } from "./sendkey";
|
||||
import { countLines, pasteIsLong, planTone, planVerb } from "./intake";
|
||||
import { kindOf, nameOf, previewURL } from "./chipfile";
|
||||
@@ -68,10 +68,10 @@ interface Props {
|
||||
sidePanel?: ReactNode;
|
||||
changeCount?: number;
|
||||
countElsewhere?: boolean;
|
||||
// The work tree's Git state as git itself reports it, refreshed on the same
|
||||
// path as changeCount (turn boundaries, writes). null is "not answered yet";
|
||||
// repo:false is a workspace with no repository, and the two render apart.
|
||||
// Git state follows changeCount's refresh path (turn boundaries and writes).
|
||||
// null is unread; repo:false is a workspace without a repository.
|
||||
git?: WorkspaceGit | null;
|
||||
workspace?: WorkspaceInfo | null;
|
||||
// The switch already answers with the workspace's Git state.
|
||||
onTreeChanged?: (git: WorkspaceGit) => void;
|
||||
// Bumped when settings change; a source edited there can change the ladder.
|
||||
@@ -79,7 +79,7 @@ interface Props {
|
||||
draftKey?: string;
|
||||
}
|
||||
|
||||
export function Composer({ port, status, running, queued = false, quote, restore, inject, focus, onSubmit, onChanged, onError, onSettings = () => {}, side = false, onSide, sidePanel, changeCount = 0, countElsewhere = false, git = null, onTreeChanged, pulse = 0, draftKey = "", fresh = false, worktreeTarget, onWorktreePhase }: Props) {
|
||||
export function Composer({ port, status, running, queued = false, quote, restore, inject, focus, onSubmit, onChanged, onError, onSettings = () => {}, side = false, onSide, sidePanel, changeCount = 0, countElsewhere = false, git = null, workspace = null, onTreeChanged, pulse = 0, draftKey = "", fresh = false, worktreeTarget, onWorktreePhase }: Props) {
|
||||
const touch = touchKeyboard();
|
||||
const providerOrder = useProviderOrder();
|
||||
const modelGroups = useModelFilter();
|
||||
@@ -195,6 +195,7 @@ export function Composer({ port, status, running, queued = false, quote, restore
|
||||
}, chips, addSession);
|
||||
const ime = useIme();
|
||||
const sendShortcut = useSendShortcut();
|
||||
const runningIntent = useRunningMessageIntent();
|
||||
|
||||
// A counter, not a boolean: asking twice in a row has to move the cursor twice.
|
||||
useEffect(() => {
|
||||
@@ -217,7 +218,9 @@ export function Composer({ port, status, running, queued = false, quote, restore
|
||||
pending.current = null;
|
||||
}, []);
|
||||
useFitHeight(box, text, placeCaret);
|
||||
const placeholder = usePlaceholderFit(box, queued ? [t("继续输入以排队后续修改"), t("继续输入…"), "…"] : [t("描述任务、问题或要改的内容…"), t("描述任务…"), "…"]);
|
||||
const showHint = useSyncExternalStore(onComposerHintChange, showsComposerHint, showsComposerHint);
|
||||
const fittedPlaceholder = usePlaceholderFit(box, queued ? [t("继续输入以排队后续修改"), t("继续输入…"), "…"] : [t("描述任务、问题或要改的内容…"), t("描述任务…"), "…"]);
|
||||
const placeholder = showHint ? fittedPlaceholder : "";
|
||||
|
||||
// Attachments ride into the turn as path references, exactly as they do from
|
||||
// the CLI — the host saved the bytes, the turn parser resolves the token. A
|
||||
@@ -637,7 +640,7 @@ export function Composer({ port, status, running, queued = false, quote, restore
|
||||
? t("正在添加附件…")
|
||||
: failed
|
||||
? t("有附件添加失败,请重试或移除")
|
||||
: sendHint(sendShortcut, running, touch)}
|
||||
: sendHint(sendShortcut, running, touch, runningIntent)}
|
||||
</span>
|
||||
{showCount && <span className="fcount">{t("{n} 字 · {lines} 行", { n: text.length, lines })}</span>}
|
||||
</div>
|
||||
@@ -691,7 +694,7 @@ export function Composer({ port, status, running, queued = false, quote, restore
|
||||
label={<><StudioIcon name="agent" /><span className="studio-sr-label">{t("计划")}</span><span>{status?.plan ? "Plan" : "Agent"}</span><StudioIcon name="down" /></>}
|
||||
/>
|
||||
</div>
|
||||
{git?.repo && (worktree.sources || status?.workspaceRoot) ? <WorktreeChip port={port} onChanged={onChanged} onSwitched={onTreeChanged} onError={onError} changeCount={countElsewhere ? 0 : changeCount} sources={worktree.sources ?? { available: false, sourceRoot: status?.workspaceRoot ?? "", sources: [] }} canCreate={fresh && !!worktree.sources?.available} selection={worktree.selection} branch={git.branch} busy={submitting || running} onSelect={worktree.setSelection} refresh={worktree.refresh} /> : <BranchChip port={port} git={git} changeCount={changeCount} countElsewhere={countElsewhere} onChanged={onChanged} onSwitched={onTreeChanged} onError={onError} />}
|
||||
{git?.repo && (worktree.sources || status?.workspaceRoot) ? <WorktreeChip port={port} workspace={workspace} onChanged={onChanged} onSwitched={onTreeChanged} onError={onError} changeCount={countElsewhere ? 0 : changeCount} sources={worktree.sources ?? { available: false, sourceRoot: status?.workspaceRoot ?? "", sources: [] }} canCreate={fresh && !!worktree.sources?.available} selection={worktree.selection} branch={git.branch} busy={submitting || running} onSelect={worktree.setSelection} refresh={worktree.refresh} /> : <BranchChip port={port} git={git} changeCount={changeCount} countElsewhere={countElsewhere} onChanged={onChanged} onSwitched={onTreeChanged} onError={onError} />}
|
||||
{/* The toggle keeps its legacy meaning: it follows `plan`, which the
|
||||
kernel turns off the moment a plan is approved. The lifecycle is a
|
||||
separate reading — an approved plan is still running, and saying so
|
||||
@@ -788,7 +791,7 @@ export function Composer({ port, status, running, queued = false, quote, restore
|
||||
<path d="M8 12.8V3.4M4.2 7.2 8 3.4l3.8 3.8" />
|
||||
</svg>
|
||||
</span>
|
||||
<span>{t(submitting ? "正在发送…" : running ? "插话" : "发送")}</span>
|
||||
<span>{t(submitting ? "正在发送…" : running ? runningIntent === "followup" ? "排队发送" : "插话" : "发送")}</span>
|
||||
</button>}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import { useSyncExternalStore } from "react";
|
||||
import { t } from "../i18n";
|
||||
import { onComposerHintChange, setShowsComposerHint, showsComposerHint } from "../state/prefs";
|
||||
import { Group } from "./Group";
|
||||
|
||||
export function ComposerHintSection() {
|
||||
const on = useSyncExternalStore(onComposerHintChange, showsComposerHint, showsComposerHint);
|
||||
return (
|
||||
<Group id="composer-hint" title={t("输入框提示")} hint={t("在空白任务输入框中显示任务和排队提示。默认显示,仅保存在本机,不影响输入和发送。")}>
|
||||
<div className="seg" data-text role="group" aria-label={t("输入框提示")}>
|
||||
<button data-action="appearance.composer-hint" data-value="off" aria-pressed={!on} onClick={() => setShowsComposerHint(false)}>{t("关闭")}</button>
|
||||
<button data-action="appearance.composer-hint" data-value="on" aria-pressed={on} onClick={() => setShowsComposerHint(true)}>{t("显示")}</button>
|
||||
</div>
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
@@ -53,6 +53,7 @@ import { Find } from "./Find";
|
||||
import { useFind } from "./usefind";
|
||||
import { RunAnalysis } from "./RunAnalysis";
|
||||
import { useBrowserTabs } from "./BrowserPanel";
|
||||
import { useWorkspaceInfo } from "./useWorkspaceInfo";
|
||||
import { useRevealAgentPages, useRevealBrowserOpen } from "./browserreveal";
|
||||
import { WorkbenchPanel } from "./WorkbenchPanel";
|
||||
import { useBrowserFull } from "./browserfull";
|
||||
@@ -102,6 +103,7 @@ function PaneView({ port, hub, rt, title, active, visible, sideHost, side, onFoc
|
||||
const [sessionRead, setSessionRead] = useState<SessionRead>({ kind: "pending" });
|
||||
const status = sessionRead.kind === "settled" ? sessionRead.status : null;
|
||||
const ready = sessionRead.kind === "settled";
|
||||
const workspace = useWorkspaceInfo(port, status?.workspaceRoot || status?.cwd || rt.root, ready);
|
||||
const [tab, showView] = useShowView(rt.id, onManualBrowser);
|
||||
const [sideAsk, setSideAsk] = useState(false);
|
||||
const [worktreeTarget, setWorktreeTarget] = useState<HTMLDivElement | null>(null);
|
||||
@@ -463,6 +465,7 @@ function PaneView({ port, hub, rt, title, active, visible, sideHost, side, onFoc
|
||||
useEffect(() => {
|
||||
onReport(rt.id, {
|
||||
status,
|
||||
workspace,
|
||||
title,
|
||||
steer: waiting(queue).filter((it) => it.intent === "steer").length,
|
||||
run: trustWaiting && run === "idle" ? "halt" : run,
|
||||
@@ -477,7 +480,7 @@ function PaneView({ port, hub, rt, title, active, visible, sideHost, side, onFoc
|
||||
rows: traj.rows.length,
|
||||
surfaces,
|
||||
});
|
||||
}, [rt.id, onReport, status, title, queue, run, running, blocked, trustWaiting, cost, contextPercent, ctx, mcp, walletDisplay, s.interactions, docked, tab, traj.rows.length, surfaces]);
|
||||
}, [rt.id, onReport, status, workspace, title, queue, run, running, blocked, trustWaiting, cost, contextPercent, ctx, mcp, walletDisplay, s.interactions, docked, tab, traj.rows.length, surfaces]);
|
||||
|
||||
return (
|
||||
<section
|
||||
@@ -636,7 +639,7 @@ function PaneView({ port, hub, rt, title, active, visible, sideHost, side, onFoc
|
||||
{alert && <div className="cmpalert">{alert}</div>}
|
||||
<RootMissingBanner items={s.items} onPick={onOpenProject} />
|
||||
{pill && <ChangedFilesPill changes={tree?.changes ?? []} onOpen={openChanges} />}
|
||||
<Composer queued={waiting(queue).length > 0} worktreeTarget={worktreeTarget} onWorktreePhase={setWorktreePhase} port={port} fresh={ready && !historyPaging.hasMore && !historyPaging.busy && !historyPaging.failed && !rt.host && s.items.length === 0} status={status} running={running} quote={quote} restore={restored} inject={inserted} focus={askFocus} onSubmit={submit} onChanged={refreshStatus} onError={notify} onSettings={onSettings} side={sideAsk} onSide={() => setSideAsk((on) => !on)} sidePanel={sideAsk ? <SidePanel port={port} onClose={() => setSideAsk(false)} /> : null} changeCount={changeCount} countElsewhere={pill} git={git} onTreeChanged={refreshTree} pulse={pulse} draftKey={draftKey(rt.host ?? "", rt.root, rt.sessionPath || status?.sessionPath || "")} />
|
||||
<Composer queued={waiting(queue).length > 0} worktreeTarget={worktreeTarget} onWorktreePhase={setWorktreePhase} port={port} workspace={workspace} fresh={ready && !historyPaging.hasMore && !historyPaging.busy && !historyPaging.failed && !rt.host && s.items.length === 0} status={status} running={running} quote={quote} restore={restored} inject={inserted} focus={askFocus} onSubmit={submit} onChanged={refreshStatus} onError={notify} onSettings={onSettings} side={sideAsk} onSide={() => setSideAsk((on) => !on)} sidePanel={sideAsk ? <SidePanel port={port} onClose={() => setSideAsk(false)} /> : null} changeCount={changeCount} countElsewhere={pill} git={git} onTreeChanged={refreshTree} pulse={pulse} draftKey={draftKey(rt.host ?? "", rt.root, rt.sessionPath || status?.sessionPath || "")} />
|
||||
<MeterRail
|
||||
tps={tps} trail={trail} running={running} speed={speed} metrics={s.metrics} ctx={ctx} mcp={mcp} compact={compact} cost={cost} turnCost={s.metrics.coverage === "none" ? NO_COST : s.metrics.turn}
|
||||
wallet={wallet} hideAmounts={hideAmounts} tasks={rail.tasks} jobs={jobs} onSettings={onSettings}
|
||||
|
||||
@@ -7,11 +7,12 @@ import { pinToViewport } from "./place";
|
||||
import { StudioIcon } from "./StudioIcon";
|
||||
import { workspaceMenuKeys } from "./WorkspaceOrder";
|
||||
|
||||
export function ProjectsHeader({ adder, canSelect, selecting, onSelect, onToggleAll, allFolded, canFold, searching }: {
|
||||
export function ProjectsHeader({ adder, canSelect, selecting, onSelect, onRecent, onToggleAll, allFolded, canFold, searching }: {
|
||||
adder: Adder;
|
||||
canSelect: boolean;
|
||||
selecting: boolean;
|
||||
onSelect: () => void;
|
||||
onRecent?: () => void;
|
||||
onToggleAll?: () => void;
|
||||
allFolded?: boolean;
|
||||
canFold?: boolean;
|
||||
@@ -42,6 +43,10 @@ export function ProjectsHeader({ adder, canSelect, selecting, onSelect, onToggle
|
||||
</button>}
|
||||
</span>
|
||||
<span className="projects-heading-actions">
|
||||
{onRecent && <button className="projects-add projects-recent" data-action="session.recent"
|
||||
title={t("最近的会话")} aria-label={t("最近的会话")} onClick={onRecent}>
|
||||
<span>{t("最近")}</span>
|
||||
</button>}
|
||||
<button className="projects-add" data-busy={adder.busy ? "" : undefined} disabled={adder.busy}
|
||||
title={t("打开项目")} aria-label={t("打开项目")} data-action="workspace.add" onClick={() => adder.add()}>
|
||||
<StudioIcon name="plus" />
|
||||
|
||||
@@ -43,6 +43,9 @@ const theirs = (it: QueueItem) => it.origin !== "host" || it.state === "blocked"
|
||||
// Codes whose entry the kernel knows never reached the model, so sending it
|
||||
// again cannot repeat an effect. Every other uncertain code may have run.
|
||||
const RETRY_SAFE = new Set(["steer_unapplied"]);
|
||||
// The kernel edits, reorders and re-freezes only a line nobody has taken yet; a
|
||||
// line already accepted as guidance can only be withdrawn or sent now.
|
||||
const editable = (it: QueueItem) => it.state === "queued" || it.state === "blocked" || it.state === "uncertain";
|
||||
const retryable = (it: QueueItem) =>
|
||||
it.state === "blocked" || (it.state === "uncertain" && !!it.blockCode && RETRY_SAFE.has(it.blockCode));
|
||||
|
||||
@@ -264,7 +267,7 @@ export function Queue({ queue, running, onRead, onEdit, onMove, onCancel, onSend
|
||||
)}
|
||||
{live && (
|
||||
<span className="qacts">
|
||||
{items.length > 1 && <>
|
||||
{items.length > 1 && editable(it) && <>
|
||||
<button className="qmove" data-action="queue.move" data-target={it.id} data-value="up" onClick={() => onMove(it.id, i - 1)} disabled={i === 0} title={t("上移")} aria-label={t("上移")}>
|
||||
<StudioIcon name="arrow" />
|
||||
</button>
|
||||
@@ -272,9 +275,11 @@ export function Queue({ queue, running, onRead, onEdit, onMove, onCancel, onSend
|
||||
<StudioIcon name="arrow" data-flip="" />
|
||||
</button>
|
||||
</>}
|
||||
<button data-action="queue.edit" data-target={it.id} onClick={() => void open(it.id)} title={t("编辑")} aria-label={t("改")}>
|
||||
<StudioIcon name="edit" />
|
||||
</button>
|
||||
{editable(it) && (
|
||||
<button data-action="queue.edit" data-target={it.id} onClick={() => void open(it.id)} title={t("编辑")} aria-label={t("改")}>
|
||||
<StudioIcon name="edit" />
|
||||
</button>
|
||||
)}
|
||||
{/* Waiting is the normal case and needs no button. This is the
|
||||
other one: the turn in front of this line is not worth
|
||||
finishing, and nothing else can end it. */}
|
||||
@@ -295,7 +300,7 @@ export function Queue({ queue, running, onRead, onEdit, onMove, onCancel, onSend
|
||||
</button>
|
||||
)}
|
||||
{/* Only an entry that quoted files has anything to re-freeze. */}
|
||||
{!!it.refs?.length && (
|
||||
{editable(it) && !!it.refs?.length && (
|
||||
<button data-action="queue.refresh" data-target={it.id} onClick={() => onRefresh(it.id)} title={t("重新冻结引用的文件")}>
|
||||
{t("刷新")}
|
||||
</button>
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
import { useEffect, useRef, useState, useSyncExternalStore } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { t } from "../i18n";
|
||||
import { reason } from "../i18n/kernel";
|
||||
import type { HubPort, TreeSession, TreeWorkspace } from "../port/hub";
|
||||
import type { RemoteHost } from "../port/remote";
|
||||
import { hidesLegacySessions, onHidesLegacySessionsChange } from "../state/prefs";
|
||||
import { SessionAge } from "./SessionAge";
|
||||
import { StudioIcon } from "./StudioIcon";
|
||||
import { TREE_PAGE } from "./treewindow";
|
||||
import { useModalFocus } from "./useModalFocus";
|
||||
import { listenAction } from "./listen";
|
||||
|
||||
export function RecentSessions({ hub, hosts, onPick, onClose }: {
|
||||
hub: HubPort;
|
||||
hosts: RemoteHost[];
|
||||
onPick: (host: string, workspace: TreeWorkspace, session: TreeSession) => Promise<void>;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const sheet = useRef<HTMLDivElement>(null);
|
||||
useModalFocus(sheet);
|
||||
const hideLegacy = useSyncExternalStore(onHidesLegacySessionsChange, hidesLegacySessions, hidesLegacySessions);
|
||||
const [view, setView] = useState({ host: "", q: "", offset: 0 });
|
||||
const [draft, setDraft] = useState("");
|
||||
const [tree, setTree] = useState<TreeWorkspace[] | null>(null);
|
||||
const [busy, setBusy] = useState(true);
|
||||
const [opening, setOpening] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const [retry, setRetry] = useState(0);
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => setView((current) => current.q === draft.trim() ? current : { ...current, q: draft.trim(), offset: 0 }), 250);
|
||||
return () => clearTimeout(timer);
|
||||
}, [draft]);
|
||||
useEffect(() => { sheet.current?.querySelector<HTMLInputElement>("input")?.focus(); }, []);
|
||||
useEffect(() => hub.onTreeChanged(() => setRetry((n) => n + 1)), [hub]);
|
||||
useEffect(() => {
|
||||
const escape = (event: KeyboardEvent) => { if (event.key === "Escape" && !opening) onClose(); };
|
||||
return listenAction(window, "keydown", { action: "layer.dismiss", listener: escape as EventListener });
|
||||
}, [onClose, opening]);
|
||||
useEffect(() => {
|
||||
let stale = false;
|
||||
setBusy(true);
|
||||
setError("");
|
||||
const window = { order: "recent" as const, scope: "active" as const, limit: TREE_PAGE, offset: view.offset, q: view.q, hideLegacy };
|
||||
const read = view.host ? hub.remoteTree(view.host, window) : hub.tree(window);
|
||||
void read.then((next) => {
|
||||
if (stale) return;
|
||||
if (next?.some((ws) => ws.recentOffset !== view.offset)) throw new Error(t("此运行环境不支持最近会话,请更新 Reasonix"));
|
||||
setTree(next ?? []);
|
||||
}).catch((e: unknown) => { if (!stale) setError(reason(e)); })
|
||||
.finally(() => { if (!stale) setBusy(false); });
|
||||
return () => { stale = true; };
|
||||
}, [hub, view, hideLegacy, retry]);
|
||||
const rows = (tree ?? []).flatMap((ws) => ws.sessions.map((session) => ({ ws, session })))
|
||||
.sort((a, b) => (b.session.lastActiveAt ?? 0) - (a.session.lastActiveAt ?? 0) || (a.session.path < b.session.path ? -1 : a.session.path > b.session.path ? 1 : 0));
|
||||
const remaining = (tree ?? []).reduce((n, ws) => n + (ws.remaining ?? 0), 0);
|
||||
const pick = async (ws: TreeWorkspace, session: TreeSession) => {
|
||||
setOpening(true);
|
||||
setError("");
|
||||
try {
|
||||
await onPick(view.host, ws, session);
|
||||
onClose();
|
||||
} catch (e) {
|
||||
setError(reason(e));
|
||||
} finally {
|
||||
setOpening(false);
|
||||
}
|
||||
};
|
||||
return createPortal(
|
||||
<div className="pickveil" role="dialog" aria-modal="true" aria-label={t("最近的会话")} ref={sheet}>
|
||||
<div className="pickcard recent-sessions">
|
||||
<h2>{t("最近的会话")}</h2>
|
||||
<p className="recent-description">{t("按最近活动排列,包含已完成的会话。选择后定位到所属项目。")}</p>
|
||||
{hosts.some((host) => !host.disabled) && <label className="recent-machine">{t("运行环境")}
|
||||
<select data-action="session.recent-environment" aria-label={t("运行环境")} value={view.host} disabled={opening} onChange={(event) => setView({ host: event.target.value, q: view.q, offset: 0 })}>
|
||||
<option value="">{t("本机")}</option>
|
||||
{hosts.filter((host) => !host.disabled).map((host) => <option key={host.name} value={host.name}>{host.name}</option>)}
|
||||
</select>
|
||||
</label>}
|
||||
<div className="pickpath"><input data-action="workspace.search" type="search" aria-label={t("搜索最近会话 / 项目")} placeholder={t("搜索最近会话 / 项目")} value={draft} disabled={opening} onChange={(event) => setDraft(event.target.value)} /></div>
|
||||
<div className="picklist" aria-busy={busy} data-busy={busy ? "" : undefined}>
|
||||
{!busy && !error && !rows.length && <p className="pickempty">{t("没有匹配的会话")}</p>}
|
||||
{busy && <p className="picknote" role="status">{t("正在读取会话…")}</p>}
|
||||
{!busy && !error && rows.map(({ ws, session }) => <button key={session.path} className="pickrow recent-session" disabled={opening}
|
||||
data-action="session.open" data-target={session.path} aria-label={`${session.title || session.name} · ${ws.name}`}
|
||||
title={`${session.workspaceRoot || ws.root}\n${session.path}`} onClick={() => void pick(ws, session)}>
|
||||
<StudioIcon name="chat" />
|
||||
<span className="recent-session-text"><b>{session.title || session.name}</b><small>{ws.name} · {session.workspaceRoot || ws.root}</small></span>
|
||||
<SessionAge at={session.lastActiveAt} />
|
||||
</button>)}
|
||||
{error && <><p className="picknote" role="alert">{error}</p><button className="pickgo" data-action="session.recent-retry" onClick={() => setRetry((n) => n + 1)}>{t("重试")}</button></>}
|
||||
</div>
|
||||
<div className="pickact">
|
||||
<button data-action="session.recent-page" data-value="previous" disabled={busy || opening || view.offset === 0} onClick={() => setView({ ...view, offset: Math.max(0, view.offset - TREE_PAGE) })}>{t("上一页")}</button>
|
||||
<span className="recent-page">{t("第 {n} 页", { n: view.offset / TREE_PAGE + 1 })}</span>
|
||||
<button data-action="session.recent-page" data-value="next" disabled={busy || opening || !!error || remaining === 0} onClick={() => setView({ ...view, offset: view.offset + TREE_PAGE })}>{t("下一页")}</button>
|
||||
<button data-action="layer.dismiss" disabled={opening} onClick={onClose}>{t("关闭")}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>, document.body,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { t } from "../i18n";
|
||||
import { setRunningMessageIntent, useRunningMessageIntent } from "../state/prefs";
|
||||
import { Group } from "./Group";
|
||||
|
||||
export function RunningMessageSetting() {
|
||||
const intent = useRunningMessageIntent();
|
||||
return (
|
||||
<Group id="running-message-intent" title={t("运行中发送消息")} hint={t("选择运行中发送消息的默认方式。仅保存在此设备,不改变已排队的消息。")}>
|
||||
<div className="seg" data-text role="radiogroup" aria-label={t("运行中发送消息")}>
|
||||
<button role="radio" data-action="composer.running-message-intent" data-value="steer" aria-checked={intent === "steer"} onClick={() => setRunningMessageIntent("steer")}>{t("下个工具边界插话")}</button>
|
||||
<button role="radio" data-action="composer.running-message-intent" data-value="followup" aria-checked={intent === "followup"} onClick={() => setRunningMessageIntent("followup")}>{t("整轮结束后发送")}</button>
|
||||
</div>
|
||||
<p className="hint">{t("可在待发送队列中选择“立即发送”来停止当前回复并开始新一轮。暂停、编辑和取回仍然可用。")}</p>
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
@@ -1,9 +1,9 @@
|
||||
import { FeedbackBadge, feedbackEntryTab } from "./feedbackentry";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { t } from "../i18n";
|
||||
import logoWordmark from "../assets/logo-wordmark.svg";
|
||||
import type { AccountState } from "../port/port";
|
||||
import type { HubPort, RuntimeView, TreeWorkspace } from "../port/hub";
|
||||
import type { HubPort, RuntimeView, TreeSession, TreeWorkspace } from "../port/hub";
|
||||
import { workspaceContains } from "../port/hub";
|
||||
import type { RemoteHost } from "../port/remote";
|
||||
import type { Adder } from "./addws";
|
||||
@@ -26,6 +26,8 @@ import type { TreePaging, TreeView } from "./treewindow";
|
||||
import { MASK, useHidesAmounts } from "./wallet";
|
||||
import { useFileDrop } from "./filedrop";
|
||||
import type { PaneView } from "../state/paneview";
|
||||
import { RecentSessions } from "./RecentSessions";
|
||||
import { sessionRuntime } from "./sessionScope";
|
||||
|
||||
interface Props {
|
||||
hub: HubPort;
|
||||
@@ -137,6 +139,9 @@ export function Sidebar({
|
||||
const destinations = useDestinations();
|
||||
const [palette, setPalette] = useState(false);
|
||||
const [projectPicker, setProjectPicker] = useState(false);
|
||||
const [recent, setRecent] = useState(false);
|
||||
const [located, setLocated] = useState<{ target: string } | null>(null);
|
||||
const locatedOnce = useRef<typeof located>(null);
|
||||
const [folded, setFolded] = useSidebarFolds();
|
||||
const hideAmounts = useHidesAmounts();
|
||||
const hiddenHosts = useHiddenHosts();
|
||||
@@ -225,9 +230,30 @@ export function Sidebar({
|
||||
const toggleAll = () => setFolded(allFolded
|
||||
? new Set([...folded].filter((key) => !foldable.includes(key))) : new Set([...folded, ...foldable]));
|
||||
|
||||
const pickRecent = async (host: string, workspace: TreeWorkspace, session: TreeSession) => {
|
||||
const held = sessionRuntime(session, runtimes, host);
|
||||
if (held) onFocusPane(held.id);
|
||||
else if (host) {
|
||||
await onOpenRemote(host, session.workspaceRoot || workspace.root, session.path);
|
||||
await readRemoteTree(host);
|
||||
} else await onOpen({ root: session.workspaceRoot || workspace.root, sessionPath: session.path });
|
||||
setRailScope("all");
|
||||
onFold(host ? remoteProjectKey(host, workspace.root) : workspace.root, false);
|
||||
setLocated({ target: host ? remoteProjectKey(host, workspace.root) + ":" + session.path : session.path });
|
||||
};
|
||||
useEffect(() => {
|
||||
if (!located || recent || locatedOnce.current === located) return;
|
||||
const row = [...document.querySelectorAll<HTMLElement>('.rail [role="treeitem"]')]
|
||||
.find((element) => element.dataset.target === located.target);
|
||||
if (!row) return;
|
||||
row.scrollIntoView?.({ block: "nearest" });
|
||||
row.focus({ preventScroll: true });
|
||||
locatedOnce.current = located;
|
||||
}, [located, recent, tree, remoteTrees]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{recent && <RecentSessions hub={hub} hosts={shownRemotes} onPick={pickRecent} onClose={() => setRecent(false)} />}
|
||||
{projectPicker && <OpenProject hub={hub} adder={adder} hosts={shownRemotes}
|
||||
onClose={() => setProjectPicker(false)} onError={onError} onOpen={async (host, root) => {
|
||||
await onOpenRemote(host, root);
|
||||
@@ -289,7 +315,7 @@ export function Sidebar({
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<RailSearch>
|
||||
<RailSearch resetKey={located}>
|
||||
<ContentMatches hub={hub} tree={tree} onOpen={onOpen} onError={onError} onReference={onReferenceSession} />
|
||||
<Workspaces
|
||||
hub={hub}
|
||||
@@ -301,6 +327,7 @@ export function Sidebar({
|
||||
folded={folded}
|
||||
onToggleAll={toggleAll}
|
||||
allFolded={allFolded}
|
||||
onRecent={() => setRecent(true)}
|
||||
canFold={foldable.length > 0}
|
||||
onFold={onFold}
|
||||
reload={reloadTree}
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import { useSyncExternalStore } from "react";
|
||||
import { t } from "../i18n";
|
||||
import { onThemeScheduleChange, setThemeSchedule, themeSchedule } from "../state/themeSchedule";
|
||||
|
||||
const FIELDS = [["start", "深色开始时间"], ["end", "恢复浅色时间"]] as const;
|
||||
|
||||
export function ThemeScheduleFields() {
|
||||
const schedule = useSyncExternalStore(onThemeScheduleChange, themeSchedule);
|
||||
return <>
|
||||
<p className="hint" id="theme-schedule-hint">{t("每天按本机时间切换,可跨午夜;开始时进入深色,结束时恢复浅色。")}</p>
|
||||
{FIELDS.map(([field, label]) => (
|
||||
<label className="prow" key={field}>
|
||||
<span className="tx">{t(label)}</span>
|
||||
<input type="time" className="fontown" value={schedule[field]} required step={60}
|
||||
aria-describedby="theme-schedule-hint" data-action="appearance.schedule" data-target={field}
|
||||
onChange={(e) => setThemeSchedule({ ...schedule, [field]: e.target.value })} />
|
||||
</label>
|
||||
))}
|
||||
{schedule.start === schedule.end && <p className="hint">{t("开始和结束时间相同时全天使用浅色。")}</p>}
|
||||
</>;
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
// @vitest-environment jsdom
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import "./testkit";
|
||||
import { Versions } from "./Versions";
|
||||
@@ -95,6 +95,16 @@ describe("the version panel", () => {
|
||||
expect(screen.getByRole("link", { name: "下载完整安装包" })).toBeTruthy();
|
||||
});
|
||||
|
||||
it("opens the full installer page in the system browser, where a file can be saved", async () => {
|
||||
const openExternal = vi.fn(() => Promise.resolve());
|
||||
const port = { ...portAt({ version: "2.21.0", phase: "error", received: 0, total: 0, code: "update.not_applied", err: "update: set aside app.exe: access denied" }), openExternal };
|
||||
render(<Versions port={port} />);
|
||||
const link = await screen.findByRole("link", { name: "下载完整安装包" });
|
||||
const notPrevented = fireEvent.click(link);
|
||||
expect(notPrevented).toBe(false);
|
||||
expect(openExternal).toHaveBeenCalledWith("https://github.com/esengine/DeepSeek-Reasonix/releases/tag/studio-v2.21.0");
|
||||
});
|
||||
|
||||
it("explains a move that did not land by its code, with the way out", async () => {
|
||||
const port = portAt({ version: "2.21.0", phase: "error", received: 0, total: 0, code: "update.not_applied", err: "update: set aside app.exe: access denied" });
|
||||
render(<Versions port={port} />);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Fragment, useCallback, useEffect, useState, type KeyboardEvent } from "react";
|
||||
import { Fragment, useCallback, useEffect, useState, type KeyboardEvent, type MouseEvent } from "react";
|
||||
import { bytes } from "../i18n/format";
|
||||
import { current, t } from "../i18n";
|
||||
import type { UpdateProgress, VersionHub, VersionNotes } from "../port/port";
|
||||
@@ -29,6 +29,7 @@ type Port = {
|
||||
restartToVersion(v: string, force: boolean): Promise<void>;
|
||||
onUpdateProgress(cb: (p: UpdateProgress) => void): () => void;
|
||||
versionNotes?(version: string, retry?: boolean): Promise<VersionNotes>;
|
||||
openExternal?(url: string): Promise<void>;
|
||||
};
|
||||
|
||||
// A document read stays read: it is kept for as long as the panel is mounted,
|
||||
@@ -84,6 +85,13 @@ function say(p: UpdateProgress): string {
|
||||
// The newest few older releases stay on the page; the rest are history one click away.
|
||||
const PAST_SHOWN = 3;
|
||||
|
||||
// An installer is a file to save, which the window's own browser does not do.
|
||||
function openInstallerPage(e: MouseEvent<HTMLAnchorElement>, port: Port, version: string) {
|
||||
if (!port.openExternal) return;
|
||||
e.preventDefault();
|
||||
void port.openExternal(releasePage(version)).catch(() => {});
|
||||
}
|
||||
|
||||
export function Versions({ port }: { port: Port }) {
|
||||
const [hub, setHub] = useState<VersionHub | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
@@ -349,7 +357,7 @@ export function Versions({ port }: { port: Port }) {
|
||||
<span className="why">
|
||||
{failure.why}
|
||||
{failure.manual && (
|
||||
<a className="lnk" href={releasePage(progress.version)} target="_blank" rel="noreferrer noopener">
|
||||
<a className="lnk" href={releasePage(progress.version)} target="_blank" rel="noreferrer noopener" data-action="versions.full-installer" onClick={(e) => openInstallerPage(e, port, progress.version)}>
|
||||
{t("下载完整安装包")}
|
||||
</a>
|
||||
)}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { inScope } from "./sessionScope";
|
||||
import { inScope, sessionRuntime } from "./sessionScope";
|
||||
import { Fragment, type ReactNode, memo, useEffect, useLayoutEffect, useRef, useState, useSyncExternalStore } from "react";
|
||||
import { hidesLegacySessions, onHidesLegacySessionsChange } from "../state/prefs";
|
||||
import { onSessionQuickActionsChange, showsSessionQuickActions } from "../state/sessionQuick";
|
||||
@@ -57,6 +57,7 @@ interface Props {
|
||||
paging?: TreePaging;
|
||||
previewLimit?: number;
|
||||
onToggleAll?: () => void;
|
||||
onRecent?: () => void;
|
||||
allFolded?: boolean;
|
||||
canFold?: boolean;
|
||||
// The session path being opened, or empty. The row shows it selected and busy
|
||||
@@ -91,7 +92,7 @@ interface Props {
|
||||
const rowLabel = (session: TreeSession) =>
|
||||
session.title || (session.runtimeId && !session.turns ? t("新会话") : session.name);
|
||||
|
||||
function WorkspacesView({ hub, tree, treeRead, answered = null, runtimes, active, folded, reload, retry, paging = NO_PAGING, previewLimit = Infinity, onFold, onToggleAll, allFolded, canFold, opening = "", onOpen, onFocus, onClose, liveIds, listedIds = liveIds, runs, scope = "all", pinned = new Set(), onPin = () => {}, onPause = () => {}, onBranch, onArchive = async () => {}, onRename, onError, adder, children, hasOtherRows }: Props) {
|
||||
function WorkspacesView({ hub, tree, treeRead, answered = null, runtimes, active, folded, reload, retry, paging = NO_PAGING, previewLimit = Infinity, onFold, onToggleAll, onRecent, allFolded, canFold, opening = "", onOpen, onFocus, onClose, liveIds, listedIds = liveIds, runs, scope = "all", pinned = new Set(), onPin = () => {}, onPause = () => {}, onBranch, onArchive = async () => {}, onRename, onError, adder, children, hasOtherRows }: Props) {
|
||||
const [busy, setBusy] = useState("");
|
||||
const [confirm, setConfirm] = useState("");
|
||||
const [selection, setSelection] = useState<Set<string> | null>(null);
|
||||
@@ -182,8 +183,9 @@ function WorkspacesView({ hub, tree, treeRead, answered = null, runtimes, active
|
||||
// The tree and runtime list arrive from separate reads. During a close or
|
||||
// restore the tree can briefly retain an id that no longer exists; focusing
|
||||
// that stale id changes no pane and makes the row look unclickable.
|
||||
if (session.runtimeId && runtimes.some((rt) => rt.id === session.runtimeId)) {
|
||||
onFocus(session.runtimeId);
|
||||
const held = sessionRuntime(session, runtimes);
|
||||
if (held) {
|
||||
onFocus(held.id);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
@@ -363,7 +365,7 @@ function WorkspacesView({ hub, tree, treeRead, answered = null, runtimes, active
|
||||
onExit={() => treeKeys.ref.current?.parentElement?.previousElementSibling?.querySelector<HTMLElement>('[data-action="machine.menu"]')?.focus()}
|
||||
onBusy={(pending) => { setBusy(pending ? "batch" : ""); if (pending) { setSessionMenu(""); setEditing(""); setConfirm(""); } }}
|
||||
onClose={onClose} onArchive={onArchive} liveIds={liveIds} reload={reload} onError={onError} />
|
||||
<ProjectsHeader adder={adder}
|
||||
<ProjectsHeader adder={adder} onRecent={onRecent}
|
||||
onToggleAll={onToggleAll} allFolded={needle ? false : allFolded} canFold={canFold} searching={!!needle}
|
||||
canSelect={displayed.length > 0} selecting={selection !== null}
|
||||
onSelect={() => setSelection(new Set())} />
|
||||
@@ -774,7 +776,6 @@ function WorkspacesView({ hub, tree, treeRead, answered = null, runtimes, active
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -25,7 +25,7 @@ it("keeps open a menu opened between the chip's commit and its mount effects", a
|
||||
});
|
||||
watch.observe(root, { childList: true, subtree: true });
|
||||
});
|
||||
mounted.render(<WorktreeChip port={port} sources={sources} canCreate={false} selection={null} branch="main" busy={false}
|
||||
mounted.render(<WorktreeChip port={port} workspace={null} sources={sources} canCreate={false} selection={null} branch="main" busy={false}
|
||||
changeCount={0} onSelect={vi.fn()} refresh={async () => sources} onChanged={vi.fn()} onError={vi.fn()} />);
|
||||
await clicked;
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
|
||||
@@ -17,6 +17,7 @@ interface Props {
|
||||
onSwitched?: (git: WorkspaceGit) => void;
|
||||
onError: (error: unknown) => void;
|
||||
sources: WorktreeSources;
|
||||
workspace: WorkspaceInfo | null;
|
||||
canCreate: boolean;
|
||||
selection: WorktreeSelection | null;
|
||||
branch: string;
|
||||
@@ -26,11 +27,10 @@ interface Props {
|
||||
refresh: () => Promise<WorktreeSources>;
|
||||
}
|
||||
|
||||
export function WorktreeChip({ sources, canCreate, selection, branch, busy, changeCount, onSelect, refresh, port, onChanged, onSwitched, onError }: Props) {
|
||||
export function WorktreeChip({ sources, workspace, canCreate, selection, branch, busy, changeCount, onSelect, refresh, port, onChanged, onSwitched, onError }: Props) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [query, setQuery] = useState("");
|
||||
const [sourceError, setSourceError] = useState(false);
|
||||
const [workspace, setWorkspace] = useState<WorkspaceInfo | null>(null);
|
||||
const trigger = useRef<HTMLButtonElement>(null);
|
||||
const panel = useRef<HTMLDivElement>(null);
|
||||
const search = useRef<HTMLInputElement>(null);
|
||||
@@ -48,14 +48,10 @@ export function WorktreeChip({ sources, canCreate, selection, branch, busy, chan
|
||||
const directoryDescription = selection ? creationDescription : workspaceDirectoryDescription(isolated);
|
||||
const scope = useRef({ port, root: sources.sourceRoot });
|
||||
useEffect(() => {
|
||||
let live = true;
|
||||
if (scope.current.port !== port || scope.current.root !== sources.sourceRoot) {
|
||||
scope.current = { port, root: sources.sourceRoot };
|
||||
returnFocus.current = false; setOpen(false); setQuery("");
|
||||
}
|
||||
setWorkspace(null);
|
||||
void port.workspaces().then((value) => { if (live) setWorkspace(value); }).catch(() => {});
|
||||
return () => { live = false; };
|
||||
}, [port, sources.sourceRoot]);
|
||||
const put = useCallback(() => {
|
||||
if (!trigger.current || !panel.current) return;
|
||||
|
||||
@@ -25,6 +25,7 @@ async function draw(shell: Record<string, unknown>, host?: string) {
|
||||
render(
|
||||
<Chrome
|
||||
port={null}
|
||||
workspace={null}
|
||||
status={{ preset: "balanced", toolApprovalMode: "ask", workspaceRoot: ROOT, sessionPath: ROOT + "/s.jsonl" } as never}
|
||||
title="会话"
|
||||
steer={0}
|
||||
|
||||
@@ -6,11 +6,14 @@ import type { HubPort, RuntimeView } from "../port/hub";
|
||||
import { apply as applyLook } from "./look";
|
||||
import { apply as applyThemePack } from "./theme";
|
||||
import { trailing } from "./trailing";
|
||||
import { onThemeScheduleChange, themeSchedule } from "../state/themeSchedule";
|
||||
import { nextScheduledBoundary, scheduledScheme } from "./scheduledTheme";
|
||||
import { host } from "../port/host";
|
||||
|
||||
export interface Paint {
|
||||
theme: string;
|
||||
setTheme: (v: string) => void;
|
||||
// What "auto" resolved to. Panes take it as a prop, not off the root.
|
||||
// The resolved colour mode, independent of the selected preference.
|
||||
scheme: "light" | "dark";
|
||||
contrast: string;
|
||||
setContrast: (v: string) => void;
|
||||
@@ -28,7 +31,13 @@ export interface Paint {
|
||||
* writes `documentElement`'s dataset, so nothing else has to know the order
|
||||
* a pack and a look have to be applied in. */
|
||||
export function usePaint(hub: HubPort, runtimes: RuntimeView[], running: boolean, onError: (e: unknown) => void): Paint {
|
||||
const [theme, setTheme] = useState(() => localStorage.getItem("rx-theme") ?? "auto");
|
||||
const [theme, setTheme] = useState(() => {
|
||||
try {
|
||||
const saved = localStorage.getItem("rx-theme");
|
||||
return saved && ["auto", "light", "dark", "scheduled"].includes(saved) ? saved : "auto";
|
||||
} catch { return "auto"; }
|
||||
});
|
||||
const schedule = useSyncExternalStore(onThemeScheduleChange, themeSchedule);
|
||||
const [scheme, setScheme] = useState<"light" | "dark">("dark");
|
||||
// "" means never chosen, which is what lets the system's own contrast setting
|
||||
// decide. Any explicit pick wins over it from then on.
|
||||
@@ -86,8 +95,12 @@ export function usePaint(hub: HubPort, runtimes: RuntimeView[], running: boolean
|
||||
// in flight — the transition is in CSS, this only moves the target.
|
||||
useEffect(() => {
|
||||
const mq = matchMedia("(prefers-color-scheme: dark)");
|
||||
let timer: number | undefined;
|
||||
const paint = () => {
|
||||
const now = theme === "auto" ? (mq.matches ? "dark" : "light") : theme;
|
||||
clearTimeout(timer);
|
||||
const clock = new Date();
|
||||
const now = theme === "scheduled" ? scheduledScheme(schedule, clock) :
|
||||
theme === "auto" ? (mq.matches ? "dark" : "light") : theme;
|
||||
document.documentElement.dataset.theme = now;
|
||||
setScheme(now as "light" | "dark");
|
||||
// The active pack is the same state shown by the appearance picker.
|
||||
@@ -100,12 +113,31 @@ export function usePaint(hub: HubPort, runtimes: RuntimeView[], running: boolean
|
||||
// After the pack, never before: size and type are the reader's, and a
|
||||
// palette someone else authored does not get to overrule them.
|
||||
applyLook(look, running);
|
||||
try {
|
||||
localStorage.setItem("rx-theme", theme);
|
||||
localStorage.setItem("rx-theme-resolved", now);
|
||||
} catch { /* this window keeps its choice */ }
|
||||
host().syncTheme(theme, now as "light" | "dark");
|
||||
if (theme === "scheduled") {
|
||||
const delay = nextScheduledBoundary(schedule, clock);
|
||||
if (delay !== null) timer = window.setTimeout(paint, delay);
|
||||
}
|
||||
};
|
||||
paint();
|
||||
mq.addEventListener("change", paint);
|
||||
localStorage.setItem("rx-theme", theme);
|
||||
return () => mq.removeEventListener("change", paint);
|
||||
}, [theme, pack, running, look, contrast]);
|
||||
if (theme === "scheduled") {
|
||||
window.addEventListener("focus", paint);
|
||||
window.addEventListener("pageshow", paint);
|
||||
document.addEventListener("visibilitychange", paint);
|
||||
}
|
||||
return () => {
|
||||
clearTimeout(timer);
|
||||
mq.removeEventListener("change", paint);
|
||||
window.removeEventListener("focus", paint);
|
||||
window.removeEventListener("pageshow", paint);
|
||||
document.removeEventListener("visibilitychange", paint);
|
||||
};
|
||||
}, [theme, schedule, pack, running, look, contrast]);
|
||||
|
||||
useEffect(() => {
|
||||
if (contrast) document.documentElement.dataset.contrast = contrast;
|
||||
|
||||
@@ -2,16 +2,25 @@
|
||||
import "./testkit";
|
||||
import { useCallback, useState } from "react";
|
||||
import { act, cleanup, render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { afterEach, expect, it, vi } from "vitest";
|
||||
import { MockPort } from "../port/mock";
|
||||
import type { SessionStatus } from "../port/port";
|
||||
import { MockHub } from "../port/mock_hub";
|
||||
import type { SessionStatus, WorkspaceInfo } from "../port/port";
|
||||
import type { WireEvent } from "../port/wire";
|
||||
import { Chrome } from "./Chrome";
|
||||
import { Pane, type PaneReport } from "./Pane";
|
||||
import { App } from "./App";
|
||||
|
||||
afterEach(() => { cleanup(); localStorage.clear(); vi.restoreAllMocks(); });
|
||||
Element.prototype.getAnimations ??= () => [];
|
||||
|
||||
function deferred<T>() {
|
||||
let resolve!: (value: T) => void;
|
||||
const promise = new Promise<T>((done) => { resolve = done; });
|
||||
return { promise, resolve };
|
||||
}
|
||||
|
||||
const deferredReads = ["mcp", "changes", "context", "queue"] as const;
|
||||
const reads = ["checkpoints", "mcp", "changes", "context", "balance", "todos", "queue", "workspaces", "capabilityScope", "models"] as const;
|
||||
|
||||
@@ -33,6 +42,8 @@ async function mount(running = false, pathless = false) {
|
||||
const mcp = await port.mcp();
|
||||
const sessionPath = pathless ? undefined : "/sessions/active.jsonl";
|
||||
let state: SessionStatus = { ...snapshot, running, sessionPath };
|
||||
const sources = port.worktreeSources.bind(port);
|
||||
vi.spyOn(port, "worktreeSources").mockImplementation(async () => ({ ...await sources(), sourceRoot: state.workspaceRoot || state.cwd }));
|
||||
let release: () => void = () => {};
|
||||
let reject: () => void = () => {};
|
||||
vi.spyOn(port, "status").mockImplementationOnce(() => new Promise((resolve, fail) => { release = () => resolve(state); reject = () => fail(new Error("status offline")); }))
|
||||
@@ -47,8 +58,8 @@ async function mount(running = false, pathless = false) {
|
||||
const [report, setReport] = useState<PaneReport | null>(null);
|
||||
const onReport = useCallback((_: string, next: PaneReport) => setReport(next), []);
|
||||
return <>
|
||||
<Chrome port={port} status={report?.status ?? null} title="active" steer={0} account={null} theme="light" onTheme={() => {}} onFind={() => {}} view="flow" rows={0} surfaces={0} onView={() => {}} />
|
||||
<Pane port={port} rt={{ id: "r1", base: "/rt/r1", root: "/workspace", name: "workspace", sessionPath }}
|
||||
<Chrome port={port} status={report?.status ?? null} workspace={report?.workspace ?? null} title="active" steer={0} account={null} theme="light" onTheme={() => {}} onFind={() => {}} view="flow" rows={0} surfaces={0} onView={() => {}} />
|
||||
<Pane port={port} rt={{ id: "r1", base: "/rt/r1", root: snapshot.workspaceRoot!, name: "workspace", sessionPath }}
|
||||
title="active" active visible sideHost={null} side={false} onFocus={() => {}} onReport={onReport}
|
||||
onSessionChanged={() => {}} pulse={0} findPulse={0} onSettings={() => {}} needsProject={false}
|
||||
onOpenProject={() => {}} onKeepHere={() => {}} theme="light" dockW={320} dockMax={640} onDockW={() => {}} />
|
||||
@@ -61,10 +72,13 @@ async function mount(running = false, pathless = false) {
|
||||
change: (next: Partial<SessionStatus>) => { state = { ...state, ...next }; } };
|
||||
}
|
||||
|
||||
it.each([false, true])("reads each deferred startup resource once after the session is known (running=%s)", async (running) => {
|
||||
const pane = await mount(running);
|
||||
it.each([
|
||||
[false, false], [true, false], [false, true], [true, true],
|
||||
])("reads startup resources once (running=%s, pathless=%s)", async (running, pathless) => {
|
||||
const pane = await mount(running, pathless);
|
||||
expect(pane.calls.workspaces).not.toHaveBeenCalled();
|
||||
await pane.ready();
|
||||
await waitFor(() => expect(pane.calls.workspaces.mock.calls.length).toBeGreaterThan(0));
|
||||
await waitFor(() => expect(pane.calls.workspaces).toHaveBeenCalledTimes(1));
|
||||
for (const name of deferredReads) expect(pane.calls[name], name).toHaveBeenCalledTimes(1);
|
||||
expect(pane.calls.balance).toHaveBeenCalledTimes(1);
|
||||
expect(pane.calls.todos).toHaveBeenCalledTimes(1);
|
||||
@@ -82,7 +96,7 @@ it("waits for the initial status before reading deferred panels without holding
|
||||
it("reads once for a new session which has no persisted path yet", async () => {
|
||||
const pane = await mount(false, true);
|
||||
await pane.ready();
|
||||
await waitFor(() => expect(pane.calls.workspaces.mock.calls.length).toBeGreaterThan(0));
|
||||
await waitFor(() => expect(pane.calls.workspaces).toHaveBeenCalledTimes(1));
|
||||
for (const name of deferredReads) expect(pane.calls[name], name).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
@@ -136,8 +150,10 @@ it("loads deferred panels and the branch chip when the first status fails", asyn
|
||||
expect(pane.port.branchReads).toHaveBeenCalled();
|
||||
expect(pane.calls.changes).toHaveBeenCalled();
|
||||
expect((await screen.findByRole("button", { name: "配置工作目录" })).textContent).toContain("rescue");
|
||||
expect(pane.calls.workspaces).toHaveBeenCalledTimes(1);
|
||||
pane.send({ kind: "turn_done" });
|
||||
await waitFor(() => expect(pane.calls.queue).toHaveBeenCalledTimes(2));
|
||||
expect(pane.calls.workspaces).toHaveBeenCalledTimes(1);
|
||||
expect(pane.calls.models).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
@@ -156,3 +172,65 @@ it("keeps event-driven reads alive while the first status is pending", async ()
|
||||
await pane.ready();
|
||||
await waitFor(() => expect(pane.calls.queue).toHaveBeenCalledTimes(2));
|
||||
});
|
||||
|
||||
it("shares the workspace result with both directory badges and rejects a stale root read", async () => {
|
||||
const pane = await mount();
|
||||
const old = deferred<WorkspaceInfo>();
|
||||
pane.calls.workspaces.mockReturnValueOnce(old.promise);
|
||||
await pane.ready();
|
||||
expect(pane.calls.workspaces).toHaveBeenCalledTimes(1);
|
||||
pane.calls.workspaces.mockResolvedValue({ current: "/next", isolated: true, canSwitch: true, canIsolate: true, recents: [] });
|
||||
pane.change({ workspaceRoot: "/next" });
|
||||
pane.gap();
|
||||
await waitFor(() => expect(document.querySelector(".chrome .isolab")?.getAttribute("title")).toContain("/next"));
|
||||
await waitFor(() => expect(screen.getByRole("button", { name: "配置工作目录" }).textContent).toContain("独立工作树"));
|
||||
await act(async () => old.resolve({ current: "/old", isolated: true, canSwitch: true, canIsolate: true, recents: [] }));
|
||||
expect(document.querySelector(".chrome .isolab")?.getAttribute("title")).toContain("/next");
|
||||
expect(screen.getByRole("button", { name: "配置工作目录" }).title).toContain("/next");
|
||||
expect(pane.calls.workspaces).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("recovers workspace metadata after a failed read when the directory changes", async () => {
|
||||
const pane = await mount();
|
||||
pane.calls.workspaces.mockRejectedValueOnce(new Error("workspace offline"));
|
||||
await pane.ready();
|
||||
expect(pane.calls.workspaces).toHaveBeenCalledTimes(1);
|
||||
expect(document.querySelector(".chrome .isolab:not(.hostlab)")).toBeNull();
|
||||
pane.calls.workspaces.mockResolvedValue({ current: "/next", isolated: true, canSwitch: true, canIsolate: true, recents: [] });
|
||||
pane.change({ workspaceRoot: "/next" });
|
||||
pane.gap();
|
||||
await waitFor(() => expect(document.querySelector(".chrome .isolab")?.getAttribute("title")).toContain("/next"));
|
||||
expect(pane.calls.workspaces).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("keeps workspace reads and late results isolated across focused panes", async () => {
|
||||
const hub = new MockHub();
|
||||
const portFor = hub.portFor.bind(hub);
|
||||
const calls = new Map<string, number>();
|
||||
const first = deferred<WorkspaceInfo>();
|
||||
let firstRoot = "";
|
||||
hub.portFor = (rt) => {
|
||||
const port = portFor(rt);
|
||||
if (calls.has(rt.id)) return port;
|
||||
calls.set(rt.id, 0);
|
||||
const status = port.status.bind(port);
|
||||
port.status = async () => ({ ...await status(), workspaceRoot: rt.root, sessionPath: rt.sessionPath });
|
||||
port.welcomeSeen = async () => true;
|
||||
port.providerSetup = async () => null;
|
||||
port.workspaces = () => {
|
||||
calls.set(rt.id, calls.get(rt.id)! + 1);
|
||||
if (rt.id === "r1") { firstRoot = rt.root; return first.promise; }
|
||||
return Promise.resolve({ current: rt.root, isolated: false, canSwitch: true, canIsolate: true, recents: [] });
|
||||
};
|
||||
return port;
|
||||
};
|
||||
render(<App hub={hub} />);
|
||||
await waitFor(() => expect(calls.get("r1")).toBe(1));
|
||||
await userEvent.click(await screen.findByRole("treeitem", { name: /站点改版/ }));
|
||||
await waitFor(() => expect([...calls.values()]).toEqual([1, 1]));
|
||||
await act(async () => first.resolve({ current: firstRoot, isolated: true, canSwitch: true, canIsolate: true, recents: [] }));
|
||||
expect(document.querySelector(".chrome .isolab:not(.hostlab)")).toBeNull();
|
||||
await userEvent.click(screen.getByRole("treeitem", { name: /并行会话演示/ }));
|
||||
await waitFor(() => expect(document.querySelector(".chrome .isolab")?.getAttribute("title")).toContain(firstRoot));
|
||||
expect([...calls.values()]).toEqual([1, 1]);
|
||||
});
|
||||
@@ -6,7 +6,7 @@ import userEvent from "@testing-library/user-event";
|
||||
import "./testkit";
|
||||
import { App } from "./App";
|
||||
import { MockHub } from "../port/mock_hub";
|
||||
import type { RuntimeView, TreeWorkspace } from "../port/hub";
|
||||
import type { RuntimeView } from "../port/hub";
|
||||
import type { AgentPort } from "../port/port";
|
||||
|
||||
const renders = vi.hoisted(() => new Map<string, number>());
|
||||
@@ -26,15 +26,9 @@ afterEach(cleanup);
|
||||
|
||||
const PANES = 6;
|
||||
|
||||
const views: RuntimeView[] = Array.from({ length: PANES }, (_, i) => ({
|
||||
id: `r${i + 1}`, base: i === 0 ? "" : `/rt/r${i + 1}`, root: "~/projects/DeepSeek-Reasonix", name: "DeepSeek-Reasonix",
|
||||
sessionPath: `/sessions/pane-${i + 1}.jsonl`,
|
||||
}));
|
||||
|
||||
class SixPaneHub extends MockHub {
|
||||
open(req: { root?: string; sessionPath?: string }) {
|
||||
const held = views.find((v) => v.sessionPath === req.sessionPath);
|
||||
return held ? Promise.resolve(held) : super.open(req);
|
||||
constructor() {
|
||||
super(PANES);
|
||||
}
|
||||
portFor(rt: RuntimeView): AgentPort {
|
||||
const port = super.portFor(rt);
|
||||
@@ -42,15 +36,6 @@ class SixPaneHub extends MockHub {
|
||||
port.welcomeSeen = async () => true;
|
||||
return port;
|
||||
}
|
||||
runtimes(): Promise<RuntimeView[]> {
|
||||
return Promise.resolve([...views]);
|
||||
}
|
||||
tree(): Promise<TreeWorkspace[]> {
|
||||
return Promise.resolve([{
|
||||
root: "~/projects/DeepSeek-Reasonix", name: "DeepSeek-Reasonix", open: true,
|
||||
sessions: Array.from({ length: PANES }, (_, i) => ({ path: `/sessions/pane-${i + 1}.jsonl`, name: `pane-${i + 1}`, title: `会话 ${i + 1}`, turns: 3 })),
|
||||
}]);
|
||||
}
|
||||
}
|
||||
|
||||
// A hidden pane holds a whole transcript. Switching tabs changes which pane is
|
||||
@@ -86,3 +71,26 @@ it("still makes the chosen pane the visible one, and its focus callback works",
|
||||
shown.get("r2")!.focus();
|
||||
await waitFor(() => expect([...shown.entries()].filter(([, p]) => p.visible).map(([id]) => id)).toEqual(["r2"]));
|
||||
});
|
||||
|
||||
it("focuses existing seeded conversations without reopening or rebinding their panes", async () => {
|
||||
const hub = new SixPaneHub();
|
||||
const initial = await hub.runtimes();
|
||||
const identities = initial.map((runtime) => [runtime.id, runtime.sessionPath]);
|
||||
const open = vi.spyOn(hub, "open");
|
||||
const { container } = render(<App hub={hub} />);
|
||||
const pick = async (id: string) => {
|
||||
const row = await waitFor(() => {
|
||||
const path = initial.find((runtime) => runtime.id === id)!.sessionPath;
|
||||
const found = [...container.querySelectorAll<HTMLElement>(".sessrow")].find((row) => row.dataset.target === path);
|
||||
expect(found).not.toBeNull();
|
||||
return found!;
|
||||
});
|
||||
await userEvent.click(row);
|
||||
await waitFor(() => expect([...shown.entries()].filter(([, pane]) => pane.visible).map(([runtime]) => runtime)).toEqual([id]));
|
||||
};
|
||||
await pick(initial[1].id);
|
||||
await pick(initial[0].id);
|
||||
expect(open).not.toHaveBeenCalled();
|
||||
expect((await hub.runtimes()).map((runtime) => [runtime.id, runtime.sessionPath])).toEqual(
|
||||
identities);
|
||||
});
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { ReactNode } from "react";
|
||||
import type { AgentPort, ContextBreakdown, McpEntry, SessionStatus } from "../port/port";
|
||||
import type { AgentPort, ContextBreakdown, McpEntry, SessionStatus, WorkspaceInfo } from "../port/port";
|
||||
import type { HubPort, RuntimeView } from "../port/hub";
|
||||
import type { ConnectOffer } from "./ConnectNote";
|
||||
import type { PaneView } from "../state/paneview";
|
||||
@@ -8,6 +8,7 @@ import type { PaneView } from "../state/paneview";
|
||||
// focus: everything else about a session stays inside the pane that owns it.
|
||||
export interface PaneReport {
|
||||
status: SessionStatus | null;
|
||||
workspace: WorkspaceInfo | null;
|
||||
title: string;
|
||||
steer: number;
|
||||
run: string;
|
||||
|
||||
@@ -320,6 +320,7 @@ export const SETTINGS: SettingEntry[] = [
|
||||
{ section: "appearance", anchor: "legacy-sessions", title: "1.x 旧会话", scope: "machine", apply: "immediate", keywords: ["1.x", "旧会话", "迁移", "隐藏", "legacy", "sessions", "sidebar"] },
|
||||
{ section: "appearance", anchor: "live-grace", title: "进行中列表保留已完成会话", scope: "machine", apply: "immediate", keywords: ["进行中", "完成", "保留", "过滤", "running", "grace", "sidebar"] },
|
||||
{ section: "appearance", anchor: "send-shortcut", title: "发送快捷键", scope: "machine", apply: "immediate", keywords: ["发送", "回车", "换行", "enter", "ctrl", "cmd"] },
|
||||
{ section: "appearance", anchor: "running-message-intent", title: "运行中发送消息", scope: "machine", apply: "immediate", keywords: ["插话", "排队", "下一轮", "steer", "queue", "followup", "running"] },
|
||||
{ section: "appearance", anchor: "global-shortcut", title: "全局显示 / 隐藏快捷键", scope: "machine", apply: "immediate", keywords: ["global", "show", "hide", "shortcut", "keyboard", "全局", "显示", "隐藏"] },
|
||||
{ section: "appearance", anchor: "keys", title: "快捷键", scope: "machine", apply: "immediate", keywords: ["shortcut", "keyboard", "ctrl", "cmd", "esc", "home", "查找", "搜索", "运行分析", "工作台"] },
|
||||
{ section: "appearance", anchor: "esc-stop", title: "Esc 停止任务", scope: "machine", apply: "immediate", keywords: ["esc", "停止", "中断", "确认", "误触", "stop"] },
|
||||
@@ -327,6 +328,7 @@ export const SETTINGS: SettingEntry[] = [
|
||||
{ section: "appearance", anchor: "session-tabs", title: "会话标签栏", scope: "machine", apply: "immediate", keywords: ["标签", "会话切换", "tabs", "session"] },
|
||||
{ section: "appearance", anchor: "folding", title: "会话折叠", scope: "machine", apply: "immediate", keywords: ["展开", "收起", "思考", "执行过程", "步骤", "输出", "简报", "fold"] },
|
||||
{ section: "appearance", anchor: "plan-progress", title: "收起时显示计划进度", scope: "machine", apply: "immediate", keywords: ["计划", "进度条", "待办", "收起", "todo", "plan", "progress", "collapsed"] },
|
||||
{ section: "appearance", anchor: "composer-hint", title: "输入框提示", scope: "machine", apply: "immediate", keywords: ["占位", "任务", "提示", "输入框", "placeholder", "composer", "hint"] },
|
||||
{ section: "appearance", anchor: "window", title: "窗口", scope: "machine", apply: "immediate", keywords: ["托盘", "关闭行为", "休眠", "睡眠"] },
|
||||
// Immediate because the endpoint hands the holder every running sink reads,
|
||||
// not because the file was written: writing it is what a restart would need.
|
||||
@@ -338,7 +340,7 @@ export const SETTINGS: SettingEntry[] = [
|
||||
{ section: "appearance", anchor: "weight", title: "文字粗细", scope: "machine", apply: "immediate", keywords: ["加粗", "字重"] },
|
||||
{ section: "appearance", anchor: "contrast", title: "文字对比度", scope: "machine", apply: "immediate", keywords: ["柔和", "对比"] },
|
||||
{ section: "appearance", anchor: "effects", title: "视觉效果", scope: "machine", apply: "immediate", keywords: ["磨砂", "模糊", "动画", "显卡", "gpu", "性能", "省电", "blur"] },
|
||||
{ section: "appearance", anchor: "mode", title: "明暗", scope: "machine", apply: "immediate", keywords: ["深色", "浅色", "跟随系统"] },
|
||||
{ section: "appearance", anchor: "mode", title: "明暗", scope: "machine", apply: "immediate", keywords: ["深色", "浅色", "跟随系统", "定时", "时间段", "schedule", "dark", "light"] },
|
||||
{ section: "appearance", anchor: "scheme", title: "配色", scope: "machine", apply: "immediate", keywords: ["主题", "theme", "色板"] },
|
||||
];
|
||||
|
||||
|
||||
@@ -8,6 +8,8 @@ import { MockPort } from "../port/mock";
|
||||
import { HttpError } from "../port/port";
|
||||
import type { RuntimeView } from "../port/hub";
|
||||
import { draftKey } from "./drafts";
|
||||
import { RunningMessageSetting } from "./RunningMessageSetting";
|
||||
import { setRunningMessageIntent } from "../state/prefs";
|
||||
|
||||
afterEach(() => { cleanup(); localStorage.clear(); vi.restoreAllMocks(); });
|
||||
|
||||
@@ -32,6 +34,43 @@ async function open(port = new MockPort()) {
|
||||
}
|
||||
|
||||
describe("queued submission and withdrawal", () => {
|
||||
it("changes running submission from steering to a follow-up through the shared setting", async () => {
|
||||
const port = await open();
|
||||
const steer = vi.spyOn(port, "steer");
|
||||
const followup = vi.spyOn(port, "queueFollowup");
|
||||
render(<RunningMessageSetting />);
|
||||
fireEvent.click(screen.getByRole("radio", { name: "整轮结束后发送" }));
|
||||
type(BODY);
|
||||
expect(screen.getByText("Enter 排队 · Shift+Enter 换行")).toBeTruthy();
|
||||
fireEvent.click(screen.getByRole("button", { name: "排队发送" }));
|
||||
await screen.findByRole("button", { name: "取回" });
|
||||
expect(followup).toHaveBeenCalledWith(BODY, undefined);
|
||||
expect(steer).not.toHaveBeenCalled();
|
||||
expect((await port.queue()).items[0].intent).toBe("followup");
|
||||
fireEvent.click(screen.getByRole("button", { name: "取回" }));
|
||||
await waitFor(() => expect(box().value).toBe(BODY));
|
||||
fireEvent.click(screen.getByRole("radio", { name: "下个工具边界插话" }));
|
||||
expect(screen.getByRole("button", { name: "插话" })).toBeTruthy();
|
||||
});
|
||||
|
||||
it("holds a preferred follow-up while paused and restores it after reload", async () => {
|
||||
setRunningMessageIntent("followup");
|
||||
const port = new MockPort();
|
||||
await port.setQueuePaused(true);
|
||||
await open(port);
|
||||
type(BODY);
|
||||
fireEvent.click(screen.getByRole("button", { name: "排队发送" }));
|
||||
await screen.findByText("待发送已暂停,这条消息已排入队列,点“继续派发”后才会发送");
|
||||
expect(screen.queryByRole("button", { name: "立即发送" })).toBeNull();
|
||||
cleanup();
|
||||
render(<Pane {...props} port={port} />);
|
||||
await screen.findByRole("button", { name: "取回" });
|
||||
expect(box().value).toBe("");
|
||||
expect((await port.queue()).items[0].intent).toBe("followup");
|
||||
fireEvent.click(screen.getByRole("button", { name: "取回" }));
|
||||
await waitFor(() => expect(box().value).toBe(BODY));
|
||||
});
|
||||
|
||||
it.each(["", "A new draft", BODY])("clears on submit and restores once beside %j", async (draft) => {
|
||||
const port = await open();
|
||||
vi.spyOn(port, "steer").mockImplementation((text) => port.queueFollowup(text));
|
||||
|
||||
@@ -43,7 +43,7 @@ const drawIdle = (q: QueueSnapshot) => paint(q, false);
|
||||
|
||||
describe("compact queue presentation", () => {
|
||||
it("keeps action identities and order when edit and withdraw become icons", () => {
|
||||
const html = draw(snapshot({ items: [item(), item({ id: "i2" })] }));
|
||||
const html = draw(snapshot({ items: [item({ state: "queued" }), item({ id: "i2", state: "queued" })] }));
|
||||
expect(html).toContain('title="编辑" aria-label="改"');
|
||||
expect(html).toContain('title="取回" aria-label="取回"');
|
||||
const actions = [...html.matchAll(/data-action="([^"]+)"/g)].map((m) => m[1]);
|
||||
@@ -52,6 +52,12 @@ describe("compact queue presentation", () => {
|
||||
expect(html).not.toContain("draggable");
|
||||
});
|
||||
|
||||
it("offers only send-now and withdraw on guidance the turn has already accepted", () => {
|
||||
const html = draw(snapshot({ items: [item(), item({ id: "i2" })] }));
|
||||
const actions = [...html.matchAll(/data-action="([^"]+)"/g)].map((m) => m[1]);
|
||||
expect(actions).toEqual(["queue.pause", "queue.send-now", "queue.cancel", "queue.send-now", "queue.cancel"]);
|
||||
});
|
||||
|
||||
it("says paused once and preserves the resume control", () => {
|
||||
const html = draw(snapshot({ paused: true }));
|
||||
expect(html).toContain("发送已暂停");
|
||||
|
||||
@@ -11,9 +11,10 @@ const RailQuery = createContext("");
|
||||
/** What the rail is searching for, trimmed and lowercased. Empty when idle. */
|
||||
export const useRailQuery = () => useContext(RailQuery).trim().toLowerCase();
|
||||
|
||||
export function RailSearch({ children }: { children: ReactNode }) {
|
||||
export function RailSearch({ children, resetKey }: { children: ReactNode; resetKey?: unknown }) {
|
||||
const [q, setQ] = useState("");
|
||||
const find = useRef<HTMLInputElement>(null);
|
||||
useEffect(() => { setQ(""); }, [resetKey]);
|
||||
|
||||
useEffect(() => {
|
||||
const onKey = (ev: KeyboardEvent) => {
|
||||
|
||||
@@ -0,0 +1,188 @@
|
||||
// @vitest-environment jsdom
|
||||
import { afterEach, expect, it, vi } from "vitest";
|
||||
import { act, cleanup, render, screen, waitFor, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import "./testkit";
|
||||
import { App } from "./App";
|
||||
import { RecentSessions } from "./RecentSessions";
|
||||
import { MockHub } from "../port/mock_hub";
|
||||
import type { TreeWindow, TreeWorkspace } from "../port/hub";
|
||||
import type { WireEvent } from "../port/wire";
|
||||
import { setHidesLegacySessions, setLiveGraceMinutes } from "../state/prefs";
|
||||
|
||||
afterEach(() => { cleanup(); setHidesLegacySessions(false); setLiveGraceMinutes(0); localStorage.clear(); });
|
||||
|
||||
function setup() {
|
||||
const hub = new MockHub();
|
||||
const portFor = hub.portFor.bind(hub);
|
||||
hub.portFor = (runtime) => {
|
||||
const port = portFor(runtime);
|
||||
port.providerSetup = async () => null;
|
||||
port.welcomeSeen = async () => true;
|
||||
return port;
|
||||
};
|
||||
hub.remoteHosts = async () => [];
|
||||
return hub;
|
||||
}
|
||||
|
||||
it("pages the complete recent history, returning to the previous page and retrying a failed read", async () => {
|
||||
const hub = setup();
|
||||
const asked: TreeWindow[] = [];
|
||||
let fail = false;
|
||||
hub.tree = async (window) => {
|
||||
asked.push(window!);
|
||||
if (fail) { fail = false; throw new Error("offline"); }
|
||||
const offset = window?.offset ?? 0;
|
||||
return [{ root: "/w", name: "w", recentOffset: offset, remaining: Math.max(0, 351 - offset - 50), sessions: Array.from({ length: Math.min(50, 351 - offset) }, (_, i) => ({
|
||||
path: `/w/${offset + i}.jsonl`, name: `finished ${offset + i}`, lastActiveAt: 1000 - offset - i,
|
||||
})) }];
|
||||
};
|
||||
render(<RecentSessions hub={hub} hosts={[]} onPick={async () => {}} onClose={() => {}} />);
|
||||
const dialog = within(screen.getByRole("dialog", { name: "最近的会话" }));
|
||||
expect(await dialog.findByRole("button", { name: /finished 0 · w/ })).toBeTruthy();
|
||||
for (let page = 1; page <= 6; page++) await userEvent.click(dialog.getByRole("button", { name: "下一页" }));
|
||||
expect(await dialog.findByRole("button", { name: /finished 300 · w/ })).toBeTruthy();
|
||||
fail = true;
|
||||
await userEvent.click(dialog.getByRole("button", { name: "下一页" }));
|
||||
expect(await dialog.findByRole("alert")).toHaveProperty("textContent", "offline");
|
||||
await userEvent.click(dialog.getByRole("button", { name: "重试" }));
|
||||
expect(await dialog.findByRole("button", { name: /finished 350 · w/ })).toBeTruthy();
|
||||
expect(dialog.getByRole("button", { name: "下一页" })).toHaveProperty("disabled", true);
|
||||
await userEvent.click(dialog.getByRole("button", { name: "上一页" }));
|
||||
expect(await dialog.findByRole("button", { name: /finished 300 · w/ })).toBeTruthy();
|
||||
expect(asked.at(-1)).toMatchObject({ order: "recent", scope: "active", limit: 50, offset: 300 });
|
||||
});
|
||||
|
||||
it("discovers a finished conversation outside All's loaded window and locates its collapsed project", async () => {
|
||||
const hub = setup();
|
||||
const ordinary = hub.tree.bind(hub);
|
||||
const open = vi.spyOn(hub, "open");
|
||||
const root = "~/projects/my-website";
|
||||
const path = "/sessions/finished.jsonl";
|
||||
hub.tree = async (window) => {
|
||||
if (window?.order === "recent") return [{ root, name: "my-website", recentOffset: window.offset ?? 0, remaining: 0, sessions: [{ path, name: "finished", title: "Finished release", lastActiveAt: 2000 }] }];
|
||||
const tree = await ordinary(window);
|
||||
const held = (await hub.runtimes()).find((rt) => rt.sessionPath === path);
|
||||
return tree.map((ws) => ws.root !== root || !held ? ws : { ...ws, sessions: [...ws.sessions, { path, name: "finished", title: "Finished release", runtimeId: held.id }] });
|
||||
};
|
||||
render(<App hub={hub} />);
|
||||
const project = await screen.findByRole("treeitem", { name: /my-website/ });
|
||||
await userEvent.click(project);
|
||||
await userEvent.click(screen.getByRole("button", { name: "最近的会话" }));
|
||||
await userEvent.click(await screen.findByRole("button", { name: "Finished release · my-website" }));
|
||||
await waitFor(() => expect(open).toHaveBeenCalledWith(expect.objectContaining({ root, sessionPath: path })));
|
||||
await waitFor(() => expect(screen.queryByRole("dialog", { name: "最近的会话" })).toBeNull());
|
||||
const located = await screen.findByRole("treeitem", { name: "Finished release" });
|
||||
expect(located.closest(".wsnode")?.getAttribute("data-current")).toBe("");
|
||||
expect(project.getAttribute("aria-expanded")).toBe("true");
|
||||
expect(located.getAttribute("aria-selected")).toBe("true");
|
||||
expect(document.activeElement).toBe(located);
|
||||
});
|
||||
|
||||
it("focuses an already-open session without opening it again, and resets Running and sidebar search", async () => {
|
||||
const hub = setup();
|
||||
const ordinary = hub.tree.bind(hub);
|
||||
hub.tree = async (window) => window?.order === "recent" ? [{ root: "~/projects/DeepSeek-Reasonix", name: "DeepSeek-Reasonix", recentOffset: window.offset ?? 0, remaining: 0, sessions: [{
|
||||
path: "/sessions/mock.jsonl", name: "mock", title: "Recent finished", runtimeId: "r1", lastActiveAt: 2000,
|
||||
}] }] : ordinary(window);
|
||||
const open = vi.spyOn(hub, "open");
|
||||
render(<App hub={hub} />);
|
||||
await screen.findByRole("treeitem", { name: "并行会话演示" });
|
||||
const segments = document.querySelector(".studio-session-segments")!;
|
||||
await userEvent.click(within(segments as HTMLElement).getByRole("button", { name: "进行中" }));
|
||||
await userEvent.type(screen.getByRole("searchbox", { name: "搜索会话 / 项目" }), "unrelated");
|
||||
await userEvent.click(screen.getByRole("button", { name: "最近的会话" }));
|
||||
await userEvent.click(await screen.findByRole("button", { name: "Recent finished · DeepSeek-Reasonix" }));
|
||||
await waitFor(() => expect(screen.queryByRole("dialog", { name: "最近的会话" })).toBeNull());
|
||||
expect(open).not.toHaveBeenCalled();
|
||||
expect(within(segments as HTMLElement).getByRole("button", { name: "全部" }).getAttribute("aria-pressed")).toBe("true");
|
||||
expect(screen.getByRole("searchbox", { name: "搜索会话 / 项目" })).toHaveProperty("value", "");
|
||||
expect(await screen.findByRole("treeitem", { name: "并行会话演示" })).toBeTruthy();
|
||||
});
|
||||
|
||||
it("reads remote recent pages only after selecting that machine, retaining host-qualified identity", async () => {
|
||||
const hub = setup();
|
||||
const remote = vi.spyOn(hub, "remoteTree").mockResolvedValue([{ root: "/same", name: "remote project", recentOffset: 0, remaining: 0, sessions: [{ path: "/same/session.jsonl", name: "Remote finished", workspaceRoot: "/same/worktree", lastActiveAt: 5000 }] }]);
|
||||
const picked = vi.fn(async () => {});
|
||||
render(<RecentSessions hub={hub} hosts={[{ name: "gpu-box", target: "mock", status: "stopped" }, { name: "disabled", target: "mock", status: "idle", disabled: true }]} onPick={picked} onClose={() => {}} />);
|
||||
expect(remote).not.toHaveBeenCalled();
|
||||
expect(screen.queryByRole("option", { name: "disabled" })).toBeNull();
|
||||
await userEvent.selectOptions(screen.getByRole("combobox", { name: "运行环境" }), "gpu-box");
|
||||
await userEvent.click(await screen.findByRole("button", { name: "Remote finished · remote project" }));
|
||||
expect(remote).toHaveBeenCalledWith("gpu-box", expect.objectContaining({ order: "recent", offset: 0 }));
|
||||
expect(picked).toHaveBeenCalledWith("gpu-box", expect.objectContaining({ root: "/same" }), expect.objectContaining({ workspaceRoot: "/same/worktree" }));
|
||||
});
|
||||
|
||||
it("keeps a just-completed session discoverable after it leaves Running", async () => {
|
||||
const hub = setup();
|
||||
const port = hub.portFor((await hub.runtimes())[0]);
|
||||
const snapshot = await port.status();
|
||||
let running = true;
|
||||
vi.spyOn(port, "status").mockImplementation(async () => ({ ...snapshot, running }));
|
||||
let deliver: ((event: WireEvent) => void) | undefined;
|
||||
vi.spyOn(port, "subscribe").mockImplementation((listener) => { deliver = listener; return () => {}; });
|
||||
setLiveGraceMinutes(0);
|
||||
render(<App hub={hub} />);
|
||||
await screen.findByRole("treeitem", { name: "并行会话演示" });
|
||||
await waitFor(() => expect(document.querySelector(".studio-runstate[data-running]")).not.toBeNull());
|
||||
const segments = within(document.querySelector(".studio-session-segments") as HTMLElement);
|
||||
await userEvent.click(segments.getByRole("button", { name: "进行中" }));
|
||||
expect(screen.getByRole("treeitem", { name: "并行会话演示" })).toBeTruthy();
|
||||
running = false;
|
||||
act(() => deliver?.({ kind: "turn_done" }));
|
||||
await waitFor(() => expect(screen.queryByRole("treeitem", { name: "并行会话演示" })).toBeNull());
|
||||
await userEvent.click(screen.getByRole("button", { name: "最近的会话" }));
|
||||
await userEvent.click(await screen.findByRole("button", { name: "并行会话演示 · DeepSeek-Reasonix" }));
|
||||
expect(await screen.findByRole("treeitem", { name: "并行会话演示" })).toBeTruthy();
|
||||
expect(segments.getByRole("button", { name: "全部" }).getAttribute("aria-pressed")).toBe("true");
|
||||
});
|
||||
|
||||
it("uses activity rather than unread or pin state, and applies search before paging with the saved legacy preference", async () => {
|
||||
const fixture: TreeWorkspace[] = [{ root: "/w", name: "w", sessions: [
|
||||
{ path: "/w/unread", name: "Unread older", unread: true, lastActiveAt: 100 },
|
||||
{ path: "/w/pinned", name: "Pinned oldest", pinned: true, lastActiveAt: 50 },
|
||||
{ path: "/w/recent", name: "Finished newest", lastActiveAt: 500 },
|
||||
{ path: "/w/archive", name: "Archived hidden", archived: true, lastActiveAt: 1000 },
|
||||
{ path: "/w/legacy", name: "Legacy history", legacy: true, lastActiveAt: 700 },
|
||||
] }];
|
||||
class FixtureHub extends MockHub {
|
||||
tree(window?: TreeWindow) { return Promise.resolve(this.recentTree(fixture, window)); }
|
||||
}
|
||||
const hub = new FixtureHub();
|
||||
const asked = vi.spyOn(hub, "tree");
|
||||
setHidesLegacySessions(true);
|
||||
const view = render(<RecentSessions hub={hub} hosts={[]} onPick={async () => {}} onClose={() => {}} />);
|
||||
await screen.findByRole("button", { name: "Finished newest · w" });
|
||||
expect([...document.querySelectorAll(".recent-session")].map((row) => row.getAttribute("aria-label"))).toEqual(["Finished newest · w", "Unread older · w", "Pinned oldest · w"]);
|
||||
await userEvent.type(screen.getByRole("searchbox", { name: "搜索最近会话 / 项目" }), "Legacy");
|
||||
expect(await screen.findByRole("button", { name: "Legacy history · w" })).toBeTruthy();
|
||||
expect(asked).toHaveBeenLastCalledWith(expect.objectContaining({ q: "Legacy", hideLegacy: true, offset: 0 }));
|
||||
view.unmount();
|
||||
render(<RecentSessions hub={hub} hosts={[]} onPick={async () => {}} onClose={() => {}} />);
|
||||
expect(await screen.findByRole("button", { name: "Finished newest · w" })).toBeTruthy();
|
||||
expect(screen.queryByRole("button", { name: /Legacy history/ })).toBeNull();
|
||||
});
|
||||
|
||||
it("does not mistake an older remote kernel's partial tree for complete recent history", async () => {
|
||||
const hub = setup();
|
||||
hub.remoteTree = async () => [{ root: "/w", name: "w", remaining: 100, sessions: [{ path: "/w/old", name: "Not a recent page" }] }];
|
||||
render(<RecentSessions hub={hub} hosts={[{ name: "old-host", target: "mock", status: "idle" }]} onPick={async () => {}} onClose={() => {}} />);
|
||||
await userEvent.selectOptions(screen.getByRole("combobox", { name: "运行环境" }), "old-host");
|
||||
expect(await screen.findByRole("alert")).toHaveProperty("textContent", "此运行环境不支持最近会话,请更新 Reasonix");
|
||||
expect(screen.queryByRole("button", { name: /Not a recent page/ })).toBeNull();
|
||||
});
|
||||
|
||||
it("keeps failed opens in the picker and reconciles a removed session on retry", async () => {
|
||||
const hub = setup();
|
||||
let removed = false;
|
||||
hub.tree = async () => [{ root: "/w", name: "w", recentOffset: 0, remaining: 0, sessions: removed ? [] : [{ path: "/w/gone", name: "Removed elsewhere" }] }];
|
||||
const pick = vi.fn(async () => { removed = true; throw new Error("No longer available"); });
|
||||
const close = vi.fn();
|
||||
render(<RecentSessions hub={hub} hosts={[]} onPick={pick} onClose={close} />);
|
||||
await userEvent.click(await screen.findByRole("button", { name: "Removed elsewhere · w" }));
|
||||
expect(await screen.findByRole("alert")).toHaveProperty("textContent", "No longer available");
|
||||
expect(close).not.toHaveBeenCalled();
|
||||
await userEvent.click(screen.getByRole("button", { name: "重试" }));
|
||||
expect(await screen.findByText("没有匹配的会话")).toBeTruthy();
|
||||
expect(screen.queryByRole("button", { name: "Removed elsewhere · w" })).toBeNull();
|
||||
});
|
||||
@@ -0,0 +1,27 @@
|
||||
import type { ThemeSchedule } from "../state/themeSchedule";
|
||||
|
||||
function darkAt(start: number, end: number, minute: number): boolean {
|
||||
return start < end ? minute >= start && minute < end : start > end && (minute >= start || minute < end);
|
||||
}
|
||||
|
||||
export function scheduledScheme({ start, end }: ThemeSchedule, now: Date): "light" | "dark" {
|
||||
const from = Number(start.slice(0, 2)) * 60 + Number(start.slice(3));
|
||||
const until = Number(end.slice(0, 2)) * 60 + Number(end.slice(3));
|
||||
return darkAt(from, until, now.getHours() * 60 + now.getMinutes()) ? "dark" : "light";
|
||||
}
|
||||
|
||||
export function nextScheduledBoundary(range: ThemeSchedule, now: Date): number | null {
|
||||
if (range.start === range.end) return null;
|
||||
const from = Number(range.start.slice(0, 2)) * 60 + Number(range.start.slice(3));
|
||||
const until = Number(range.end.slice(0, 2)) * 60 + Number(range.end.slice(3));
|
||||
const current = darkAt(from, until, now.getHours() * 60 + now.getMinutes());
|
||||
const next = new Date(now);
|
||||
next.setSeconds(0, 0);
|
||||
// Walking actual minutes honours skipped and repeated local times at DST changes.
|
||||
// Only the resulting boundary gets a timer; there is no minute polling.
|
||||
for (let minute = 0; minute < 26 * 60; minute++) {
|
||||
next.setTime(next.getTime() + 60_000);
|
||||
if (darkAt(from, until, next.getHours() * 60 + next.getMinutes()) !== current) return next.getTime() - now.getTime();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
// @vitest-environment jsdom
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { sendHint } from "./sendkey";
|
||||
|
||||
afterEach(() => { vi.restoreAllMocks(); vi.resetModules(); });
|
||||
const key = (patch = {}) => ({ key: "Enter", code: "Enter", ctrlKey: false, metaKey: false, altKey: false, shiftKey: false, ...patch });
|
||||
@@ -35,4 +36,11 @@ describe("platform send chords", () => {
|
||||
expect(sendsOnKey(key({ shiftKey: true }), "enter")).toBe(false);
|
||||
expect(sendsOnKey(key(), "enter", true)).toBe(false);
|
||||
});
|
||||
|
||||
it("describes follow-up submission for both send shortcuts and touch without changing idle hints", () => {
|
||||
expect(sendHint("enter", true, false, "followup")).toBe("Enter 排队 · Shift+Enter 换行");
|
||||
expect(sendHint("modifier_enter", true, false, "followup")).toContain("排队 · Enter 换行");
|
||||
expect(sendHint("enter", true, true, "followup")).toBe("点按排队 · 回车换行");
|
||||
expect(sendHint("enter", false, false, "followup")).toBe(sendHint("enter", false, false, "steer"));
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,5 @@
|
||||
import { t } from "../i18n";
|
||||
import type { SendShortcut } from "../state/prefs";
|
||||
import type { RunningMessageIntent, SendShortcut } from "../state/prefs";
|
||||
import { ariaChord, chord, pressedChord } from "./keys";
|
||||
|
||||
type Key = { key: string; code: string; metaKey: boolean; ctrlKey: boolean; altKey: boolean; shiftKey: boolean };
|
||||
@@ -13,7 +13,12 @@ export function sendAria(mode: SendShortcut, touch = false): string | undefined
|
||||
return mode === "modifier_enter" ? ariaChord("Enter") : touch ? undefined : "Enter Shift+Enter";
|
||||
}
|
||||
|
||||
export function sendHint(mode: SendShortcut, running: boolean, touch: boolean): string {
|
||||
export function sendHint(mode: SendShortcut, running: boolean, touch: boolean, intent: RunningMessageIntent = "steer"): string {
|
||||
if (running && intent === "followup") {
|
||||
if (touch) return t("点按排队 · 回车换行");
|
||||
if (mode === "modifier_enter") return t("{key} 排队 · Enter 换行", { key: chord("Enter") });
|
||||
return t("Enter 排队 · Shift+Enter 换行");
|
||||
}
|
||||
if (touch) return t(running ? "点按插话 · 回车换行" : "点按发送 · 回车换行");
|
||||
if (mode === "modifier_enter") return t(running ? "{key} 插话 · Enter 换行" : "{key} 发送 · Enter 换行", { key: chord("Enter") });
|
||||
return t(running ? "Enter 插话 · Shift+Enter 换行" : "Enter 发送 · Shift+Enter 换行");
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { inScope, listedAsLive } from "./sessionScope";
|
||||
import { inScope, listedAsLive, sessionRuntime } from "./sessionScope";
|
||||
|
||||
const live = { path: "/a.jsonl", name: "a", archived: false };
|
||||
const archived = { path: "/b.jsonl", name: "b", archived: true };
|
||||
@@ -40,3 +40,15 @@ describe("a finished session stays listed as running for the grace the reader ch
|
||||
expect(listedAsLive(false, undefined, 5 * MIN, 30)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
it("resolves open session identity by host and path without trusting a stale runtime ID", () => {
|
||||
const runtimes = [
|
||||
{ id: "local", base: "", root: "/w", name: "local", sessionPath: "/same.jsonl" },
|
||||
{ id: "remote", base: "", root: "/w", name: "remote", host: "gpu", sessionPath: "/same.jsonl" },
|
||||
{ id: "changed", base: "", root: "/w", name: "changed", sessionPath: "/other.jsonl" },
|
||||
];
|
||||
const session = { path: "/same.jsonl", name: "same", runtimeId: "changed" };
|
||||
expect(sessionRuntime(session, runtimes)?.id).toBe("local");
|
||||
expect(sessionRuntime(session, runtimes, "gpu")?.id).toBe("remote");
|
||||
expect(sessionRuntime({ ...session, path: "/closed.jsonl" }, runtimes)).toBeUndefined();
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { TreeSession } from "../port/hub";
|
||||
import type { RuntimeView, TreeSession } from "../port/hub";
|
||||
|
||||
export type SessionScope = "all" | "live" | "pinned" | "archived";
|
||||
|
||||
@@ -20,3 +20,8 @@ export function inScope(session: TreeSession, scope: SessionScope, searching: bo
|
||||
export function listedAsLive(live: boolean, finishedAt: number | undefined, now: number, graceMinutes: number): boolean {
|
||||
return live || (graceMinutes > 0 && finishedAt !== undefined && now - finishedAt < graceMinutes * 60_000);
|
||||
}
|
||||
|
||||
export function sessionRuntime(session: TreeSession, runtimes: RuntimeView[], host = ""): RuntimeView | undefined {
|
||||
return runtimes.find((runtime) => (runtime.host || "") === host &&
|
||||
(runtime.sessionPath === session.path || (!runtime.sessionPath && runtime.id === session.runtimeId)));
|
||||
}
|
||||
@@ -16,8 +16,8 @@ export const PAGES: Partial<Record<Section, readonly SettingPage[]>> = {
|
||||
appearance: [
|
||||
{ id: "look", label: "外观与主题", anchors: ["mode", "scheme", "font", "size", "weight", "contrast", "effects", "wallpaper", "language", "advanced-appearance"] },
|
||||
{ id: "navigation", label: "导航与侧栏", anchors: ["navrail", "naventries", "session-quick-actions", "legacy-sessions", "live-grace", "open-session-limit", "live-stream-budget", "session-tabs"] },
|
||||
{ id: "conversation", label: "会话显示", anchors: ["new-answer-marker", "folding", "plan-progress"] },
|
||||
{ id: "shortcuts", label: "快捷键", anchors: ["send-shortcut", "global-shortcut", "keys", "esc-stop"] },
|
||||
{ id: "conversation", label: "会话显示", anchors: ["new-answer-marker", "composer-hint", "folding", "plan-progress"] },
|
||||
{ id: "shortcuts", label: "快捷键", anchors: ["send-shortcut", "running-message-intent", "esc-stop", "global-shortcut", "keys"] },
|
||||
{ id: "window", label: "窗口与性能", anchors: ["window", "notify", "page-sleep", "graphics"] },
|
||||
],
|
||||
};
|
||||
|
||||
@@ -93,6 +93,18 @@ describe("pages inside the appearance section", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("the shortcuts page", () => {
|
||||
it("lists what stops or sends a turn together, before the key table", async () => {
|
||||
draw("appearance");
|
||||
await userEvent.click(await screen.findByRole("tab", { name: /快捷键/ }));
|
||||
await waitFor(() => expect(shown("send-shortcut")).toBe(true));
|
||||
const order = [...document.querySelectorAll<HTMLElement>("[data-setting]")]
|
||||
.filter((el) => shown(el.dataset.setting ?? ""))
|
||||
.map((el) => el.dataset.setting);
|
||||
expect(order).toEqual(["send-shortcut", "running-message-intent", "esc-stop", "keys"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("what wraps a setting leaves with it", () => {
|
||||
it("hides the disclosure around a setting together with the setting", async () => {
|
||||
draw("appearance:navrail");
|
||||
|
||||
@@ -5,6 +5,7 @@ import userEvent from "@testing-library/user-event";
|
||||
import "./testkit";
|
||||
import { Settings } from "./Settings";
|
||||
import { SETTINGS, SECTION_NAME, SETTING_AT, settingMatches } from "./prefsnav";
|
||||
import { storageTotal } from "./storageTally";
|
||||
import { boot, STORAGE, t } from "../i18n";
|
||||
import { MockPort } from "../port/mock";
|
||||
import { MockHub } from "../port/mock_hub";
|
||||
@@ -14,7 +15,7 @@ afterEach(() => { cleanup(); vi.restoreAllMocks(); localStorage.setItem(STORAGE,
|
||||
const language = (lang: string) => { localStorage.setItem(STORAGE, lang); boot(); };
|
||||
const block = (anchor: string) => document.querySelector<HTMLElement>(`[data-setting="${anchor}"]`);
|
||||
|
||||
function draw() {
|
||||
async function draw() {
|
||||
const port = new MockPort();
|
||||
const calls: string[] = [];
|
||||
const watched = new Proxy(port, {
|
||||
@@ -25,6 +26,11 @@ function draw() {
|
||||
},
|
||||
}) as unknown as AgentPort;
|
||||
const onClose = vi.fn();
|
||||
// The pane tallies the disk usage a beat after it opens, and that walk reads
|
||||
// the port. A known total means no walk is scheduled at all, so the search
|
||||
// is measured against the port as it stood — not against a timer that fires
|
||||
// midway through the search and reads the port on the way past.
|
||||
await storageTotal(watched);
|
||||
render(<Settings
|
||||
hub={new MockHub() as never} port={watched}
|
||||
status={{ preset: "balanced", toolApprovalMode: "ask" } as SessionStatus}
|
||||
@@ -41,7 +47,7 @@ it.each(["market", "plugins", "mcp", "skills", "ext-runtime", "hooks", "composit
|
||||
async (anchor) => {
|
||||
language("en");
|
||||
const entry = SETTING_AT(anchor)!;
|
||||
const { find, calls } = draw();
|
||||
const { find, calls } = await draw();
|
||||
await act(async () => {});
|
||||
expect(block(anchor)).toBeNull();
|
||||
const reads = [...calls];
|
||||
@@ -66,7 +72,7 @@ it.each(["market", "plugins", "mcp", "skills", "ext-runtime", "hooks", "composit
|
||||
|
||||
it("finds extension blocks by the displayed section name without mounting them", async () => {
|
||||
language("en");
|
||||
const { find, calls, onClose } = draw();
|
||||
const { find, calls, onClose } = await draw();
|
||||
await act(async () => {});
|
||||
const reads = [...calls];
|
||||
await userEvent.type(find(), "Extension");
|
||||
|
||||
@@ -44,6 +44,22 @@ function draw() {
|
||||
}
|
||||
|
||||
describe("finding a setting", () => {
|
||||
it("finds the composer hint and opens its immediate machine-local control", async () => {
|
||||
const { find, block } = draw();
|
||||
await userEvent.type(find(), "placeholder");
|
||||
const hit = await screen.findByRole("option", { name: /输入框提示/ });
|
||||
expect(hit.getAttribute("data-target")).toBe("composer-hint");
|
||||
await userEvent.click(hit);
|
||||
await waitFor(() => expect(block("composer-hint")).toBeTruthy());
|
||||
const section = block("composer-hint")!;
|
||||
expect(section.closest("[hidden]")).toBeNull();
|
||||
expect(section.querySelector("[data-apply]")?.getAttribute("data-apply")).toBe("immediate");
|
||||
expect(section.querySelector("[data-scope]")?.getAttribute("data-scope")).toBe("machine");
|
||||
await userEvent.click(section.querySelector<HTMLButtonElement>('[data-value="off"]')!);
|
||||
expect(localStorage.getItem("rx-composer-hint")).toBe("off");
|
||||
await userEvent.click(section.querySelector<HTMLButtonElement>('[data-value="on"]')!);
|
||||
expect(localStorage.getItem("rx-composer-hint")).toBe("on");
|
||||
});
|
||||
it("finds collapsed todo progress and opens its immediate machine-local control", async () => {
|
||||
const { find, block } = draw();
|
||||
await userEvent.type(find(), "todo");
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
// @vitest-environment jsdom
|
||||
import { act, cleanup, fireEvent, render, renderHook, screen } from "@testing-library/react";
|
||||
import { afterEach, beforeEach, expect, it, vi } from "vitest";
|
||||
import "./testkit";
|
||||
import { Appearance } from "./Appearance";
|
||||
import { usePaint } from "./paint";
|
||||
import { nextScheduledBoundary, scheduledScheme } from "./scheduledTheme";
|
||||
import { setThemeSchedule, themeSchedule } from "../state/themeSchedule";
|
||||
import { MockHub } from "../port/mock_hub";
|
||||
import { boot, STORAGE, t } from "../i18n";
|
||||
|
||||
const overnight = { start: "20:00", end: "07:00" };
|
||||
const at = (hour: number, minute = 0, second = 0) => new Date(2026, 9, 29, hour, minute, second);
|
||||
const fail = vi.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
localStorage.setItem(STORAGE, "zh");
|
||||
boot();
|
||||
setThemeSchedule(overnight);
|
||||
vi.spyOn(HTMLCanvasElement.prototype, "getContext").mockReturnValue(null);
|
||||
});
|
||||
afterEach(() => { cleanup(); vi.useRealTimers(); vi.restoreAllMocks(); vi.unstubAllEnvs(); });
|
||||
|
||||
it.each([
|
||||
[overnight, 19, 59, "light"], [overnight, 20, 0, "dark"],
|
||||
[overnight, 0, 0, "dark"], [overnight, 6, 59, "dark"], [overnight, 7, 0, "light"],
|
||||
[{ start: "09:00", end: "17:00" }, 9, 0, "dark"],
|
||||
[{ start: "09:00", end: "17:00" }, 17, 0, "light"],
|
||||
[{ start: "07:00", end: "07:00" }, 7, 0, "light"],
|
||||
] as const)("resolves a local half-open daily range %o at %i:%i", (range, hour, minute, scheme) => {
|
||||
expect(scheduledScheme(range, at(hour, minute))).toBe(scheme);
|
||||
});
|
||||
|
||||
it("waits only until a real transition, including overnight and sub-minute boundaries", () => {
|
||||
expect(nextScheduledBoundary(overnight, at(19, 59, 59))).toBe(1000);
|
||||
expect(nextScheduledBoundary(overnight, at(23))).toBe(8 * 60 * 60 * 1000);
|
||||
expect(nextScheduledBoundary({ start: "07:00", end: "07:00" }, at(12))).toBeNull();
|
||||
});
|
||||
|
||||
it("persists a valid range, ignores incomplete edits, and defaults corrupt storage", () => {
|
||||
setThemeSchedule({ start: "18:30", end: "06:45" });
|
||||
expect(themeSchedule()).toEqual({ start: "18:30", end: "06:45" });
|
||||
expect(JSON.parse(localStorage.getItem("rx-theme-schedule")!)).toEqual(themeSchedule());
|
||||
setThemeSchedule({ start: "", end: "24:00" });
|
||||
expect(themeSchedule()).toEqual({ start: "18:30", end: "06:45" });
|
||||
localStorage.setItem("rx-theme-schedule", '{"start":"99:00","end":"07:00"}');
|
||||
expect(themeSchedule()).toEqual(overnight);
|
||||
localStorage.setItem("rx-theme-schedule", "not JSON");
|
||||
expect(themeSchedule()).toEqual(overnight);
|
||||
});
|
||||
|
||||
it("keeps a choice for this window when persistence is unavailable", () => {
|
||||
vi.spyOn(Storage.prototype, "setItem").mockImplementation(() => { throw new DOMException("unavailable"); });
|
||||
setThemeSchedule({ start: "21:00", end: "08:00" });
|
||||
expect(themeSchedule()).toEqual({ start: "21:00", end: "08:00" });
|
||||
});
|
||||
|
||||
async function setup() {
|
||||
const hub = new MockHub();
|
||||
const runtimes = await hub.runtimes();
|
||||
const port = hub.machinePort();
|
||||
vi.spyOn(port, "themes").mockResolvedValue([{ id: "test", name: "Test", active: true,
|
||||
tokens: { light: { bg: "#eeeeee", fg: "#111111" }, dark: { bg: "#111111", fg: "#eeeeee" } } }]);
|
||||
const hook = renderHook(() => usePaint(hub, runtimes, false, fail));
|
||||
await act(async () => {});
|
||||
return { hub, port, ...hook };
|
||||
}
|
||||
const packed = () => ((document.getElementById("pack-theme") as HTMLStyleElement)?.sheet?.cssRules[0] as CSSStyleRule)?.style;
|
||||
|
||||
it("repaints active packs and contrast exactly at both boundaries, and restores mode/range after remount", async () => {
|
||||
localStorage.setItem("rx-theme", "scheduled");
|
||||
localStorage.setItem("rx-contrast", "strong");
|
||||
vi.useFakeTimers(); vi.setSystemTime(at(19, 59, 59));
|
||||
const first = await setup();
|
||||
expect(first.result.current.scheme).toBe("light");
|
||||
expect(packed().getPropertyValue("--page")).toBe("#eeeeee");
|
||||
expect(localStorage.getItem("rx-theme-resolved")).toBe("light");
|
||||
act(() => vi.advanceTimersByTime(1000));
|
||||
expect(first.result.current.scheme).toBe("dark");
|
||||
expect(document.documentElement.dataset.theme).toBe("dark");
|
||||
expect(packed().getPropertyValue("--page")).toBe("#111111");
|
||||
expect(packed().getPropertyValue("--text")).toContain("+ 0.101");
|
||||
expect(localStorage.getItem("rx-theme-resolved")).toBe("dark");
|
||||
act(() => vi.advanceTimersByTime(11 * 60 * 60 * 1000));
|
||||
expect(first.result.current.scheme).toBe("light");
|
||||
first.unmount();
|
||||
const second = await setup();
|
||||
expect(second.result.current.theme).toBe("scheduled");
|
||||
expect(second.result.current.scheme).toBe("light");
|
||||
expect(themeSchedule()).toEqual(overnight);
|
||||
});
|
||||
|
||||
it("reconciles sleep, visibility and clock changes on focus without replaying missed boundaries", async () => {
|
||||
localStorage.setItem("rx-theme", "scheduled");
|
||||
vi.useFakeTimers(); vi.setSystemTime(at(19));
|
||||
const { result } = await setup();
|
||||
expect(vi.getTimerCount()).toBe(1);
|
||||
vi.setSystemTime(at(23));
|
||||
act(() => window.dispatchEvent(new Event("focus")));
|
||||
expect(result.current.scheme).toBe("dark");
|
||||
vi.setSystemTime(at(8));
|
||||
act(() => document.dispatchEvent(new Event("visibilitychange")));
|
||||
expect(result.current.scheme).toBe("light");
|
||||
expect(vi.getTimerCount()).toBe(1);
|
||||
});
|
||||
|
||||
it("keeps explicit and system modes authoritative and cancels the schedule when leaving it", async () => {
|
||||
vi.useFakeTimers(); vi.setSystemTime(at(23));
|
||||
const { result } = await setup();
|
||||
act(() => result.current.setTheme("light"));
|
||||
act(() => setThemeSchedule({ start: "00:00", end: "23:59" }));
|
||||
expect(result.current.scheme).toBe("light");
|
||||
act(() => result.current.setTheme("dark"));
|
||||
expect(result.current.scheme).toBe("dark");
|
||||
act(() => result.current.setTheme("auto"));
|
||||
expect(result.current.scheme).toBe(matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light");
|
||||
act(() => result.current.setTheme("scheduled"));
|
||||
expect(result.current.scheme).toBe("dark");
|
||||
expect(vi.getTimerCount()).toBe(1);
|
||||
act(() => result.current.setTheme("light"));
|
||||
expect(vi.getTimerCount()).toBe(0);
|
||||
act(() => vi.advanceTimersByTime(24 * 60 * 60 * 1000));
|
||||
expect(result.current.scheme).toBe("light");
|
||||
});
|
||||
|
||||
it("changes the real paint through appearance controls and leaves contrast, palettes and fixed modes available", async () => {
|
||||
vi.useFakeTimers(); vi.setSystemTime(at(21));
|
||||
const { port, result } = await setup();
|
||||
function Picker() {
|
||||
const p = result.current;
|
||||
return <Appearance port={port} theme={p.theme} onTheme={p.setTheme} contrast={p.contrast} onContrast={p.setContrast}
|
||||
weight={p.weight} onWeight={p.setWeight} look={p.look} onLook={p.onLook} reloadThemes={p.reloadThemes} />;
|
||||
}
|
||||
const view = render(<Picker />);
|
||||
expect(screen.queryByLabelText(t("深色开始时间"))).toBeNull();
|
||||
fireEvent.click(screen.getByRole("button", { name: t("定时") }));
|
||||
view.rerender(<Picker />);
|
||||
expect(result.current.scheme).toBe("dark");
|
||||
fireEvent.change(screen.getByLabelText(t("深色开始时间")), { target: { value: "22:00" } });
|
||||
expect(result.current.scheme).toBe("light");
|
||||
expect(screen.getByLabelText(t("恢复浅色时间"))).toBeTruthy();
|
||||
expect(screen.getByRole("group", { name: t("界面主题") })).toBeTruthy();
|
||||
fireEvent.click(view.container.querySelector(".appearance-advanced summary")!);
|
||||
fireEvent.click(screen.getByRole("button", { name: t("更强") }));
|
||||
view.rerender(<Picker />);
|
||||
expect(result.current.contrast).toBe("strong");
|
||||
fireEvent.click(screen.getByRole("button", { name: t("深色") }));
|
||||
view.rerender(<Picker />);
|
||||
expect(result.current.scheme).toBe("dark");
|
||||
expect(screen.queryByLabelText(t("深色开始时间"))).toBeNull();
|
||||
expect(themeSchedule().start).toBe("22:00");
|
||||
});
|
||||
@@ -0,0 +1,16 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import type { AgentPort, WorkspaceInfo } from "../port/port";
|
||||
|
||||
export function useWorkspaceInfo(port: AgentPort | null, root: string, ready: boolean) {
|
||||
const [read, setRead] = useState<{ port: AgentPort; root: string; value: WorkspaceInfo } | null>(null);
|
||||
useEffect(() => {
|
||||
if (!port || !root || !ready) return;
|
||||
let current = true;
|
||||
setRead(null);
|
||||
void port.workspaces().then((value) => {
|
||||
if (current) setRead({ port, root, value });
|
||||
}).catch(() => {});
|
||||
return () => { current = false; };
|
||||
}, [port, root, ready]);
|
||||
return ready && read?.port === port && read.root === root ? read.value : null;
|
||||
}
|
||||
@@ -1,8 +1,11 @@
|
||||
// @vitest-environment jsdom
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { renderHook } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { act, cleanup, renderHook } from "@testing-library/react";
|
||||
import { useSubmit } from "./usesubmit";
|
||||
import { HttpError, type AgentPort } from "../port/port";
|
||||
import { setRunningMessageIntent, type RunningMessageIntent } from "../state/prefs";
|
||||
|
||||
afterEach(() => { cleanup(); localStorage.clear(); vi.restoreAllMocks(); });
|
||||
|
||||
const hook = (submit: () => Promise<unknown>) => {
|
||||
const dispatch = vi.fn();
|
||||
@@ -29,4 +32,57 @@ describe("useSubmit", () => {
|
||||
const { send } = hook(async () => undefined);
|
||||
expect(await send("hello")).toBe(true);
|
||||
});
|
||||
|
||||
it.each<RunningMessageIntent>(["steer", "followup"])("routes a running message through the selected canonical intent: %s", async (intent) => {
|
||||
setRunningMessageIntent(intent);
|
||||
const port = {
|
||||
steer: vi.fn(async () => ({ itemId: "steer", disposition: "steer_accepted" })),
|
||||
queueFollowup: vi.fn(async () => ({ itemId: "followup", disposition: "queued_followup" })),
|
||||
submit: vi.fn(),
|
||||
};
|
||||
const dispatch = vi.fn();
|
||||
const { result } = renderHook(() => useSubmit({ port: port as unknown as AgentPort, running: true, dispatch, trajDispatch: vi.fn(), refreshStatus: vi.fn(), fail: vi.fn() }));
|
||||
expect(await result.current("later")).toBe(true);
|
||||
expect(port.steer).toHaveBeenCalledTimes(intent === "steer" ? 1 : 0);
|
||||
expect(port.queueFollowup).toHaveBeenCalledTimes(intent === "followup" ? 1 : 0);
|
||||
expect(port.submit).not.toHaveBeenCalled();
|
||||
expect(dispatch).toHaveBeenCalledWith(expect.objectContaining({ kind: "__queued", queued: intent }));
|
||||
});
|
||||
|
||||
it("uses a changed preference on the next message without remounting", async () => {
|
||||
const port = { steer: vi.fn(), queueFollowup: vi.fn() };
|
||||
const { result } = renderHook(() => useSubmit({ port: port as unknown as AgentPort, running: true, dispatch: vi.fn(), trajDispatch: vi.fn(), refreshStatus: vi.fn(), fail: vi.fn() }));
|
||||
await result.current("steer first");
|
||||
act(() => setRunningMessageIntent("followup"));
|
||||
await result.current("follow up next");
|
||||
expect(port.steer).toHaveBeenCalledWith("steer first");
|
||||
expect(port.queueFollowup).toHaveBeenCalledWith("follow up next", undefined);
|
||||
});
|
||||
|
||||
it.each<RunningMessageIntent>(["steer", "followup"])("leaves idle submission unchanged with %s selected", async (intent) => {
|
||||
setRunningMessageIntent(intent);
|
||||
const submit = vi.fn(async () => undefined);
|
||||
const { send } = hook(submit);
|
||||
expect(await send("idle")).toBe(true);
|
||||
expect(submit).toHaveBeenCalledWith("idle", undefined);
|
||||
});
|
||||
|
||||
it("keeps skill invocations as follow-ups even with steering selected", async () => {
|
||||
const port = { steer: vi.fn(), queueFollowup: vi.fn(async () => ({ itemId: "skill" })) };
|
||||
const dispatch = vi.fn();
|
||||
const { result } = renderHook(() => useSubmit({ port: port as unknown as AgentPort, running: true, dispatch, trajDispatch: vi.fn(), refreshStatus: vi.fn(), fail: vi.fn() }));
|
||||
const chips = { submit: "review", invocations: [] };
|
||||
await result.current("review", chips);
|
||||
expect(port.steer).not.toHaveBeenCalled();
|
||||
expect(port.queueFollowup).toHaveBeenCalledWith("review", chips);
|
||||
expect(dispatch).toHaveBeenCalledWith(expect.objectContaining({ kind: "__queued", queued: "followup" }));
|
||||
});
|
||||
|
||||
it("uses the canonical receipt when an undeliverable steer becomes a follow-up", async () => {
|
||||
const port = { steer: vi.fn(async () => ({ itemId: "converted", disposition: "queued_followup" })) };
|
||||
const dispatch = vi.fn();
|
||||
const { result } = renderHook(() => useSubmit({ port: port as unknown as AgentPort, running: true, dispatch, trajDispatch: vi.fn(), refreshStatus: vi.fn(), fail: vi.fn() }));
|
||||
await result.current("just missed the turn");
|
||||
expect(dispatch).toHaveBeenCalledWith(expect.objectContaining({ kind: "__queued", queued: "followup" }));
|
||||
});
|
||||
});
|
||||
@@ -2,6 +2,7 @@ import { useCallback, type Dispatch } from "react";
|
||||
import { HttpError, type AgentPort, type ChipCall } from "../port/port";
|
||||
import { localId } from "../state/session";
|
||||
import type { reduceTraj } from "../state/trajectory";
|
||||
import { useRunningMessageIntent } from "../state/prefs";
|
||||
|
||||
/** What came of one line: sent, taken back, or refused because this
|
||||
* connection cannot run a `!` command at all. The third is a different thing to
|
||||
@@ -24,6 +25,7 @@ export function useSubmit({ port, running, dispatch, trajDispatch, refreshStatus
|
||||
refreshStatus: () => void;
|
||||
fail: (e: unknown) => void;
|
||||
}) {
|
||||
const runningIntent = useRunningMessageIntent();
|
||||
// The kernel refuses a submit it cannot start with a code, not a sentence:
|
||||
// the words are fine, the timing is not. Queueing them is what that code
|
||||
// asks for.
|
||||
@@ -44,16 +46,15 @@ export function useSubmit({ port, running, dispatch, trajDispatch, refreshStatus
|
||||
|
||||
return useCallback(
|
||||
async (text: string, chips?: ChipCall): Promise<Submitted> => {
|
||||
const steering = running;
|
||||
const queuedWhileRunning = running;
|
||||
const id = localId();
|
||||
dispatch({ kind: "__user", text, pending: steering, id } as never);
|
||||
dispatch({ kind: "__user", text, pending: queuedWhileRunning, id } as never);
|
||||
trajDispatch({ kind: "__user", text });
|
||||
try {
|
||||
if (steering) {
|
||||
// The row is already on screen; the receipt is what gives it a name
|
||||
// to be taken back by while it waits at the tool boundary.
|
||||
const queued = chips ? await port.queueFollowup(text, chips) : await port.steer(text);
|
||||
if (queued?.itemId) dispatch({ kind: "__queued", id, itemId: queued.itemId, queued: chips || queued.paused ? "followup" : "steer", paused: queued.paused } as never);
|
||||
if (queuedWhileRunning) {
|
||||
const followup = !!chips || runningIntent === "followup";
|
||||
const queued = followup ? await port.queueFollowup(text, chips) : await port.steer(text);
|
||||
if (queued?.itemId) dispatch({ kind: "__queued", id, itemId: queued.itemId, queued: followup || queued.paused || queued.disposition === "queued_followup" ? "followup" : "steer", paused: queued.paused } as never);
|
||||
} else {
|
||||
await submitOrQueue(text, id, chips);
|
||||
}
|
||||
@@ -64,6 +65,6 @@ export function useSubmit({ port, running, dispatch, trajDispatch, refreshStatus
|
||||
return e instanceof HttpError && e.reason?.code === "shell.unavailable_over_http" ? "shell_unavailable" : false;
|
||||
}
|
||||
},
|
||||
[port, running, dispatch, trajDispatch, submitOrQueue, fail],
|
||||
[port, running, runningIntent, dispatch, trajDispatch, submitOrQueue, fail],
|
||||
);
|
||||
}
|
||||
@@ -12,12 +12,12 @@ function draw(fresh = true, branch = "main", isolated = false) {
|
||||
const readSources = port.worktreeSources.bind(port);
|
||||
vi.spyOn(port, "worktreeSources").mockImplementation(async () => ({ ...await readSources(), sourceRoot: "/work/demo" }));
|
||||
vi.spyOn(port, "status").mockResolvedValue({ workspaceRoot: "~/projects/DeepSeek-Reasonix" } as SessionStatus);
|
||||
vi.spyOn(port, "workspaces").mockResolvedValue({ current: "/work/demo", canSwitch: true, canIsolate: true, recents: [], isolated });
|
||||
const workspace = { current: "/work/demo", canSwitch: true, canIsolate: true, recents: [], isolated };
|
||||
const isolate = vi.spyOn(port, "isolateWorkspace").mockResolvedValue();
|
||||
const submit = vi.fn(async () => true);
|
||||
const error = vi.fn();
|
||||
const status = { workspaceRoot: "/work/demo", modelRef: "deepseek/deepseek-v4-pro", preset: "balanced", toolApprovalMode: "ask", plan: false } as SessionStatus;
|
||||
render(<Composer port={port} status={status} running={false} fresh={fresh} git={{ repo: true, name: "demo", branch, detached: false, added: 0, removed: 0, untracked: 0 }} onSubmit={submit} onChanged={vi.fn()} onTreeChanged={vi.fn()} onError={error} />);
|
||||
render(<Composer port={port} workspace={workspace} status={status} running={false} fresh={fresh} git={{ repo: true, name: "demo", branch, detached: false, added: 0, removed: 0, untracked: 0 }} onSubmit={submit} onChanged={vi.fn()} onTreeChanged={vi.fn()} onError={error} />);
|
||||
return { port, isolate, submit, error };
|
||||
}
|
||||
|
||||
|
||||
@@ -1,14 +1,29 @@
|
||||
// @vitest-environment jsdom
|
||||
import type { ComponentProps } from "react";
|
||||
import { afterEach, expect, it, vi } from "vitest";
|
||||
import { act, cleanup, render, screen } from "@testing-library/react";
|
||||
import { act, cleanup, render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import "./testkit";
|
||||
import { Workspaces } from "./Workspaces";
|
||||
import { Chrome } from "./Chrome";
|
||||
import { Chrome as ChromeView } from "./Chrome";
|
||||
import { MockPort } from "../port/mock";
|
||||
import type { TreeWorkspace } from "../port/hub";
|
||||
import type { WorkspaceInfo } from "../port/port";
|
||||
import { useWorkspaceInfo } from "./useWorkspaceInfo";
|
||||
|
||||
function Chrome(props: Omit<ComponentProps<typeof ChromeView>, "workspace">) {
|
||||
const workspace = useWorkspaceInfo(props.port, props.status?.workspaceRoot || props.status?.cwd || "", props.status !== null);
|
||||
return <ChromeView {...props} workspace={workspace} />;
|
||||
}
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
function deferred<T>() {
|
||||
let resolve!: (value: T) => void;
|
||||
const promise = new Promise<T>((done) => { resolve = done; });
|
||||
return { promise, resolve };
|
||||
}
|
||||
|
||||
const root = "/projects/site";
|
||||
const copy = "/state/worktrees/one/site";
|
||||
const path = "/sessions/isolated.jsonl";
|
||||
@@ -63,17 +78,51 @@ it.each([false, true])("only shows a directory badge when isolated=%s", async (i
|
||||
}
|
||||
});
|
||||
|
||||
it("ignores old checkout metadata when switching conversations before it arrives", async () => {
|
||||
it.each([root, copy])("ignores old checkout metadata when switching ports to %s", async (current) => {
|
||||
const old = new MockPort();
|
||||
const next = new MockPort();
|
||||
let finish!: (value: Awaited<ReturnType<MockPort["workspaces"]>>) => void;
|
||||
vi.spyOn(old, "workspaces").mockReturnValue(new Promise((resolve) => { finish = resolve; }));
|
||||
vi.spyOn(next, "workspaces").mockResolvedValue({ current: root, isolated: false, canSwitch: true, canIsolate: true, recents: [] });
|
||||
const { promise, resolve } = deferred<WorkspaceInfo>();
|
||||
vi.spyOn(old, "workspaces").mockReturnValue(promise);
|
||||
const reads = vi.spyOn(next, "workspaces").mockResolvedValue({ current, isolated: false, canSwitch: true, canIsolate: true, recents: [] });
|
||||
const props = { title: "工作", steer: 0, account: null, theme: "dark" as const,
|
||||
onTheme: vi.fn(), onFind: vi.fn(), view: "flow" as const, rows: 0, surfaces: 0, onView: vi.fn() };
|
||||
const { rerender } = render(<Chrome {...props} port={old} status={{ workspaceRoot: copy } as never} />);
|
||||
await act(async () => { rerender(<Chrome {...props} port={next} status={{ workspaceRoot: root } as never} />); });
|
||||
await act(async () => finish({ current: copy, isolated: true, canSwitch: true, canIsolate: true, recents: [] }));
|
||||
await act(async () => { rerender(<Chrome {...props} port={next} status={{ workspaceRoot: current } as never} />); });
|
||||
await act(async () => resolve({ current: copy, isolated: true, canSwitch: true, canIsolate: true, recents: [] }));
|
||||
expect(screen.queryByText("项目目录")).toBeNull();
|
||||
expect(screen.queryByText("独立工作树")).toBeNull();
|
||||
expect(reads).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("reads again for a changed root on the same port and rejects its stale response", async () => {
|
||||
const port = new MockPort();
|
||||
const { promise, resolve } = deferred<WorkspaceInfo>();
|
||||
const reads = vi.spyOn(port, "workspaces")
|
||||
.mockReturnValueOnce(promise)
|
||||
.mockResolvedValue({ current: root, isolated: true, canSwitch: true, canIsolate: true, recents: [] });
|
||||
const props = { port, title: "工作", steer: 0, account: null, theme: "dark" as const,
|
||||
onTheme: vi.fn(), onFind: vi.fn(), view: "flow" as const, rows: 0, surfaces: 0, onView: vi.fn() };
|
||||
const { rerender } = render(<Chrome {...props} status={{ workspaceRoot: copy } as never} />);
|
||||
rerender(<Chrome {...props} status={{ workspaceRoot: root } as never} />);
|
||||
await waitFor(() => expect(screen.getByText("独立工作树").title).toContain(root));
|
||||
await act(async () => resolve({ current: copy, isolated: true, canSwitch: true, canIsolate: true, recents: [] }));
|
||||
expect(screen.getByText("独立工作树").title).toContain(root);
|
||||
expect(reads).toHaveBeenCalledTimes(2);
|
||||
rerender(<Chrome {...props} status={{ workspaceRoot: root, running: true, sessionPath: path } as never} />);
|
||||
expect(reads).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("waits for a directory, accepts cwd, and clears the badge when the pane closes", async () => {
|
||||
const port = new MockPort();
|
||||
const reads = vi.spyOn(port, "workspaces").mockResolvedValue({ current: root, isolated: true, canSwitch: true, canIsolate: true, recents: [] });
|
||||
const props = { title: "工作", steer: 0, account: null, theme: "dark" as const,
|
||||
onTheme: vi.fn(), onFind: vi.fn(), view: "flow" as const, rows: 0, surfaces: 0, onView: vi.fn() };
|
||||
const { rerender } = render(<Chrome {...props} port={null} status={null} />);
|
||||
rerender(<Chrome {...props} port={port} status={{} as never} />);
|
||||
expect(reads).not.toHaveBeenCalled();
|
||||
rerender(<Chrome {...props} port={port} status={{ cwd: root } as never} />);
|
||||
await screen.findByText("独立工作树");
|
||||
expect(reads).toHaveBeenCalledTimes(1);
|
||||
rerender(<Chrome {...props} port={null} status={null} />);
|
||||
expect(screen.queryByText("独立工作树")).toBeNull();
|
||||
});
|
||||
@@ -55,6 +55,8 @@ reasonix plugin doctor <name>
|
||||
|
||||
Reports component status, plan, effect receipts, recoverability, lifecycle metrics, and the process-local `runtimeOwnerFallbacks` count. Product boot binds an isolated owner; a non-zero fallback count identifies compatibility code that reached the shared default owner and needs explicit wiring.
|
||||
|
||||
With no live runtime to ask (the CLI's case), recoverability and resume are reported as unknown, which JSON writes as `null`: effect receipts live only in the process that owned the runtime.
|
||||
|
||||
Plan diagnostics separate two facts: `prefixChanged` is computed after build by comparing the previous and current snapshot `CacheHash`; `providerChanged` reports provider capability additions, removals, or reloads.
|
||||
|
||||
A provider-only backend roll therefore reports `prefixChanged=false, providerChanged=true` when the provider-visible system prompt and tool schemas remain byte-identical.
|
||||
|
||||
+201
@@ -30,6 +30,7 @@
|
||||
- [Embedded documentation retrieval](#embedded-documentation-retrieval)
|
||||
- [@ references](#-references)
|
||||
- [Two-model collaboration](#two-model-collaboration)
|
||||
- [Cross-project task dispatch](#cross-project-task-dispatch)
|
||||
- [Letting the model change a setting](#letting-the-model-change-a-setting)
|
||||
- [Google Gemini, native (experimental)](#google-gemini-native-experimental)
|
||||
|
||||
@@ -179,6 +180,14 @@ tool_timeout_seconds = { "generate_video" = 1800 } # optional raw MCP tool nam
|
||||
|
||||
For the full schema and every field's contract, see [`SPEC.md` §5](./SPEC.md#5-configuration-toml).
|
||||
|
||||
The startup tool summary reuses a trusted snapshot in the current Reasonix data
|
||||
directory for up to 24 hours. Probes refreshed by another data directory do not
|
||||
replace that valid snapshot.
|
||||
|
||||
On expiry, transient probe failures retain previous
|
||||
successful observations, keeping the system prefix stable across rebuilds and
|
||||
restarts; definitive changes, such as a removed tool, are adopted on refresh.
|
||||
|
||||
Installed and project-configured MCP servers need no per-tool trust
|
||||
list. The dedicated two-model Planner may use every non-destructive MCP tool,
|
||||
even when the server omits `readOnlyHint`; strict read-only sub-agents still
|
||||
@@ -944,6 +953,36 @@ and `PreCompact` fire in every agent a session runs. Each fires under a
|
||||
- `SubagentStop` fires on every end: answer, failure, cancel or refusal;
|
||||
- neither can block: exit 2 only warns.
|
||||
|
||||
## Studio appearance schedule
|
||||
|
||||
In **Settings → Appearance → Light and dark**, choose **Scheduled** to use
|
||||
dark mode during a daily local-time range and light mode outside it. The initial
|
||||
range is 20:00–07:00; edit either time to apply it immediately.
|
||||
|
||||
The start is inclusive and the end is exclusive. Overnight ranges cross
|
||||
midnight. Equal times mean light all day; choose **Dark** for dark all day.
|
||||
|
||||
**Follow the system**, **Light**, and **Dark** retain their existing behaviour.
|
||||
Choosing one stops the schedule without forgetting its times. The title-bar
|
||||
theme toggle also chooses a fixed mode.
|
||||
|
||||
The range and mode stay on this device, using the same local preferences as
|
||||
other theme choices. Desktop restores these across launches; web preferences
|
||||
belong to the browser origin. If storage cannot be written, the choice lasts
|
||||
only for the current window.
|
||||
|
||||
Scheduling uses the device's clock and timezone, not a server timezone. Skipped
|
||||
or repeated daylight-saving times follow the actual local clock. Returning to
|
||||
the window, restoring a page, or reloading recalculates the current scheme.
|
||||
|
||||
The active theme pack and text contrast still apply. Scheduling changes the
|
||||
pack's light/dark variant, not the selected pack. The desktop window's native
|
||||
frame and background follow the same resolved scheme.
|
||||
|
||||
Before the page loads, the shell uses the last saved resolved scheme as a
|
||||
startup tint. The page then recalculates from the current clock and updates
|
||||
the shell. CLI/TUI theme settings are unchanged.
|
||||
|
||||
## Keyboard shortcuts
|
||||
|
||||
Shortcuts are documented by client because users usually look for the keys that
|
||||
@@ -1000,6 +1039,14 @@ Composer shortcuts:
|
||||
| Plain `Up` / `Down` at the prompt boundary | Recalls older or newer submitted prompts | Modified arrows and native text navigation stay with the textarea. |
|
||||
| `Esc` while a turn is running | Cancels the running turn | If the turn has not produced a response yet, the draft is restored. |
|
||||
|
||||
**Settings → Appearance → Conversation display → Composer hint** controls
|
||||
guidance in the empty task input. It is on by default. Turning it off hides
|
||||
both task and queue placeholders immediately and persists on this machine.
|
||||
|
||||
The input's accessible name, entered text, queue content, IME handling and
|
||||
send shortcuts stay unchanged. Turning the hint back on restores the
|
||||
responsive guidance at the theme's normal contrast.
|
||||
|
||||
Remembered projects have Move up / Move down in their project menu. Open it with the keyboard, navigate with arrows or Home/End, and use Escape to return to the trigger. Moving a project changes sidebar order without changing the launch project.
|
||||
|
||||
Studio remembers which local and remote projects you collapse in the sidebar,
|
||||
@@ -1007,6 +1054,32 @@ including Collapse all / Expand all, when you reopen it in the same browser or
|
||||
desktop profile. New projects start expanded. These display choices stay on
|
||||
this device; they do not change sessions or synchronize to another client.
|
||||
|
||||
**Recent** in the project heading finds saved conversations across projects,
|
||||
including completed work that has left **Running**. It uses each conversation's
|
||||
last saved activity, not its unread status, pin order or last opening time.
|
||||
|
||||
The picker reads 50 conversations per page from the complete matching history.
|
||||
**Previous page**, **Next page** and its session/project search reach older
|
||||
conversations without the sidebar's 300-row display ceiling.
|
||||
|
||||
Selecting a conversation opens it, or focuses its existing pane, switches the
|
||||
sidebar to **All**, clears its search and expands the owning project. Worktree
|
||||
conversations retain their own working directory. On phones, opening a session
|
||||
closes the rail as usual; reopening it shows the selected project and session.
|
||||
|
||||
Archived conversations stay in **Archived**. Recent respects the saved
|
||||
hide-legacy preference; an explicit search can still find legacy history.
|
||||
Deleted files or unavailable folders report the normal opening error.
|
||||
|
||||
The environment selector starts with this machine. Selecting a remote host
|
||||
reads its recent history through the existing connection flow; hidden or disabled
|
||||
hosts are not offered. Older kernels ask for an update rather than showing a
|
||||
partial sidebar window as complete recent history.
|
||||
|
||||
Activity comes from persisted session metadata and survives restart. The picker
|
||||
reopens on the first page with no search; it does not create a separate visit
|
||||
history or change the saved project order.
|
||||
|
||||
Ordinary Reasonix-managed worktree chats appear under their original project,
|
||||
without a separate project row for each checkout.
|
||||
|
||||
@@ -1548,6 +1621,16 @@ background tool-call failure. Removing the MCP server also removes its local
|
||||
OAuth state unless a lower-priority declaration for the same resource becomes
|
||||
effective.
|
||||
|
||||
If a server's configuration file or source directory was deleted outside
|
||||
Reasonix, **Remove** in the running session also clears its retained connection,
|
||||
startup failure, or on-demand tools.
|
||||
|
||||
An unknown name still reports an error, and unreadable or unwritable
|
||||
configuration is not treated as a successful removal.
|
||||
|
||||
Servers managed by a plugin package must be disabled or removed with that
|
||||
package; removing a writable declaration may reveal a same-name fallback.
|
||||
|
||||
Browse the official MCP Registry from **Settings → MCP servers → Browse
|
||||
registry**, or use `reasonix mcp browse [query]` and
|
||||
`reasonix mcp install <registry-name>`. Registry access is explicit and never
|
||||
@@ -2040,6 +2123,20 @@ so ordinary `@mentions` stay literal. Typing `/` or `@` opens an autocomplete
|
||||
menu — slash commands, or hierarchical file navigation (one directory level at a
|
||||
time, descend into folders) plus MCP resources.
|
||||
|
||||
- **Line snapshots:** `@path/to/file:10-20` attaches source lines 10 through 20,
|
||||
inclusive and starting at 1. The file is read when the message is sent, with
|
||||
its original start and end lines recorded for accurate citations.
|
||||
- **Paths:** keep the existing space escape spelling, e.g.
|
||||
`@src/my\ file.go:10-20`. Only a final digits-only range suffix is recognized;
|
||||
Windows drive colons are preserved.
|
||||
- **Literal mentions:** if the path does not resolve to an existing local entry,
|
||||
the token stays literal, including `@alice:1-2` and `@10-12`.
|
||||
- **Refusals:** for existing paths, zero, inverted, or out-of-file ranges produce
|
||||
a visible notice without falling back to the whole file. Ranges require a
|
||||
readable regular text file; selected text over 64 KiB is refused rather than truncated.
|
||||
- Whole-file references and text selected in the workspace viewer remain
|
||||
available.
|
||||
|
||||
In Studio's file panel, right-click one file or folder:
|
||||
|
||||
- **Copy path** copies its absolute workspace path, including remote roots.
|
||||
@@ -2491,6 +2588,110 @@ Not built, and why:
|
||||
- Gemini: its video input is in the native Gemini API (inline data, the Files API, YouTube URLs); the OpenAI-compatible page does not list a video content part, and Reasonix reaches Gemini through that endpoint.
|
||||
- Zhipu GLM: the documentation shows `video_url` with a public HTTPS address only and says nothing about base64 or limits, so a local file cannot be sent without Reasonix uploading it somewhere.
|
||||
|
||||
## Cross-project task dispatch
|
||||
|
||||
Use this when one project needs an answer from another project's session, rather
|
||||
than letting the current session edit across project roots. Dispatch is
|
||||
experimental and off by default.
|
||||
|
||||
Start from a **local session in Reasonix Studio
|
||||
or the web UI**; terminal/protocol sessions and SSH-driven panes cannot open the
|
||||
receiving session.
|
||||
|
||||
1. Add the target folder to your projects in the sidebar and open it. Answer its
|
||||
workspace trust question there before dispatching. The target must be an
|
||||
existing directory in your project list; use its full path if several projects
|
||||
share a folder name.
|
||||
2. In the sending session, open **Settings → Session messages → Messages between
|
||||
sessions (experimental)**. Turn on **Turn on session messages**, then **Let the
|
||||
model hand a task to another session**.
|
||||
|
||||
These save to your user config, not
|
||||
`reasonix.toml`. They apply between turns; if a turn is running, send the task
|
||||
on the next turn after saving. No session restart is needed for these UI
|
||||
switches.
|
||||
3. Make a concrete request, including the context the other project needs:
|
||||
|
||||
> In `/work/shop-web`, we need to show an order's delivery status. Hand a task
|
||||
> to the `shop-api` project at `/work/shop-api`: inspect its order-status API,
|
||||
> report the endpoint, response fields and possible status values, and do not
|
||||
> change files. Bring its answer back here and explain how this frontend
|
||||
> should call it.
|
||||
|
||||
The model uses `dispatch_to_project` to open a session in the target project.
|
||||
Later dispatches from this sender to the same project reuse that dispatched
|
||||
session while it remains open; they do not take over an arbitrary existing
|
||||
conversation.
|
||||
4. You can inspect the work in the target project's session. When its turn ends,
|
||||
its final answer is sent back automatically to the **sending session**, as a
|
||||
message labelled as data from the other session.
|
||||
|
||||
Return to the sending
|
||||
conversation for the reply and the model's continuation. The initial dispatch
|
||||
receipt only confirms delivery, not completion.
|
||||
|
||||
If you paused the receiving
|
||||
queue yourself, standing consent does not resume it: resume it to process the
|
||||
queued task or reply.
|
||||
|
||||
The equivalent user configuration is:
|
||||
|
||||
```toml
|
||||
[peers]
|
||||
enabled = true
|
||||
dispatch = true
|
||||
```
|
||||
|
||||
**Consent and permissions.** Enabling dispatch is standing consent for tasks and
|
||||
replies between the paired sessions: there is no separate message-approval card
|
||||
for each one.
|
||||
|
||||
Ordinary messages between unpaired sessions still need approval
|
||||
unless you separately release the receiving project under **Projects allowed through**.
|
||||
|
||||
That list is optional for dispatch and is not workspace trust; releasing a
|
||||
project skips incoming-message consent, not tool permissions or sandbox rules.
|
||||
You can revoke a released project there.
|
||||
|
||||
A newly dispatched session inherits the sender's model, preset, approval mode
|
||||
and planning mode.
|
||||
|
||||
In particular, a YOLO sender opens a YOLO session: the target
|
||||
project's `ask` permission rules are auto-allowed in that mode, while `deny`
|
||||
rules still hold.
|
||||
|
||||
Workspace permissions and sandbox restrictions still apply;
|
||||
dispatch consent is not a promise that no files will change. Choose the sender's
|
||||
mode and task scope accordingly.
|
||||
|
||||
**Trust and limits.** Naming an undecided project already in your sidebar can
|
||||
trust it automatically where whole-workspace trust is allowed. A declined
|
||||
project is refused.
|
||||
|
||||
If trust needs a user answer, its pane opens with the trust
|
||||
question and the task is **not sent**: answer there, then ask to dispatch again.
|
||||
|
||||
For a folder not yet in your project list, add it yourself, or let the model
|
||||
request `open_projects`; its card lists the paths and requires your explicit
|
||||
approval even in YOLO mode before adding or trusting them.
|
||||
|
||||
`open_projects` is unavailable in plan mode until the plan is approved.
|
||||
|
||||
Dispatch refuses unknown or ambiguous projects and the current project itself
|
||||
(work locally or use a sub-agent instead).
|
||||
|
||||
The default ceilings are one dispatch
|
||||
level and eight open dispatched sessions per sender; user-only
|
||||
`peers.max_dispatch_depth` and `peers.max_dispatched_sessions` can change them
|
||||
(`0` removes a ceiling).
|
||||
|
||||
A workspace-bound agent composition cannot dispatch
|
||||
into another workspace.
|
||||
|
||||
Disabling dispatch also stops automatic replies; live
|
||||
pairings are held in memory, so do not rely on them surviving an application
|
||||
restart.
|
||||
|
||||
## Letting the model change a setting
|
||||
|
||||
The model has a `setting` tool. When something it needs is switched off, such as handing a task to another project, it can ask you to turn the switch on instead of sending you to Settings.
|
||||
|
||||
@@ -30,6 +30,7 @@
|
||||
- [内置文档检索](#内置文档检索)
|
||||
- [@ 引用](#-引用)
|
||||
- [双模型协同](#双模型协同)
|
||||
- [跨项目任务派发](#跨项目任务派发)
|
||||
- [让模型修改设置](#让模型修改设置)
|
||||
- [Google Gemini 原生接口(实验)](#google-gemini-原生接口实验)
|
||||
|
||||
@@ -188,6 +189,10 @@ tool_timeout_seconds = { "generate_video" = 1800 } # 可选:raw MCP tool 名
|
||||
|
||||
完整 schema 与每个字段的契约见 [`SPEC.md` §5](./SPEC.md#5-configuration-toml)。
|
||||
|
||||
启动工具摘要会复用当前 Reasonix 数据目录中仍可信的快照,有效期为 24 小时。其他数据目录
|
||||
刷新探测结果不会替换这份有效快照。快照过期后,临时探测失败会保留先前的成功记录,让系统
|
||||
前缀在运行时重建和应用重启之间保持稳定;工具被移除等确定性变化会在刷新时生效。
|
||||
|
||||
已安装或由项目配置声明的 MCP server 不需要逐工具信任名单。独立双模型 Planner 可使用所有
|
||||
非 destructive 工具,即使 server 没有声明 `readOnlyHint`;严格只读 subagent 仍要求
|
||||
`readOnlyHint: true` 且无 `destructiveHint`。
|
||||
@@ -715,6 +720,29 @@ deny = ["Bash(git push*)"]
|
||||
- 不存在“仅本次会话”的作用域:hooks 来自设置文件,对每个加载它们的会话生效。
|
||||
- 原生 hook 不能替用户回答审批提示;只有 `PreToolUse` 的阻断会拒绝一次调用。
|
||||
|
||||
## Studio 定时明暗
|
||||
|
||||
在 **设置 → 外观 → 明暗** 选择 **定时**,每天在指定的本机时间段内使用深色,
|
||||
其余时间使用浅色。初始时间段为 20:00–07:00,修改任一时间立即生效。
|
||||
|
||||
开始时间进入深色,结束时间恢复浅色,支持跨午夜。开始与结束相同时全天为浅色;
|
||||
若要全天深色,请选择 **深色**。
|
||||
|
||||
**跟随系统**、**浅色** 和 **深色** 保持原有行为。选择它们会停止定时,
|
||||
但保留已设置的时间。标题栏的明暗切换按钮也会选择固定模式。
|
||||
|
||||
模式和时间段与其他主题偏好一样保存在本机。桌面端在重启后恢复;
|
||||
网页版按浏览器来源分别保存。若无法写入存储,选择仅在当前窗口有效。
|
||||
|
||||
定时使用当前设备的时钟和时区,不使用服务器时区。夏令时跳过或重复的时间
|
||||
按实际本地时钟处理。重新聚焦窗口、恢复页面或重新加载时会重新判断当前明暗。
|
||||
|
||||
已选主题包和文字对比度仍然生效;定时仅切换主题包的深浅版本。
|
||||
桌面窗口的原生边框和背景也跟随同一次明暗判断。
|
||||
|
||||
页面加载前,桌面 shell 使用上次保存的明暗作为启动底色;
|
||||
页面随后按当前时钟重新判断并同步 shell。CLI/TUI 的主题设置不受影响。
|
||||
|
||||
## 快捷键
|
||||
|
||||
这里按使用端来写,因为用户通常是先知道“我现在在桌面端/CLI”,再找对应按键。
|
||||
@@ -764,6 +792,12 @@ CLI/TUI 文本输入可通过 `[ui].cursor_shape` 设置光标形状,支持 `u
|
||||
| 输入边界处的普通 `Up` / `Down` | 回放更旧或更新的已提交提示词 | 带修饰键的方向键和原生文本导航仍交给 textarea。 |
|
||||
| 运行中按 `Esc` | 取消当前 turn | 如果后端尚未开始回复,会恢复草稿。 |
|
||||
|
||||
**设置 → 外观 → 会话显示 → 输入框提示**控制空白任务输入框中的引导文字,默认显示。
|
||||
关闭后立即隐藏任务和排队占位提示,并记住本机选择。
|
||||
|
||||
输入框的无障碍名称、已输入文字、队列内容、输入法处理和发送快捷键不变。
|
||||
重新开启后,以主题原有对比度恢复自适应提示。
|
||||
|
||||
菜单与控件:
|
||||
|
||||
| 按键或控件 | 作用 | 说明 |
|
||||
@@ -893,6 +927,24 @@ Studio 会在同一浏览器或桌面配置中记住侧边栏本机和远程项
|
||||
|
||||
已保存项目的操作菜单提供上移和下移。用键盘打开菜单后,方向键或 Home/End 可导航,Escape 会返回触发按钮。移动只改变侧栏顺序,不改变启动项目。
|
||||
|
||||
项目标题旁的**最近**按会话最后保存的活动时间跨项目查找,也能找到已经离开**进行中**的已完成会话。
|
||||
未读状态、置顶顺序和仅仅打开会话都不会改变这个排序。
|
||||
|
||||
列表每页读取 50 个会话,上一页、下一页和会话/项目搜索覆盖完整匹配历史,
|
||||
不受侧栏 300 行显示上限限制。
|
||||
|
||||
点选后打开会话或聚焦已有面板,切回**全部**、清空侧栏搜索并展开所属项目。
|
||||
worktree 会话保留自己的工作目录;手机上仍照常关闭侧栏,再打开侧栏即可看到选中的项目和会话。
|
||||
|
||||
归档会话仍在**归档**中。最近遵循已保存的隐藏旧版会话设置,明确搜索时仍能找到旧版历史。
|
||||
文件已删除或目录不可用时,显示正常的打开错误。
|
||||
|
||||
运行环境默认选择本机;选中远程主机后才按既有连接流程读取其最近会话。
|
||||
隐藏或禁用的主机不会出现;旧版内核会提示更新,不把不完整的侧栏窗口当作完整历史。
|
||||
|
||||
活动时间来自持久化的会话元数据,重启后仍然有效。每次打开最近都从空搜索的第一页开始,
|
||||
不会另建访问历史,也不改变已保存的项目顺序。
|
||||
|
||||
Reasonix 管理的普通 worktree 会话统一列在原项目下,不再为隔离目录单列同名项目。
|
||||
只有独立工作树会在顶部和底部目录选择器显示中性色的“独立工作树”标识。
|
||||
普通目录不显示徽标,底部只显示分支名。
|
||||
@@ -1170,6 +1222,10 @@ OAuth client 与 token 状态保存在工作区之外、该 server 私有的 Rea
|
||||
主动点击或运行登录命令后打开浏览器,不会因后台工具调用失败而自动弹出浏览器。删除 MCP server
|
||||
也会删除其本地 OAuth 状态;若删除后有同一 resource 的低优先级声明生效,则保留该状态。
|
||||
|
||||
如果在 Reasonix 外删除了服务器的配置文件或来源目录,运行中会话里的「移除」也能清掉
|
||||
残留连接、启动失败记录或按需工具。未知名称仍会报错,配置无法读取或写入时不会伪报移除成功。
|
||||
由插件包管理的服务器仍需停用或移除对应插件;删除可写声明后,同名的低优先级声明可能重新生效。
|
||||
|
||||
可在 **设置 → MCP 服务器 → 浏览市场** 打开官方 MCP Registry,也可使用
|
||||
`reasonix mcp browse [query]` 与 `reasonix mcp install <registry-name>`。Registry
|
||||
只在用户显式浏览或安装时联网,不进入启动路径。需要 secret 或必填参数的条目只显示为手动配置,
|
||||
@@ -1500,6 +1556,15 @@ evaluator 判定。旧 `.reasonix/autoresearch/<task-id>/` 目录保持只读:
|
||||
真实存在**时才当作引用,普通 `@mention` 保持原文。敲 `/` 或 `@` 会弹出补全菜单——斜杠
|
||||
命令,或**逐层**的文件导航(一次只列当前一层目录、可下钻进子目录)外加 MCP 资源。
|
||||
|
||||
- **行快照:**`@path/to/file:10-20` 只附上源文件第 10 至 20 行(含首尾,从 1 开始计数)。
|
||||
文件在发送消息时读取,上下文块携带原始起止行号,方便模型准确引用源文件位置。
|
||||
- **路径:**有空格时沿用转义写法,例如 `@src/my\ file.go:10-20`。
|
||||
只有末尾的纯数字 `:起始行-结束行` 才识别为范围,不会拆掉 Windows 盘符中的冒号。
|
||||
- **普通提及:**路径无法解析为真实存在的本地条目时,整个标记保持原文,包括 `@alice:1-2` 和 `@10-12`。
|
||||
- **拒绝:**路径存在时,行号为零、倒序或超出文件范围会显示提示,不会退回引用整个文件。
|
||||
范围只支持可读的普通文本文件;所选内容超过 64 KiB 会拒绝,而非静默截断。
|
||||
- 整文件引用和工作区查看器中的手动选中文本引用保持不变。
|
||||
|
||||
Studio 文件面板里,右键文件或文件夹可选择「复制路径」(工作区中的绝对路径)或
|
||||
「复制引用」(可粘贴到输入框的 `@` 引用)。空格和制表符会被转义,目录引用以 `/`
|
||||
结尾。引用语法无法表示的名称会禁用「复制引用」;工作区提供绝对根路径后才可
|
||||
@@ -1845,6 +1910,71 @@ video_protocol = "inline_data_url" # 或 "moonshot_file"
|
||||
- Gemini:视频输入在原生 Gemini 接口里(内联数据、Files API、YouTube 链接);OpenAI 兼容页没有列视频内容块,而 Reasonix 是通过那个端点接 Gemini 的。
|
||||
- 智谱 GLM:文档只给了公网 HTTPS 地址的 `video_url` 示例,没写 base64 与限制,所以不能在不替用户把文件传到别处的前提下发送本地文件。
|
||||
|
||||
## 跨项目任务派发
|
||||
|
||||
当一个项目需要另一个项目的会话提供答案,而不是让当前会话跨项目根目录修改文件时,
|
||||
可以使用派发。此功能仍为实验功能,默认关闭。请从 **Reasonix Studio 或 Web UI 中的
|
||||
本机会话**发起;终端、协议会话和 SSH 驱动的窗格不能打开接收任务的会话。
|
||||
|
||||
1. 将目标文件夹添加到侧栏的项目列表并打开,先在那里回答工作区信任问题。
|
||||
目标必须是项目列表中实际存在的目录;若多个项目的文件夹同名,请使用完整路径。
|
||||
2. 在发起会话打开 **设置 → 会话间消息 → 会话间消息(实验)**,先打开
|
||||
**启用会话间消息**,再打开 **允许模型把任务交给别的会话**。
|
||||
开关写入用户配置,不是 `reasonix.toml`,在回合之间生效;如果当前回合还在运行,
|
||||
保存后请在下个回合发送任务。这两个 UI 开关不需要重启会话。
|
||||
3. 给出具体请求,并附上对方项目需要的上下文,例如:
|
||||
|
||||
> 我们在 `/work/shop-web` 里要展示订单配送状态。请把任务交给
|
||||
> `/work/shop-api` 的 `shop-api` 项目:检查订单状态 API,报告端点、响应字段和
|
||||
> 可能的状态值,不要修改文件。把答案带回这里,并解释这个前端该怎样调用。
|
||||
|
||||
模型通过 `dispatch_to_project` 在目标项目打开会话。同一个发起会话后续再向该项目
|
||||
派发时,只要之前派出的会话仍开着,就会复用它;不会接管任意已有对话。
|
||||
4. 可以在目标项目的会话里查看工作过程。对方回合结束时,最终答复会自动送回
|
||||
**发起会话**,标注为来自另一个会话的数据。回到发起对话查看回信和模型的后续处理。
|
||||
最初的派发回执只说明已送达,不代表已完成。如果你亲手暂停了接收队列,
|
||||
总同意不会替你恢复;需要恢复队列,才会处理排队的任务或回信。
|
||||
|
||||
等价的用户配置为:
|
||||
|
||||
```toml
|
||||
[peers]
|
||||
enabled = true
|
||||
dispatch = true
|
||||
```
|
||||
|
||||
**同意与权限。** 打开派发就是对配对会话间任务和回信的长期同意,不会为每条消息单独
|
||||
出审批卡。不属于派发配对的普通会话间消息仍需批准,除非你在 **放行的项目** 中另行
|
||||
放行了接收项目。派发不要求配置此列表;它也不是工作区信任:放行项目只跳过来信的
|
||||
同意步骤,不会跳过工具权限或沙盒规则。可以在该列表随时撤销放行。
|
||||
|
||||
新派出的会话继承发起方的模型、预设、审批档位和计划模式。特别是 YOLO 发起方会
|
||||
打开 YOLO 会话:该模式下目标项目的 `ask` 权限规则会自动放行,`deny` 规则仍然有效。
|
||||
工作区权限与沙盒限制仍然适用;同意派发不代表保证不会修改文件。
|
||||
请据此选择发起会话的模式和任务范围。
|
||||
|
||||
**信任与限制。** 对已在侧栏、尚未决定信任的项目,在允许整工作区信任时,
|
||||
派发点名本身就可能自动信任它。已经拒绝信任的项目会被拒绝派发。
|
||||
|
||||
若信任需要你回答,目标窗格会打开并显示信任问题,任务**尚未发送**:
|
||||
请在那里回答,然后再要求派发。
|
||||
|
||||
对于尚未加入项目列表的文件夹,可以自己添加,
|
||||
或让模型通过 `open_projects` 请求;其审批卡会列出路径,即使在 YOLO 模式下也必须
|
||||
由你明确批准,才会添加或信任。
|
||||
|
||||
计划模式下不能使用 `open_projects`,需要先批准计划。
|
||||
|
||||
不存在或重名不明确的项目、当前项目本身都会被拒绝(当前项目应在本地完成或用
|
||||
subagent)。
|
||||
|
||||
默认最多派发一层,每个发起会话最多保持八个派出会话打开;
|
||||
只能由用户级配置 `peers.max_dispatch_depth` 和 `peers.max_dispatched_sessions`
|
||||
调整,`0` 表示取消对应上限。
|
||||
|
||||
绑定工作区的 agent composition 不能向另一个工作区派发。
|
||||
关闭派发也会停止自动回信;运行中的配对关系保存在内存里,不要依赖它们跨应用重启保留。
|
||||
|
||||
## 让模型修改设置
|
||||
|
||||
模型有一个 `setting` 工具。需要的功能被关着时(比如把任务交给另一个项目),它可以请你把开关打开,而不是让你自己去设置里找。能被某项设置解除的拒绝会以 `(fix: setting ...)` 结尾,工具也可以按设置 id 里的词列出设置。
|
||||
|
||||
@@ -770,6 +770,23 @@ leave the workspace are still reported as claim violations.
|
||||
Declaring paths is what buys parallelism; it costs `bash` on hosts where the OS
|
||||
sandbox cannot enforce write roots.
|
||||
|
||||
- On resume after yielding for user input, the owner-side claim record identifies
|
||||
other sessions that claimed overlapping paths during the wait.
|
||||
- A foreign claim or incomplete history adds a persistent `workspace_claim_yielded`
|
||||
checkpoint coverage gap alongside the model notice and completion-receipt gap.
|
||||
- A crossing observation cannot attribute watched paths or a whole-workspace
|
||||
diff to its tool, even when files look unchanged. Unknown mutations retain
|
||||
their `unproven_mutation` obligation.
|
||||
- The lease owner tracks observation boundaries per prepared call, including
|
||||
child calls and optimistic calls that claim no additional paths.
|
||||
- Fresh or disjoint operations are not tainted by another waiter's repeated
|
||||
notice, and cannot discharge the earlier operation's debt.
|
||||
|
||||
The existing bounded claim log reports unknown coverage when a full retained
|
||||
history no longer includes the yield's mark; a short complete history is not
|
||||
treated as truncation. These facts describe cooperating lease owners, not writes
|
||||
by external processes that bypass the claim protocol.
|
||||
|
||||
### 3.13 Sub-agent context inheritance is explicit
|
||||
|
||||
A child inherits nothing implicitly. What it receives is exactly this:
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
owner: @esengine
|
||||
backup: @SivanCola
|
||||
status: active
|
||||
reviewed: 2026-10-08
|
||||
reviewed: 2026-10-29
|
||||
---
|
||||
|
||||
# Studio parity with the 1.x desktop app
|
||||
@@ -89,7 +89,7 @@ Checked against `studio` at `d779d89bc` on 2026-10-08.
|
||||
| Browser panel | Have | `ui/BrowserPanel.tsx` |
|
||||
| Integrated terminal panel | Unverified | `ui/Shell.tsx` only chooses the shell; no terminal view was found by name; no issue yet |
|
||||
| Worktree merge dialog | Unverified | Worktree handling exists in `ui/Workspaces.tsx`; the merge flow was not checked |
|
||||
| Scheduled tasks (heartbeat) | Missing | #11384 |
|
||||
| Scheduled tasks (heartbeat) | Have differently | Scheduled runs exist (`ui/ScheduledRuns.tsx` and the schedule API, with budgets, breakers and pause reasons); 1.x's separate heartbeat loop was not ported; #11384 |
|
||||
| Keyboard shortcuts cheat sheet | Unverified | Key handling is in `ui/keys.ts`, `ui/windowkeys.ts`; no cheat sheet found by name; no issue yet |
|
||||
| CSV and media preview in the workspace panel | Have differently | `ui/document`: CSV/TSV, PDF and still images render in the workbench; audio/video remain unsupported. CSV/TSV、PDF 和图片可直接阅读,音视频仍不支持内联预览。 |
|
||||
| File delivery cards / 文件交付卡片 | Have | `present_files` and `ui/document/DeliveryCard.tsx`: validated file references reopen in the shared workbench; auxiliary actions live in More. 已验证文件引用复用工作台标签,辅助操作位于“更多”。 |
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
---
|
||||
owner: @SivanCola
|
||||
backup: @esengine
|
||||
status: active
|
||||
reviewed: 2026-10-28
|
||||
---
|
||||
|
||||
# Worktree apply ancestors / 工作树应用的父目录边界
|
||||
|
||||
## Behavior / 行为
|
||||
|
||||
An isolated candidate names Git directory entries. If a source ancestor becomes
|
||||
a directory link while that candidate runs, matching leaf bytes do not establish
|
||||
that the candidate still addresses the original path.
|
||||
|
||||
Applying now refuses that
|
||||
ancestor with the existing path-conflict result before reading leaf content or
|
||||
writing a sibling change. Links inside the repository are refused too, including
|
||||
links from a selected project folder to its surrounding repository.
|
||||
|
||||
隔离候选使用 Git 目录项路径。候选运行期间,如果源文件的父目录被换成目录链接,
|
||||
即使最终文件内容相同,也不能认定仍是原路径。应用现在会在读取最终文件内容或写入
|
||||
其他变更前,以已有的路径冲突结果拒绝该父目录。仓库内部链接也会被拒绝,包括从所选
|
||||
项目目录指向仓库其他目录的链接。
|
||||
|
||||
Deletion ancestors are checked in the shared worktree publisher, including
|
||||
ordinary isolation and best-of callers. Path-by-path apply checks every changed
|
||||
path's ancestors before hashing leaves.
|
||||
|
||||
Deletion uses an open `os.Root` for the source repository, so a parent
|
||||
link cannot redirect that filesystem operation outside the opened root. A link
|
||||
at the final deleted entry is removed as an entry; its target is not removed.
|
||||
|
||||
Whole-workspace application may delete a file before creating a directory at
|
||||
the same name. Its deletion preflight preserves that conversion; Git owns the
|
||||
subsequent restoration. Path-by-path application's existing refusal of
|
||||
unsupported tree entries is unchanged.
|
||||
|
||||
共同 worktree 发布层检查删除路径的父目录,覆盖普通隔离和 best-of 调用方。逐路径应用
|
||||
还在计算文件摘要前检查所有变更路径的父目录。
|
||||
|
||||
删除通过源仓库的已打开 `os.Root` 执行,父目录链接不能将该删除
|
||||
操作重定向到根目录外。被删除的最终目录项本身若是链接,只删除链接,不删除其目标。
|
||||
|
||||
整工作区应用可能先删除一个文件,再创建同名目录。删除前置检查保留这一转换,后续
|
||||
恢复由 Git 负责。逐路径应用原有的“不支持的 tree 条目”拒绝规则不变。
|
||||
|
||||
The existing apply tool reports the conflict and retains the original result ID
|
||||
and candidate. After restoring the directory, reopening the Store can apply that
|
||||
same ID. New directories, ordinary deletions, and unrelated workspace edits keep
|
||||
their existing behavior.
|
||||
|
||||
No configuration, tool schema, prompt prefix, journal,
|
||||
or persisted format changes; existing results use the checks when applied by the
|
||||
new reader. Older binaries retain their previous behavior.
|
||||
|
||||
已有应用工具报告冲突并保留原 result ID 和候选。恢复目录后,重新打开 Store 可使用
|
||||
同一 ID 应用。新建目录、正常删除及工作区其他修改保留原行为。不修改配置、工具
|
||||
schema、提示词前缀、journal 或持久化格式;新版本应用旧结果时同样执行检查。
|
||||
旧二进制仍保留原行为。
|
||||
|
||||
## Evidence and limits / 证据与限制
|
||||
|
||||
Regression fixtures create real Git candidates, then replace source ancestors
|
||||
with external, internal, dangling, or non-directory entries. They check both
|
||||
deletions and writes, sibling preservation, and Store reconstruction followed by
|
||||
same-ID retry.
|
||||
|
||||
Windows fixtures use directory junctions; Unix fixtures use
|
||||
symbolic links. The existing Windows isolation inventory requires every named
|
||||
regression to pass, with no missing, duplicate, failed, or skipped terminal.
|
||||
|
||||
回归测试创建真实 Git 候选,再将源父目录换成外部链接、内部链接、悬空链接或普通文件,
|
||||
覆盖删除、写入、其他文件保持不变,以及重建 Store 后用原 ID 重试。Windows 使用目录
|
||||
junction,Unix 使用符号链接。已有 Windows 隔离清单要求每个指定测试通过,拒绝缺失、
|
||||
重复、失败或跳过的终态。
|
||||
|
||||
These are ancestor-type preconditions and confined deletion, not an atomic
|
||||
multi-file transaction or an inode compare-and-swap. A directory replaced by
|
||||
another real directory containing identical files is not distinguished.
|
||||
|
||||
Checks cannot fence arbitrary writers between inspection and Git restoration. File
|
||||
restoration still uses Git, and errors after publication begins may leave partial
|
||||
changes.
|
||||
|
||||
Physical process settlement, remote effects, mount changes, complete
|
||||
native publisher ownership, and crash-safe rollback remain separate work.
|
||||
|
||||
本修补提供父目录类型前置条件及受根目录约束的删除,不是多文件原子事务或 inode CAS。
|
||||
真实目录被换成包含相同文件的另一真实目录时,不会区分。
|
||||
|
||||
检查无法阻止任意外部写入者
|
||||
在检查与 Git 恢复之间修改路径。文件恢复仍由 Git 执行,开始发布后的错误可能留下部分
|
||||
变更。进程物理终结、远程效果、挂载点变化、完整原生发布所有权与崩溃安全回滚仍是
|
||||
独立的未完成工作。
|
||||
@@ -73,6 +73,9 @@ func adoptCollectingNotices(t *testing.T, from *control.Controller, home, dir st
|
||||
if err := ApplyRuntimeMigration(to, from, CaptureRuntimeMigration(from)); err != nil {
|
||||
t.Fatalf("ApplyRuntimeMigration: %v", err)
|
||||
}
|
||||
if from.History()[0].Content != to.History()[0].Content {
|
||||
t.Logf("system prompt changed during migration: %q", firstDivergence(from.History()[0].Content, to.History()[0].Content))
|
||||
}
|
||||
return notices
|
||||
}
|
||||
|
||||
|
||||
@@ -10,10 +10,10 @@ import (
|
||||
// RuntimeDoctorReport is the structured runtime diagnostics document for
|
||||
// `reasonix doctor runtime` and desktop status panels.
|
||||
type RuntimeDoctorReport struct {
|
||||
Status *extension.RuntimeStatus `json:"status,omitempty"`
|
||||
Status *extension.RuntimeStatus `json:"status"`
|
||||
Metrics extension.LifecycleMetricsSnapshot `json:"metrics"`
|
||||
Recoverability extension.Recoverability `json:"recoverability"`
|
||||
Resume extension.ResumeDecision `json:"resume"`
|
||||
Recoverability *extension.Recoverability `json:"recoverability"`
|
||||
Resume *extension.ResumeDecision `json:"resume"`
|
||||
PublishedGen uint64 `json:"publishedGeneration"`
|
||||
DrainingGens []uint64 `json:"drainingGenerations,omitempty"`
|
||||
RuntimeOwnerFallbacks uint64 `json:"runtimeOwnerFallbacks"`
|
||||
@@ -22,33 +22,39 @@ type RuntimeDoctorReport struct {
|
||||
|
||||
// CollectRuntimeDoctor builds a report from an optional live BuildResult and
|
||||
// its session-lineage owner. Also sweeps expired drains so doctor is a natural
|
||||
// product trigger for drain-timeout force-expire.
|
||||
// product trigger for drain-timeout force-expire. A nil BuildResult means no
|
||||
// runtime was available to ask: verdicts stay nil (unknown), never clean.
|
||||
func CollectRuntimeDoctor(res *BuildResult) RuntimeDoctorReport {
|
||||
owner := extension.DefaultRuntimeOwner
|
||||
if res != nil && res.Owner != nil {
|
||||
owner = res.Owner
|
||||
report := RuntimeDoctorReport{
|
||||
Metrics: extension.DefaultLifecycleMetrics.Snapshot(),
|
||||
RuntimeOwnerFallbacks: extension.RuntimeOwnerFallbackCount(),
|
||||
}
|
||||
if res == nil {
|
||||
return report
|
||||
}
|
||||
owner := res.Owner
|
||||
if owner == nil {
|
||||
owner = extension.DefaultRuntimeOwner
|
||||
}
|
||||
gate := owner.Gate
|
||||
_ = gate.SweepAndForceExpire()
|
||||
gen := gate.Published()
|
||||
report := RuntimeDoctorReport{
|
||||
Metrics: extension.DefaultLifecycleMetrics.Snapshot(),
|
||||
PublishedGen: gen,
|
||||
DrainingGens: gate.DrainingGenerations(),
|
||||
RuntimeOwnerFallbacks: extension.RuntimeOwnerFallbackCount(),
|
||||
Recoverability: owner.AssessRecoverability(gen),
|
||||
Resume: owner.DecideResume(gen),
|
||||
report.PublishedGen = gen
|
||||
report.DrainingGens = gate.DrainingGenerations()
|
||||
rec := owner.AssessRecoverability(gen)
|
||||
decision := owner.DecideResume(gen)
|
||||
report.Recoverability = &rec
|
||||
report.Resume = &decision
|
||||
report.Status = res.Status
|
||||
if res.Status != nil {
|
||||
report.Text = FormatRuntimeStatus(res.Status)
|
||||
}
|
||||
if res != nil {
|
||||
report.Status = res.Status
|
||||
if res.Status != nil {
|
||||
report.Text = FormatRuntimeStatus(res.Status)
|
||||
}
|
||||
if res.Snapshot != nil {
|
||||
g := res.Snapshot.Generation()
|
||||
report.Recoverability = owner.AssessRecoverability(g)
|
||||
report.Resume = owner.DecideResume(g)
|
||||
}
|
||||
if res.Snapshot != nil {
|
||||
g := res.Snapshot.Generation()
|
||||
rec := owner.AssessRecoverability(g)
|
||||
decision := owner.DecideResume(g)
|
||||
report.Recoverability = &rec
|
||||
report.Resume = &decision
|
||||
}
|
||||
if report.Text == "" {
|
||||
report.Text = FormatRuntimeStatus(report.Status)
|
||||
@@ -68,13 +74,28 @@ func RenderRuntimeDoctorText(report RuntimeDoctorReport) string {
|
||||
body = "runtime status: unavailable\n"
|
||||
}
|
||||
body += fmt.Sprintf("runtime owner fallbacks: %d\n", report.RuntimeOwnerFallbacks)
|
||||
body += fmt.Sprintf("recoverability: clean=%v irreversible=%v\n", report.Recoverability.Clean, report.Recoverability.HasIrreversible)
|
||||
body += fmt.Sprintf("resume: allow=%v cleanRollback=%v\n", report.Resume.AllowResume, report.Resume.CleanRollback)
|
||||
for _, n := range report.Recoverability.Notes {
|
||||
body += " note: " + n + "\n"
|
||||
if report.Recoverability == nil {
|
||||
body += "recoverability: unknown\n"
|
||||
} else {
|
||||
body += fmt.Sprintf("recoverability: clean=%v irreversible=%v\n", report.Recoverability.Clean, report.Recoverability.HasIrreversible)
|
||||
}
|
||||
for _, n := range report.Resume.Notes {
|
||||
body += " resume-note: " + n + "\n"
|
||||
if report.Resume == nil {
|
||||
body += "resume: unknown\n"
|
||||
} else {
|
||||
body += fmt.Sprintf("resume: allow=%v cleanRollback=%v\n", report.Resume.AllowResume, report.Resume.CleanRollback)
|
||||
}
|
||||
if report.Recoverability == nil || report.Resume == nil {
|
||||
body += " note: no runtime was available to ask; effect receipts live only in the process that owned the runtime\n"
|
||||
}
|
||||
if report.Recoverability != nil {
|
||||
for _, n := range report.Recoverability.Notes {
|
||||
body += " note: " + n + "\n"
|
||||
}
|
||||
}
|
||||
if report.Resume != nil {
|
||||
for _, n := range report.Resume.Notes {
|
||||
body += " resume-note: " + n + "\n"
|
||||
}
|
||||
}
|
||||
if len(report.DrainingGens) > 0 {
|
||||
body += fmt.Sprintf("draining generations: %v\n", report.DrainingGens)
|
||||
|
||||
Loaded 100 of 201 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user