mirror of
https://github.com/esengine/DeepSeek-Reasonix.git
synced 2026-11-07 10:45:59 -10:00
Compare commits
150
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dc6c69a5f8 | ||
|
|
f6aa704059 | ||
|
|
dc5a49b8fa | ||
|
|
fb08cdc0d5 | ||
|
|
2bd960b09d | ||
|
|
f770f33791 | ||
|
|
b58d62b589 | ||
|
|
37e203408d | ||
|
|
2407ebd639 | ||
|
|
f247be74ea | ||
|
|
f88faddf41 | ||
|
|
027668a0ee | ||
|
|
b0c5d3287e | ||
|
|
498b834b71 | ||
|
|
2051fc937f | ||
|
|
ee47753988 | ||
|
|
5ecf313c3d | ||
|
|
fea800bac6 | ||
|
|
ba6bf964bb | ||
|
|
82de6d9622 | ||
|
|
14dbabfec2 | ||
|
|
6bccc90b84 | ||
|
|
34707b672f | ||
|
|
839114a857 | ||
|
|
d56abf84d1 | ||
|
|
e52e22bcce | ||
|
|
de35fb7b0f | ||
|
|
a8fe2001c8 | ||
|
|
1efd6dcae6 | ||
|
|
41cd0d7021 | ||
|
|
5f82ea087f | ||
|
|
14a85d29cb | ||
|
|
4367b57921 | ||
|
|
40a7506965 | ||
|
|
de34556d42 | ||
|
|
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@b843ebf9b913146b38da2eaa7aeddf76074ae1ad # v6.1.0
|
||||
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
|
||||
|
||||
@@ -12,8 +12,8 @@
|
||||
"census:verify": "node tools/ui-census/verify.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"@a2ui/react": "0.13.0",
|
||||
"@a2ui/web_core": "0.13.0",
|
||||
"@a2ui/react": "0.14.0",
|
||||
"@a2ui/web_core": "0.14.0",
|
||||
"@codemirror/commands": "^6.11.1",
|
||||
"@codemirror/lang-css": "^6.3.1",
|
||||
"@codemirror/lang-go": "^6.0.1",
|
||||
@@ -30,17 +30,19 @@
|
||||
"@codemirror/language": "^6.13.1",
|
||||
"@codemirror/search": "^6.7.2",
|
||||
"@codemirror/state": "^6.7.6",
|
||||
"@codemirror/view": "^6.43.14",
|
||||
"@codemirror/view": "^6.43.15",
|
||||
"@lezer/highlight": "^1.2.5",
|
||||
"@mantine/charts": "9.7.3",
|
||||
"@mantine/core": "9.7.3",
|
||||
"@mantine/hooks": "9.7.3",
|
||||
"@mantine/charts": "9.7.4",
|
||||
"@mantine/core": "9.7.4",
|
||||
"@mantine/hooks": "9.7.4",
|
||||
"@noble/curves": "2.4.0",
|
||||
"codemirror": "^6.0.2",
|
||||
"docx-preview": "0.4.1",
|
||||
"dompurify": "^3.4.17",
|
||||
"katex": "^0.19.0",
|
||||
"lucide-react": "^1.61.0",
|
||||
"maplibre-gl": "6.15.0",
|
||||
"exceljs": "4.4.0",
|
||||
"katex": "^0.19.1",
|
||||
"lucide-react": "^1.64.0",
|
||||
"maplibre-gl": "6.16.0",
|
||||
"react": "^19.3.1",
|
||||
"react-dom": "^19.3.1",
|
||||
"papaparse": "^5.7.0",
|
||||
@@ -55,10 +57,11 @@
|
||||
"remark-gemoji": "^8.0.0",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"remark-math": "^6.0.0",
|
||||
"ssf": "0.11.2",
|
||||
"zod": "3.25.76"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/parser": "^8.0.7",
|
||||
"@babel/parser": "^8.0.8",
|
||||
"@testing-library/react": "^16.3.3",
|
||||
"@testing-library/user-event": "^14.6.8",
|
||||
"@types/papaparse": "^5.5.2",
|
||||
@@ -66,6 +69,7 @@
|
||||
"@types/react-dom": "^19.3.0",
|
||||
"@vitejs/plugin-react": "^6.1.2",
|
||||
"jsdom": "^30.1.3",
|
||||
"jszip": "3.10.2",
|
||||
"playwright": "^1.64.1",
|
||||
"typescript": "^7.0.2",
|
||||
"vite": "^8.3.5",
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -624,7 +607,7 @@ declare global {
|
||||
__panes: () => number;
|
||||
// Answer every completion request with this one, as the kernel would for a command typed in full.
|
||||
__complete: (answer: import("../src/port/port").Completion) => void;
|
||||
__previewFiles: (files: Record<string, { kind: PreviewInfo["kind"]; text: string }>) => void;
|
||||
__previewFiles: (files: Record<string, { kind: PreviewInfo["kind"]; text?: string; base64?: string }>) => void;
|
||||
// Sub-agent runs the kernel would list, each with the conversation it would return.
|
||||
__subagents: (runs: { run: SubagentRun; messages: HistoryMessage[] }[]) => void;
|
||||
// What folding a stored transcript into cards costs on its own, with no
|
||||
@@ -645,13 +628,15 @@ window.__toast = pushToast;
|
||||
window.__syntax = { apply: applyPack, mount: mountEditor };
|
||||
window.__feed = (ev) => hub.feeds.forEach((p) => p.feed(ev));
|
||||
window.__panes = () => hub.feeds.size;
|
||||
const fixtureBytes = (f: { text?: string; base64?: string }) =>
|
||||
f.base64 !== undefined ? Uint8Array.from(atob(f.base64), (c) => c.charCodeAt(0)) : new TextEncoder().encode(f.text ?? "");
|
||||
window.__previewFiles = (files) => hub.feeds.forEach((port) => {
|
||||
port.workspacePreviewInfo = async (ref) => ({
|
||||
ref, kind: files[ref.path].kind, size: new TextEncoder().encode(files[ref.path].text).length,
|
||||
ref, kind: files[ref.path].kind, size: fixtureBytes(files[ref.path]).length,
|
||||
version: "fixture", limit: 32 << 20, readOnly: false,
|
||||
});
|
||||
port.workspacePreviewContent = async (ref) => ({
|
||||
data: new TextEncoder().encode(files[ref.path].text).buffer, version: "fixture",
|
||||
data: fixtureBytes(files[ref.path]).buffer, version: "fixture",
|
||||
});
|
||||
});
|
||||
window.__subagents = (runs) => hub.feeds.forEach((port) => {
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { chromium } from "playwright";
|
||||
|
||||
// OFFICE_SAMPLES names a folder holding report.docx and sales.xlsx; OUT where the shots go.
|
||||
const dir = process.env.OFFICE_SAMPLES;
|
||||
const out = process.env.OUT ?? "/tmp";
|
||||
assert.ok(dir, "set OFFICE_SAMPLES");
|
||||
const files = {
|
||||
"report.docx": { kind: "office", base64: readFileSync(join(dir, "report.docx")).toString("base64") },
|
||||
"sales.xlsx": { kind: "office", base64: readFileSync(join(dir, "sales.xlsx")).toString("base64") },
|
||||
};
|
||||
const browser = await chromium.launch(process.env.CHROME ? { executablePath: process.env.CHROME } : {});
|
||||
try {
|
||||
for (const [scheme, width] of [["light", 1440], ["dark", 1440], ["light", 390]]) {
|
||||
const page = await browser.newPage({ viewport: { width, height: 900 }, colorScheme: scheme });
|
||||
const errors = [];
|
||||
const requests = [];
|
||||
page.on("pageerror", (err) => errors.push(err.message));
|
||||
page.on("request", (req) => requests.push(req.url()));
|
||||
await page.goto(process.env.PERF_URL ?? "http://127.0.0.1:4399/perf.html?pref=zh", { waitUntil: "networkidle" });
|
||||
await page.waitForFunction(() => window.__panes?.() > 0);
|
||||
await page.evaluate((files) => {
|
||||
window.__previewFiles(files);
|
||||
window.__feed({ kind: "turn_started", authoredTurn: 1, msgIndex: 0, text: "整理季度报告" });
|
||||
window.__feed({ kind: "tool_dispatch", tool: { id: "present", name: "present_files", readOnly: true } });
|
||||
window.__feed({ kind: "tool_result", tool: { id: "present", name: "present_files", readOnly: true,
|
||||
output: "Presented files", deliveries: Object.keys(files).map((path) => ({ path, description: "季度材料" })) } });
|
||||
window.__feed({ kind: "text", text: "报告和表格已经生成。" });
|
||||
window.__feed({ kind: "turn_done" });
|
||||
}, files);
|
||||
for (const name of width < 600 ? ["sales.xlsx"] : ["report.docx", "sales.xlsx"]) {
|
||||
await page.locator(".delivery-open").filter({ hasText: name }).click();
|
||||
const frame = page.frameLocator('iframe[title="Office 文档预览"]');
|
||||
await frame.locator(name.endsWith("docx") ? "section.docx" : "table").first().waitFor({ timeout: 15000 });
|
||||
const sandbox = await page.locator('iframe[title="Office 文档预览"]').getAttribute("sandbox");
|
||||
assert.equal(sandbox, "allow-same-origin");
|
||||
await page.waitForTimeout(300);
|
||||
await page.screenshot({ path: join(out, `office-${name.split(".")[1]}-${scheme}-${width}.png`) });
|
||||
if (name === "report.docx") {
|
||||
await page.frameLocator('iframe[title="Office 文档预览"]').getByText("续约率", { exact: false }).click({ clickCount: 3 });
|
||||
await page.locator('[data-action="document.quote"]').filter({ hasText: "正文第" }).waitFor();
|
||||
await page.screenshot({ path: join(out, `office-word-select-${scheme}-${width}.png`) });
|
||||
}
|
||||
if (name === "sales.xlsx" && width >= 600) {
|
||||
const sheet = page.frameLocator('iframe[title="Office 文档预览"]');
|
||||
const centre = async (cell) => {
|
||||
const box = await sheet.locator(`[data-cell="${cell}"]`).boundingBox();
|
||||
return [box.x + box.width / 2, box.y + box.height / 2];
|
||||
};
|
||||
const [bx, by] = await centre("B2");
|
||||
await page.mouse.move(bx, by);
|
||||
await page.mouse.down();
|
||||
const [dx, dy] = await centre("D4");
|
||||
await page.mouse.move(dx, dy, { steps: 6 });
|
||||
await page.mouse.up();
|
||||
const quote = page.locator('[data-action="document.quote"]');
|
||||
await quote.filter({ hasText: "B2:D4" }).waitFor();
|
||||
await page.screenshot({ path: join(out, `office-select-${scheme}-${width}.png`) });
|
||||
await quote.click();
|
||||
await page.locator(".compose").getByText("sales.xlsx", { exact: false }).first().waitFor();
|
||||
await page.waitForTimeout(800);
|
||||
await page.screenshot({ path: join(out, `office-quoted-${scheme}-${width}.png`) });
|
||||
}
|
||||
}
|
||||
assert.deepEqual(errors, []);
|
||||
assert.deepEqual(requests.filter((u) => !u.startsWith("http://127.0.0.1") && !u.startsWith("http://localhost") && !u.startsWith("data:") && !u.startsWith("blob:")), []);
|
||||
await page.close();
|
||||
}
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
Generated
+1679
-91
File diff suppressed because it is too large.
Load diff
@@ -109,6 +109,8 @@ 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.office-preview", 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 +127,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 +155,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 +518,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 +570,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 +595,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" },
|
||||
|
||||
@@ -3,6 +3,10 @@ import type { UIAction } from "./actions";
|
||||
export const DOCUMENT_ACTIONS: UIAction[] = [
|
||||
{ id: "document.zoom", kind: "view", target: "entity", proof: "interaction" },
|
||||
{ id: "document.page", kind: "view", target: "entity", proof: "interaction" },
|
||||
{ id: "document.sheet", kind: "view", target: "entity", proof: "interaction" },
|
||||
{ id: "document.quote", kind: "view", target: "entity", proof: "interaction" },
|
||||
{ id: "document.select-cells", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "document.select-text", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "document.scroll", kind: "view", target: "entity", proof: "interaction" },
|
||||
{ id: "document.refresh", kind: "repeatable", target: "entity", proof: "interaction" },
|
||||
{ id: "delivery.preview", kind: "navigation", target: "entity", proof: "interaction" },
|
||||
|
||||
@@ -662,6 +662,8 @@ export const EN: Record<string, string> = {
|
||||
"含图片,本轮结束后发送": "Has an image: sent when this turn ends",
|
||||
"插话排队": "Steer queued",
|
||||
"后台任务续接": "Background job follow-on",
|
||||
"后台 · 受阻": "Background · blocked",
|
||||
"后台 · 状态不明": "Background · uncertain",
|
||||
"插话已收": "Steer accepted",
|
||||
"下个工具边界送入": "goes in at the next tool boundary",
|
||||
"已送入": "delivered",
|
||||
@@ -790,7 +792,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…",
|
||||
|
||||
@@ -39,4 +39,17 @@ export const EN_DOCUMENT: Record<string, string> = {
|
||||
"文件不是有效的 UTF-8 文本": "The file is not valid UTF-8 text.",
|
||||
"读取期间文件发生变化,请刷新": "The file changed while being read. Refresh to try again.",
|
||||
"文件预览读取失败,请重试": "Could not read the file preview. Try again.",
|
||||
"只读预览,宏、脚本和外部资源未加载": "Read-only preview. Macros, scripts and external resources are not loaded.",
|
||||
"无法解析这个文件,可以用系统应用打开": "This file could not be read. Open it in the system app instead.",
|
||||
"工作表": "Sheets",
|
||||
"只显示前 {rows} 行、{cols} 列": "Showing the first {rows} rows and {cols} columns only.",
|
||||
"这个工作簿没有可见的工作表": "This workbook has no visible sheets.",
|
||||
"Office 文档预览": "Office document preview",
|
||||
"Office 文档预览已在设置中关闭": "Office document preview is turned off in Settings.",
|
||||
"加入对话 · {where}": "Add to chat · {where}",
|
||||
"选中文字后可加入对话": "Select text to add it to the chat",
|
||||
"点选或拖选单元格后可加入对话": "Click or drag across cells to add them to the chat",
|
||||
"{sheet} 工作表 {range}": "sheet {sheet}, {range}",
|
||||
"正文第 {n} 块": "body block {n}",
|
||||
"正文第 {a}–{b} 块": "body blocks {a}–{b}",
|
||||
};
|
||||
@@ -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,9 @@ 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",
|
||||
"在工作台里只读预览 Word(.docx)和 Excel(.xlsx)。首次打开这类文件时才加载,在隔离的框中显示,不运行宏和脚本。关闭后只能用系统应用打开。仅保存在本机。": "Read-only preview of Word (.docx) and Excel (.xlsx) files in the workbench. It loads the first time such a file is opened and renders in an isolated frame without running macros or scripts. When off, these files open only in the system app. Saved on this device only.",
|
||||
"在空白任务输入框中显示任务和排队提示。默认显示,仅保存在本机,不影响输入和发送。": "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: [] });
|
||||
|
||||
@@ -106,7 +106,7 @@ export interface FileRef {
|
||||
|
||||
export interface PreviewInfo {
|
||||
ref: FileRef;
|
||||
kind: "markdown" | "text" | "image" | "pdf" | "table" | "html" | "unsupported";
|
||||
kind: "markdown" | "text" | "image" | "pdf" | "table" | "html" | "office" | "unsupported";
|
||||
size: number;
|
||||
version: string;
|
||||
limit: number;
|
||||
|
||||
@@ -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,60 @@ 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 OFFICE_PREVIEW_KEY = "rx-office-preview";
|
||||
const officePreviewListeners = new Set<() => void>();
|
||||
|
||||
export function previewsOffice(): boolean {
|
||||
try {
|
||||
return localStorage.getItem(OFFICE_PREVIEW_KEY) !== "off";
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
export function onOfficePreviewChange(fn: () => void): () => void {
|
||||
officePreviewListeners.add(fn);
|
||||
return () => {
|
||||
officePreviewListeners.delete(fn);
|
||||
};
|
||||
}
|
||||
|
||||
export function setPreviewsOffice(on: boolean): void {
|
||||
try {
|
||||
localStorage.setItem(OFFICE_PREVIEW_KEY, on ? "on" : "off");
|
||||
} catch {
|
||||
/* unavailable storage keeps the default */
|
||||
}
|
||||
officePreviewListeners.forEach((fn) => fn());
|
||||
}
|
||||
|
||||
const PLAN_PROGRESS_KEY = "rx-plan-progress";
|
||||
const planProgressListeners = new Set<() => void>();
|
||||
|
||||
@@ -515,6 +569,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,15 @@ import { OpenSessionLimitSection } from "./OpenSessionLimitSection";
|
||||
import { LiveStreamBudgetSection } from "./LiveStreamBudgetSection";
|
||||
import { NewAnswerSection } from "./NewAnswerSection";
|
||||
import { PlanProgressSection } from "./PlanProgressSection";
|
||||
import { ComposerHintSection } from "./ComposerHintSection";
|
||||
import { OfficePreviewSection } from "./OfficePreviewSection";
|
||||
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 +37,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 +52,7 @@ export const SCHEMES: [string, string][] = [
|
||||
["auto", "跟随系统"],
|
||||
["light", "浅色"],
|
||||
["dark", "深色"],
|
||||
["scheduled", "定时"],
|
||||
];
|
||||
|
||||
interface Props {
|
||||
@@ -233,7 +238,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 +247,7 @@ export function Appearance({ port, theme, onTheme, contrast, onContrast, weight,
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{theme === "scheduled" && <ThemeScheduleFields />}
|
||||
</div>
|
||||
<ApplyNote id="mode" />
|
||||
</section>
|
||||
@@ -570,9 +576,12 @@ export function Appearance({ port, theme, onTheme, contrast, onContrast, weight,
|
||||
<NewAnswerSection />
|
||||
<GraphicsSection />
|
||||
<SendShortcut />
|
||||
<ComposerHintSection />
|
||||
<OfficePreviewSection />
|
||||
<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,8 +18,8 @@ import { ChipMirror, useSkillChips } from "./ChipMirror";
|
||||
import { useIme } from "./ime";
|
||||
import { usePlaceholderFit } from "./placeholderfit";
|
||||
import { useFitHeight } from "./fitHeight";
|
||||
import { useSendShortcut } from "../state/prefs";
|
||||
import { sendAria, sendHint, sendsOnKey } from "./sendkey";
|
||||
import { onComposerHintChange, showsComposerHint, useRunningMessageIntent, useSendShortcut } from "../state/prefs";
|
||||
import { runningSendIntent, sendAria, sendHint, sendsOnKey } from "./sendkey";
|
||||
import { countLines, pasteIsLong, planTone, planVerb } from "./intake";
|
||||
import { kindOf, nameOf, previewURL } from "./chipfile";
|
||||
import { useIntake } from "./useIntake";
|
||||
@@ -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 = runningSendIntent(useRunningMessageIntent(), chips.list.length > 0);
|
||||
|
||||
// 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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { useSyncExternalStore } from "react";
|
||||
import { t } from "../i18n";
|
||||
import { onOfficePreviewChange, previewsOffice, setPreviewsOffice } from "../state/prefs";
|
||||
import { Group } from "./Group";
|
||||
|
||||
export function OfficePreviewSection() {
|
||||
const on = useSyncExternalStore(onOfficePreviewChange, previewsOffice, previewsOffice);
|
||||
return (
|
||||
<Group id="office-preview" title={t("Office 文档预览")} hint={t("在工作台里只读预览 Word(.docx)和 Excel(.xlsx)。首次打开这类文件时才加载,在隔离的框中显示,不运行宏和脚本。关闭后只能用系统应用打开。仅保存在本机。")}>
|
||||
<div className="seg" data-text role="group" aria-label={t("Office 文档预览")}>
|
||||
<button data-action="appearance.office-preview" data-value="off" aria-pressed={!on} onClick={() => setPreviewsOffice(false)}>{t("关闭")}</button>
|
||||
<button data-action="appearance.office-preview" data-value="on" aria-pressed={on} onClick={() => setPreviewsOffice(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));
|
||||
|
||||
@@ -56,7 +59,9 @@ export const waiting = (queue: QueueSnapshot | null): QueueItem[] =>
|
||||
// these call sites, and a table of strings looked up later is invisible to it —
|
||||
// which is how a row would render Chinese inside an English window.
|
||||
function label(it: QueueItem): string {
|
||||
if (it.origin === "host" && it.state !== "blocked" && it.state !== "uncertain") {
|
||||
if (it.origin === "host") {
|
||||
if (it.state === "blocked") return t("后台 · 受阻");
|
||||
if (it.state === "uncertain") return t("后台 · 状态不明");
|
||||
return t("后台任务续接");
|
||||
}
|
||||
switch (it.state) {
|
||||
@@ -223,7 +228,7 @@ export function Queue({ queue, running, onRead, onEdit, onMove, onCancel, onSend
|
||||
{ordinary(it) ? (
|
||||
<span className="qdot" aria-hidden="true" />
|
||||
) : (
|
||||
<span className="pill" data-tone={tone(it.state)} title={it.state === "steer_accepted" ? t("下个工具边界送入") : undefined}>
|
||||
<span className="pill" data-tone={tone(it.state)} title={it.state === "steer_accepted" ? t("下个工具边界送入") : it.origin === "host" ? t("后台任务续接") : undefined}>
|
||||
{label(it)}
|
||||
</span>
|
||||
)}
|
||||
@@ -264,7 +269,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 +277,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 +302,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>
|
||||
)}
|
||||
|
||||
@@ -598,7 +598,7 @@ export function WorkbenchPanel({
|
||||
)}
|
||||
{mode === "document" || unpreviewableText ? (
|
||||
<Suspense fallback={<div className="workbench-empty">{t("正在读取…")}</div>}>
|
||||
<DocumentPreview key={filePath} port={port} path={filePath} shown={shown && !showFiles && !launcher}
|
||||
<DocumentPreview key={filePath} port={port} path={filePath} shown={shown && !showFiles && !launcher} onQuote={onQuoteSelection}
|
||||
view={documentState(documentStates, filePath)}
|
||||
refresh={JSON.stringify([changeKey, glance, running, wrote])}
|
||||
onOpenExternal={!remote && port.opensFiles() && /\.(pdf|html?|svg|docx?|xlsx?|pptx?)$/i.test(filePath) ? () => {
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { lazy, Suspense, useEffect, useState } from "react";
|
||||
import { lazy, Suspense, useEffect, useState, useSyncExternalStore } from "react";
|
||||
import { onOfficePreviewChange, previewsOffice } from "../../state/prefs";
|
||||
import type { AgentPort } from "../../port/port";
|
||||
import { t } from "../../i18n";
|
||||
import { useDocument } from "./useDocument";
|
||||
@@ -9,6 +10,7 @@ import type { DocumentViewState } from "./viewState";
|
||||
const PdfPreview = lazy(() => import("./PdfPreview"));
|
||||
const DelimitedPreview = lazy(() => import("./DelimitedPreview"));
|
||||
const StaticHtmlPreview = lazy(() => import("./StaticHtmlPreview"));
|
||||
const OfficePreview = lazy(() => import("./OfficePreview"));
|
||||
|
||||
function Picture({ data, path, view }: { data: ArrayBuffer; path: string; view: DocumentViewState }) {
|
||||
const [url, setURL] = useState("");
|
||||
@@ -33,16 +35,21 @@ function Picture({ data, path, view }: { data: ArrayBuffer; path: string; view:
|
||||
</>;
|
||||
}
|
||||
|
||||
export default function DocumentPreview({ port, path, shown, refresh, onOpenExternal, view }: {
|
||||
export default function DocumentPreview({ port, path, shown, refresh, onOpenExternal, view, onQuote }: {
|
||||
port: AgentPort; path: string; shown: boolean; refresh: string; onOpenExternal?: () => void;
|
||||
onQuote?: (text: string, source: string) => void;
|
||||
view: DocumentViewState;
|
||||
}) {
|
||||
const { value, reload } = useDocument(port, path, shown, refresh);
|
||||
const officeOn = useSyncExternalStore(onOfficePreviewChange, previewsOffice, previewsOffice);
|
||||
const { info, data, error } = value ?? {};
|
||||
const officeWaiting = officeOn && info?.kind === "office" && !data && !error;
|
||||
useEffect(() => { if (officeWaiting) reload(); }, [officeWaiting, reload]);
|
||||
const fallback = <p role="alert">{t("此文件暂不支持预览")}</p>;
|
||||
let body = <p role="status">{t("正在读取…")}</p>;
|
||||
if (error) body = <p role="alert">{error}</p>;
|
||||
else if (info?.kind === "unsupported") body = fallback;
|
||||
else if (info?.kind === "office" && !officeOn) body = <p role="status">{t("Office 文档预览已在设置中关闭")}</p>;
|
||||
else if (info && info.size > info.limit) body = <p role="alert">{t("文件超过预览大小限制")}</p>;
|
||||
else if (data && info) {
|
||||
const text = () => new TextDecoder("utf-8", { fatal: true }).decode(data);
|
||||
@@ -51,6 +58,7 @@ export default function DocumentPreview({ port, path, shown, refresh, onOpenExte
|
||||
case "table": body = <DelimitedPreview path={path} text={text()} view={view} />; break;
|
||||
case "html": body = <StaticHtmlPreview text={text()} />; break;
|
||||
case "image": body = <Picture data={data} path={path} view={view} />; break;
|
||||
case "office": body = <OfficePreview data={data} path={path} view={view} onQuote={onQuote} />; break;
|
||||
default: body = fallback;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { t } from "../../i18n";
|
||||
import { staticDocument } from "./staticDocument";
|
||||
import { readWorkbook, SHEET_COLUMN_LIMIT, SHEET_ROW_LIMIT, type SheetView } from "./officeSheet";
|
||||
import { fitZoom, readWordDocument, type WordView } from "./officeWord";
|
||||
import { markCells, sheetSelection, wordSelection, type OfficeSelection } from "./officeSelection";
|
||||
import { listenAction } from "../listen";
|
||||
import type { DocumentViewState } from "./viewState";
|
||||
|
||||
type Parsed = { kind: "word"; word: WordView } | { kind: "sheets"; sheets: SheetView[] } | { kind: "failed" };
|
||||
|
||||
function useWidth() {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const [width, setWidth] = useState(0);
|
||||
useEffect(() => {
|
||||
const el = ref.current;
|
||||
if (!el || typeof ResizeObserver === "undefined") return;
|
||||
const observer = new ResizeObserver(([entry]) => setWidth(Math.round(entry.contentRect.width)));
|
||||
observer.observe(el);
|
||||
return () => observer.disconnect();
|
||||
}, []);
|
||||
return [ref, width] as const;
|
||||
}
|
||||
|
||||
export default function OfficePreview({ data, path, view, onQuote }: {
|
||||
data: ArrayBuffer; path: string; view: DocumentViewState; onQuote?: (text: string, source: string) => void;
|
||||
}) {
|
||||
const [picked, setPicked] = useState<OfficeSelection>();
|
||||
const frame = useRef<HTMLIFrameElement>(null);
|
||||
const unwatch = useRef<() => void>(undefined);
|
||||
useEffect(() => () => unwatch.current?.(), []);
|
||||
const [parsed, setParsed] = useState<Parsed>();
|
||||
const [sheet, updateSheet] = useState(view.sheet ?? 0);
|
||||
const setSheet = (i: number) => { view.sheet = i; updateSheet(i); };
|
||||
const [box, width] = useWidth();
|
||||
const [chosen, updateZoom] = useState(view.zoom);
|
||||
const setZoom = (value: number | undefined) => { view.zoom = value; updateZoom(value); };
|
||||
useEffect(() => {
|
||||
let live = true;
|
||||
setParsed(undefined);
|
||||
const read = /\.xlsx$/i.test(path)
|
||||
? readWorkbook(data).then((sheets): Parsed => ({ kind: "sheets", sheets }))
|
||||
: readWordDocument(data).then((word): Parsed => ({ kind: "word", word }));
|
||||
read.catch((): Parsed => ({ kind: "failed" })).then((p) => { if (live) setParsed(p); });
|
||||
return () => { live = false; };
|
||||
}, [data, path]);
|
||||
const current = parsed?.kind === "sheets" ? parsed.sheets[Math.min(sheet, parsed.sheets.length - 1)] : undefined;
|
||||
const fit = parsed?.kind === "word" ? fitZoom(width, parsed.word.pageWidth) : 1;
|
||||
const zoom = chosen ?? fit;
|
||||
const html = parsed?.kind === "word" ? `<style>body{zoom:${zoom}}</style>${parsed.word.html}` : current?.html;
|
||||
const doc = useMemo(() => html === undefined ? "" : staticDocument(html), [html]);
|
||||
const onFrameLoad = () => {
|
||||
unwatch.current?.();
|
||||
unwatch.current = undefined;
|
||||
setPicked(undefined);
|
||||
const inner = frame.current?.contentDocument;
|
||||
if (!inner || !onQuote) return;
|
||||
const sheet = current?.name;
|
||||
if (sheet === undefined) {
|
||||
const read = () => setPicked(wordSelection(inner));
|
||||
const stops = [
|
||||
listenAction(inner, "mouseup", { action: "document.select-text", listener: read }),
|
||||
listenAction(inner, "keyup", { action: "document.select-text", listener: read }),
|
||||
];
|
||||
unwatch.current = () => stops.forEach((stop) => stop());
|
||||
return;
|
||||
}
|
||||
let anchor = "";
|
||||
const cellAt = (e: Event) => (e.target as Element | null)?.closest?.("td[data-cell]")?.getAttribute("data-cell") ?? "";
|
||||
const pick = (end: string) => { markCells(inner, anchor, end); setPicked(sheetSelection(inner, sheet, anchor, end)); };
|
||||
const down = (e: MouseEvent) => { const at = cellAt(e); if (!at) return; e.preventDefault(); anchor = e.shiftKey && anchor ? anchor : at; pick(at); };
|
||||
const move = (e: MouseEvent) => { const at = cellAt(e); if (at && anchor && e.buttons & 1) pick(at); };
|
||||
const stops = [
|
||||
listenAction(inner, "pointerdown", { action: "document.select-cells", listener: down as EventListener }),
|
||||
listenAction(inner, "pointermove", { action: "document.select-cells", listener: move as EventListener }),
|
||||
];
|
||||
unwatch.current = () => stops.forEach((stop) => stop());
|
||||
};
|
||||
|
||||
let body;
|
||||
if (!parsed) body = <p role="status">{t("正在读取…")}</p>;
|
||||
else if (parsed.kind === "failed") body = <p role="alert">{t("无法解析这个文件,可以用系统应用打开")}</p>;
|
||||
else if (parsed.kind === "sheets" && !current) body = <p role="status">{t("这个工作簿没有可见的工作表")}</p>;
|
||||
else body = <iframe ref={frame} onLoad={onFrameLoad} className="document-html document-office" title={t("Office 文档预览")} sandbox="allow-same-origin" srcDoc={doc} />;
|
||||
return <div className="document-office-view" ref={box}>
|
||||
{parsed && parsed.kind !== "failed" && <p className="document-notice">{t("只读预览,宏、脚本和外部资源未加载")}</p>}
|
||||
{parsed?.kind === "word" && (
|
||||
<div className="document-controls">
|
||||
<button data-action="document.zoom" data-target={path} onClick={() => setZoom(Math.max(0.25, zoom - 0.1))} aria-label={t("缩小")}>−</button>
|
||||
<button data-action="document.zoom" data-target={path} aria-pressed={chosen === undefined} onClick={() => setZoom(undefined)}>{t("适应宽度")} · {Math.round(zoom * 100)}%</button>
|
||||
<button data-action="document.zoom" data-target={path} onClick={() => setZoom(Math.min(3, zoom + 0.1))} aria-label={t("放大")}>+</button>
|
||||
</div>
|
||||
)}
|
||||
{parsed?.kind === "sheets" && parsed.sheets.length > 1 && (
|
||||
<div className="document-controls" role="tablist" aria-label={t("工作表")}>
|
||||
{parsed.sheets.map((s, i) => (
|
||||
<button key={s.name} role="tab" aria-selected={s === current} data-action="document.sheet" data-target={s.name} onClick={() => setSheet(i)}>{s.name}</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{onQuote && parsed && parsed.kind !== "failed" && (
|
||||
<div className="document-controls">
|
||||
<button data-action="document.quote" data-target={path} disabled={!picked} onClick={() => picked && onQuote(picked.text, `${path} (${picked.position})`)}>
|
||||
{picked ? t("加入对话 · {where}", { where: picked.label }) : t(parsed.kind === "word" ? "选中文字后可加入对话" : "点选或拖选单元格后可加入对话")}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{current?.clipped && <p className="document-notice">{t("只显示前 {rows} 行、{cols} 列", { rows: SHEET_ROW_LIMIT, cols: SHEET_COLUMN_LIMIT })}</p>}
|
||||
{body}
|
||||
</div>;
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
.document-preview, .document-pdf {
|
||||
.document-preview, .document-pdf, .document-office-view {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
@@ -23,6 +23,15 @@
|
||||
.document-notice, .document-preview > p {
|
||||
padding: 8px 12px;
|
||||
}
|
||||
.document-office-view > .document-notice {
|
||||
margin: 0;
|
||||
font-size: 12px;
|
||||
font-weight: normal;
|
||||
color: var(--muted);
|
||||
}
|
||||
.document-controls [role="tab"][aria-selected="true"] {
|
||||
border-color: var(--text);
|
||||
}
|
||||
.document-html {
|
||||
flex: 1;
|
||||
width: 100%;
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
// @vitest-environment jsdom
|
||||
import { act, cleanup, render, screen, waitFor } from "@testing-library/react";
|
||||
import ExcelJS from "exceljs";
|
||||
import { afterEach, beforeEach, expect, it, vi } from "vitest";
|
||||
import { MockPort } from "../../port/mock";
|
||||
import { setPreviewsOffice } from "../../state/prefs";
|
||||
import DocumentPreview from "./DocumentPreview";
|
||||
import { previewKind } from "./registry";
|
||||
|
||||
beforeEach(() => localStorage.clear());
|
||||
afterEach(() => cleanup());
|
||||
|
||||
async function sheetBytes(): Promise<ArrayBuffer> {
|
||||
const book = new ExcelJS.Workbook();
|
||||
book.addWorksheet("Q3").addRow(["区域", 1100]);
|
||||
return new Uint8Array((await book.xlsx.writeBuffer()) as ArrayBuffer).slice().buffer;
|
||||
}
|
||||
|
||||
function officePort(data: ArrayBuffer) {
|
||||
const port = new MockPort();
|
||||
vi.spyOn(port, "workspacePreviewInfo").mockResolvedValue({ ref: { path: "budget.xlsx" }, kind: "office", version: "v1", size: data.byteLength, limit: 32 << 20, readOnly: false });
|
||||
const content = vi.spyOn(port, "workspacePreviewContent").mockResolvedValue({ data, version: "v1" });
|
||||
return { port, content };
|
||||
}
|
||||
|
||||
it("classes the formats it can read as office and leaves the rest unsupported", () => {
|
||||
expect(previewKind("报告.docx")).toBe("office");
|
||||
expect(previewKind("budget.XLSX")).toBe("office");
|
||||
for (const name of ["old.doc", "old.xls", "deck.pptx", "old.ppt"]) expect(previewKind(name)).toBe("unsupported");
|
||||
});
|
||||
|
||||
it("renders a workbook in a sandboxed frame", async () => {
|
||||
const { port } = officePort(await sheetBytes());
|
||||
render(<DocumentPreview port={port} path="budget.xlsx" shown refresh="" view={{}} />);
|
||||
const frame = await screen.findByTitle("Office 文档预览", undefined, { timeout: 5000 });
|
||||
expect(frame.getAttribute("sandbox")).toBe("allow-same-origin");
|
||||
expect(frame.getAttribute("sandbox")).not.toContain("allow-scripts");
|
||||
expect(frame.getAttribute("srcdoc")).toContain("1100");
|
||||
expect(frame.getAttribute("srcdoc")).toContain("Content-Security-Policy");
|
||||
});
|
||||
|
||||
it("with the setting off reads no content, and turning it on loads the preview", async () => {
|
||||
setPreviewsOffice(false);
|
||||
const { port, content } = officePort(await sheetBytes());
|
||||
render(<DocumentPreview port={port} path="budget.xlsx" shown refresh="" view={{}} />);
|
||||
await screen.findByText("Office 文档预览已在设置中关闭");
|
||||
expect(content).not.toHaveBeenCalled();
|
||||
act(() => setPreviewsOffice(true));
|
||||
await waitFor(() => expect(content).toHaveBeenCalled());
|
||||
expect(await screen.findByTitle("Office 文档预览", undefined, { timeout: 5000 })).toBeTruthy();
|
||||
});
|
||||
|
||||
it("says so instead of breaking when the file is not a real workbook", async () => {
|
||||
const { port } = officePort(new TextEncoder().encode("not a zip").buffer);
|
||||
render(<DocumentPreview port={port} path="budget.xlsx" shown refresh="" view={{}} />);
|
||||
expect(await screen.findByText("无法解析这个文件,可以用系统应用打开", undefined, { timeout: 5000 })).toBeTruthy();
|
||||
});
|
||||
@@ -0,0 +1,102 @@
|
||||
// @vitest-environment jsdom
|
||||
import ExcelJS from "exceljs";
|
||||
import JSZip from "jszip";
|
||||
import { expect, it } from "vitest";
|
||||
import { displayText, readWorkbook, SHEET_ROW_LIMIT } from "./officeSheet";
|
||||
import { fitZoom, readWordDocument } from "./officeWord";
|
||||
import { staticDocument } from "./staticDocument";
|
||||
|
||||
async function workbook(build: (book: ExcelJS.Workbook) => void): Promise<ArrayBuffer> {
|
||||
const book = new ExcelJS.Workbook();
|
||||
build(book);
|
||||
const buf = await book.xlsx.writeBuffer();
|
||||
return new Uint8Array(buf as ArrayBuffer).slice().buffer;
|
||||
}
|
||||
|
||||
const parse = (html: string) => new DOMParser().parseFromString(staticDocument(html), "text/html");
|
||||
|
||||
it("shows a cell the way its own number format writes it", () => {
|
||||
expect(displayText(1100, "#,##0")).toBe("1,100");
|
||||
expect(displayText(0.31, "0.0%")).toBe("31.0%");
|
||||
expect(displayText(-0.04, "0.0%")).toBe("-4.0%");
|
||||
expect(displayText(1100)).toBe("1100");
|
||||
expect(displayText({ formula: "SUM(A1:A2)", result: 2500 } as ExcelJS.CellValue, "#,##0")).toBe("2,500");
|
||||
expect(displayText({ richText: [{ text: "华" }, { text: "东" }] } as ExcelJS.CellValue)).toBe("华东");
|
||||
expect(displayText(new Date(Date.UTC(2026, 9, 1)), "yyyy-mm-dd")).toBe("2026-10-01");
|
||||
expect(displayText(12, "[bogus")).toBe("12");
|
||||
});
|
||||
|
||||
it("renders sheets with merges, styles and escaped text, skipping hidden ones", async () => {
|
||||
const data = await workbook((book) => {
|
||||
const ws = book.addWorksheet("Q3");
|
||||
ws.addRow(["区域", "7月"]);
|
||||
ws.addRow(["<script>alert(1)</script>", 1100]);
|
||||
ws.getCell("B2").numFmt = "#,##0";
|
||||
ws.getCell("A1").font = { bold: true, color: { argb: "FFFFFFFF" } };
|
||||
ws.getCell("A1").fill = { type: "pattern", pattern: "solid", fgColor: { argb: "FF1F4E79" } };
|
||||
ws.mergeCells("A3:B3");
|
||||
ws.getCell("A3").value = "备注";
|
||||
book.addWorksheet("secret", { state: "hidden" }).addRow(["hidden"]);
|
||||
});
|
||||
const sheets = await readWorkbook(data);
|
||||
expect(sheets.map((s) => s.name)).toEqual(["Q3"]);
|
||||
const doc = parse(sheets[0].html);
|
||||
expect(doc.querySelector("script")).toBeNull();
|
||||
expect(doc.querySelector('[data-cell="A2"]')?.textContent).toBe("<script>alert(1)</script>");
|
||||
expect(doc.querySelector('[data-cell="B2"]')?.textContent).toBe("1,100");
|
||||
const header = doc.querySelector<HTMLElement>('[data-cell="A1"]')!;
|
||||
expect(header.style.background).toContain("rgb(31, 78, 121)");
|
||||
expect(header.style.fontWeight).toBe("bold");
|
||||
expect(doc.querySelector('[data-cell="A3"]')?.getAttribute("colspan")).toBe("2");
|
||||
expect(doc.querySelector('[data-cell="B3"]')).toBeNull();
|
||||
});
|
||||
|
||||
it("clips a sheet past the row limit and says so", async () => {
|
||||
const data = await workbook((book) => {
|
||||
const ws = book.addWorksheet("big");
|
||||
for (let i = 0; i < SHEET_ROW_LIMIT + 5; i++) ws.addRow([i]);
|
||||
});
|
||||
const [sheet] = await readWorkbook(data);
|
||||
expect(sheet.clipped).toBe(true);
|
||||
expect(sheet.rows).toBe(SHEET_ROW_LIMIT + 5);
|
||||
expect(parse(sheet.html).querySelectorAll("tbody tr")).toHaveLength(SHEET_ROW_LIMIT);
|
||||
});
|
||||
|
||||
async function wordFile(body: string): Promise<ArrayBuffer> {
|
||||
const zip = new JSZip();
|
||||
zip.file("[Content_Types].xml", '<?xml version="1.0"?><Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types"><Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/><Default Extension="xml" ContentType="application/xml"/><Override PartName="/word/document.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"/></Types>');
|
||||
zip.file("_rels/.rels", '<?xml version="1.0"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="word/document.xml"/></Relationships>');
|
||||
zip.file("word/document.xml", `<?xml version="1.0"?><w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:body>${body}</w:body></w:document>`);
|
||||
return zip.generateAsync({ type: "arraybuffer" });
|
||||
}
|
||||
|
||||
it("renders a Word body to static HTML that keeps its text and runs nothing", async () => {
|
||||
const data = await wordFile('<w:p><w:r><w:rPr><w:b/></w:rPr><w:t>季度报告</w:t></w:r><w:r><w:t xml:space="preserve"> <img src=x onerror=alert(1)></w:t></w:r></w:p>');
|
||||
const word = await readWordDocument(data);
|
||||
const doc = parse(word.html);
|
||||
expect(doc.body.textContent).toContain("季度报告");
|
||||
expect(doc.body.textContent).toContain("<img src=x onerror=alert(1)>");
|
||||
expect(doc.querySelector("img,script,[onerror]")).toBeNull();
|
||||
expect(doc.querySelector('meta[http-equiv="Content-Security-Policy"]')).not.toBeNull();
|
||||
});
|
||||
|
||||
it("refuses something that is not an Office package", async () => {
|
||||
await expect(readWorkbook(new TextEncoder().encode("not a zip").buffer)).rejects.toThrow();
|
||||
await expect(readWordDocument(new TextEncoder().encode("not a zip").buffer)).rejects.toThrow();
|
||||
});
|
||||
|
||||
it("draws Symbol-font bullets as the glyphs they stand for", async () => {
|
||||
const data = await wordFile('<w:p><w:r><w:t>\uF0B7 one \uF0A7 two</w:t></w:r></w:p>');
|
||||
const { html } = await readWordDocument(data);
|
||||
expect(html).toContain("• one ▪ two");
|
||||
expect(html).not.toMatch(/[\uF020-\uF0FF]/);
|
||||
});
|
||||
|
||||
it("fits a page to the frame in 5% steps and never past 100%", () => {
|
||||
expect(fitZoom(848, 816)).toBe(1);
|
||||
expect(fitZoom(2000, 816)).toBe(1);
|
||||
expect(fitZoom(520, 816)).toBe(0.55);
|
||||
expect(fitZoom(100, 816)).toBe(0.25);
|
||||
expect(fitZoom(520, 0)).toBe(1);
|
||||
expect(fitZoom(0, 816)).toBe(1);
|
||||
});
|
||||
@@ -0,0 +1,65 @@
|
||||
// @vitest-environment jsdom
|
||||
import { expect, it } from "vitest";
|
||||
import { cellRange, markCells, sheetSelection, wordSelection } from "./officeSelection";
|
||||
|
||||
it("normalises two cell addresses into one rectangle", () => {
|
||||
expect(cellRange("D4", "B2")?.text).toBe("B2:D4");
|
||||
expect(cellRange("B4", "D2")?.text).toBe("B2:D4");
|
||||
expect(cellRange("C3", "C3")?.text).toBe("C3");
|
||||
expect(cellRange("Z1", "AB2")?.text).toBe("Z1:AB2");
|
||||
expect(cellRange("B2", "nope")).toBeUndefined();
|
||||
});
|
||||
|
||||
function sheet(): Document {
|
||||
const doc = document.implementation.createHTMLDocument();
|
||||
doc.body.innerHTML = '<table><tbody>'
|
||||
+ '<tr><th>1</th><td data-cell="A1">区域</td><td data-cell="B1">7月</td></tr>'
|
||||
+ '<tr><th>2</th><td data-cell="A2">华东</td><td data-cell="B2">1,100</td></tr>'
|
||||
+ '</tbody></table>';
|
||||
return doc;
|
||||
}
|
||||
|
||||
it("quotes a cell range as tab-separated rows with its sheet and range", () => {
|
||||
const doc = sheet();
|
||||
const picked = sheetSelection(doc, "Q3", "B2", "A1");
|
||||
expect(picked?.text).toBe("区域\t7月\n华东\t1,100");
|
||||
expect(picked?.position).toBe('sheet "Q3", cells A1:B2');
|
||||
markCells(doc, "A2", "B2");
|
||||
expect([...doc.querySelectorAll(".sel")].map((el) => el.getAttribute("data-cell"))).toEqual(["A2", "B2"]);
|
||||
markCells(doc, "A1", "A1");
|
||||
expect([...doc.querySelectorAll(".sel")].map((el) => el.getAttribute("data-cell"))).toEqual(["A1"]);
|
||||
});
|
||||
|
||||
function word(): Document {
|
||||
const doc = document;
|
||||
doc.body.innerHTML = '<section class="docx"><header><p>页眉</p></header><article>'
|
||||
+ '<p id="b1">标题</p><p id="b2">第一段</p><table id="b3"><tr><td>表格</td></tr></table></article></section>'
|
||||
+ '<section class="docx"><article><p id="b4">第二页</p></article></section>';
|
||||
return doc;
|
||||
}
|
||||
|
||||
it("names the body blocks a Word selection covers, across pages and past the header", () => {
|
||||
const doc = word();
|
||||
const select = (from: string, to: string) => {
|
||||
const range = doc.createRange();
|
||||
range.setStart(doc.getElementById(from)!.firstChild!, 0);
|
||||
const end = doc.getElementById(to)!;
|
||||
range.setEnd(end.querySelector("td")?.firstChild ?? end.firstChild!, 1);
|
||||
doc.getSelection()!.removeAllRanges();
|
||||
doc.getSelection()!.addRange(range);
|
||||
};
|
||||
select("b2", "b2");
|
||||
expect(wordSelection(doc)?.position).toBe("body block 2");
|
||||
select("b2", "b4");
|
||||
expect(wordSelection(doc)?.position).toBe("body blocks 2-4");
|
||||
select("b3", "b3");
|
||||
expect(wordSelection(doc)?.position).toBe("body block 3");
|
||||
const triple = doc.createRange();
|
||||
triple.setStart(doc.getElementById("b2")!.firstChild!, 0);
|
||||
triple.setEnd(doc.getElementById("b3")!, 0);
|
||||
doc.getSelection()!.removeAllRanges();
|
||||
doc.getSelection()!.addRange(triple);
|
||||
expect(wordSelection(doc)?.position).toBe("body block 2");
|
||||
doc.getSelection()!.removeAllRanges();
|
||||
expect(wordSelection(doc)).toBeUndefined();
|
||||
});
|
||||
@@ -0,0 +1,86 @@
|
||||
import { t } from "../../i18n";
|
||||
|
||||
export interface OfficeSelection {
|
||||
text: string;
|
||||
/** Where the text sits in the file, in terms a later read of the same file can resolve. */
|
||||
position: string;
|
||||
/** The same place, worded for the reader. */
|
||||
label: string;
|
||||
}
|
||||
|
||||
const CELL = /^([A-Z]+)(\d+)$/;
|
||||
const columnNumber = (s: string) => [...s].reduce((n, ch) => n * 26 + ch.charCodeAt(0) - 64, 0);
|
||||
function columnLetters(n: number): string {
|
||||
let s = "";
|
||||
for (; n > 0; n = Math.floor((n - 1) / 26)) s = String.fromCharCode(65 + ((n - 1) % 26)) + s;
|
||||
return s;
|
||||
}
|
||||
|
||||
/** The rectangle two cell addresses span, written top-left to bottom-right. */
|
||||
export function cellRange(a: string, b: string): { top: number; left: number; bottom: number; right: number; text: string } | undefined {
|
||||
const x = CELL.exec(a), y = CELL.exec(b);
|
||||
if (!x || !y) return undefined;
|
||||
const top = Math.min(Number(x[2]), Number(y[2])), bottom = Math.max(Number(x[2]), Number(y[2]));
|
||||
const left = Math.min(columnNumber(x[1]), columnNumber(y[1])), right = Math.max(columnNumber(x[1]), columnNumber(y[1]));
|
||||
const start = `${columnLetters(left)}${top}`, end = `${columnLetters(right)}${bottom}`;
|
||||
return { top, left, bottom, right, text: start === end ? start : `${start}:${end}` };
|
||||
}
|
||||
|
||||
/** The cells of a range in the rendered sheet, as tab-separated rows. */
|
||||
export function sheetSelection(doc: Document, sheet: string, a: string, b: string): OfficeSelection | undefined {
|
||||
const range = cellRange(a, b);
|
||||
if (!range) return undefined;
|
||||
const rows: string[] = [];
|
||||
for (let r = range.top; r <= range.bottom; r++) {
|
||||
const row: string[] = [];
|
||||
for (let c = range.left; c <= range.right; c++) {
|
||||
row.push(doc.querySelector(`[data-cell="${columnLetters(c)}${r}"]`)?.textContent ?? "");
|
||||
}
|
||||
rows.push(row.join("\t"));
|
||||
}
|
||||
return { text: rows.join("\n"), position: `sheet "${sheet}", cells ${range.text}`, label: t("{sheet} 工作表 {range}", { sheet, range: range.text }) };
|
||||
}
|
||||
|
||||
/** Marks the cells of a range, clearing any earlier mark. */
|
||||
export function markCells(doc: Document, a: string, b: string): void {
|
||||
doc.querySelectorAll(".sel").forEach((el) => el.classList.remove("sel"));
|
||||
const range = cellRange(a, b);
|
||||
if (!range) return;
|
||||
for (let r = range.top; r <= range.bottom; r++) {
|
||||
for (let c = range.left; c <= range.right; c++) doc.querySelector(`[data-cell="${columnLetters(c)}${r}"]`)?.classList.add("sel");
|
||||
}
|
||||
}
|
||||
|
||||
const bodyBlocks = (doc: Document) => [...doc.querySelectorAll("section.docx > article > *")];
|
||||
|
||||
function blockOf(blocks: Element[], node: Node | null): number {
|
||||
for (let el: Node | null = node; el; el = el.parentNode) {
|
||||
const at = blocks.indexOf(el as Element);
|
||||
if (at >= 0) return at + 1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
/** The selected text of a rendered Word body and the body blocks it covers, counted
|
||||
* from 1 in document order with each paragraph and each table one block. */
|
||||
export function wordSelection(doc: Document): OfficeSelection | undefined {
|
||||
const sel = doc.getSelection();
|
||||
const text = sel?.toString().trim();
|
||||
if (!sel || sel.isCollapsed || !text || sel.rangeCount === 0) return undefined;
|
||||
const range = sel.getRangeAt(0);
|
||||
const blocks = bodyBlocks(doc);
|
||||
const first = blockOf(blocks, range.startContainer);
|
||||
let last = blockOf(blocks, range.endContainer);
|
||||
if (first === 0 || last === 0) return undefined;
|
||||
// A triple click ends the range at the start of the next block; a block none of
|
||||
// whose text is selected is not part of the selection.
|
||||
if (last > first) {
|
||||
const tail = doc.createRange();
|
||||
tail.setStart(blocks[last - 1], 0);
|
||||
tail.setEnd(range.endContainer, range.endOffset);
|
||||
if (!tail.toString().trim()) last--;
|
||||
}
|
||||
return first === last
|
||||
? { text, position: `body block ${first}`, label: t("正文第 {n} 块", { n: first }) }
|
||||
: { text, position: `body blocks ${first}-${last}`, label: t("正文第 {a}–{b} 块", { a: first, b: last }) };
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
import type { Cell, CellValue, Workbook, Worksheet } from "exceljs";
|
||||
import * as SSF from "ssf";
|
||||
|
||||
export const SHEET_ROW_LIMIT = 2000;
|
||||
export const SHEET_COLUMN_LIMIT = 200;
|
||||
|
||||
export interface SheetView {
|
||||
name: string;
|
||||
html: string;
|
||||
rows: number;
|
||||
columns: number;
|
||||
clipped: boolean;
|
||||
}
|
||||
|
||||
const escape = (s: string) => s.replace(/[&<>"']/g, (c) => `&#${c.charCodeAt(0)};`);
|
||||
const color = (argb: unknown) => typeof argb === "string" && /^[0-9a-f]{8}$/i.test(argb) ? `#${argb.slice(2)}` : "";
|
||||
const EXCEL_EPOCH = Date.UTC(1899, 11, 30);
|
||||
|
||||
function raw(value: CellValue): unknown {
|
||||
if (value === null || value === undefined) return "";
|
||||
if (value instanceof Date) return value;
|
||||
if (typeof value !== "object") return value;
|
||||
if ("richText" in value) return value.richText.map((part) => part.text).join("");
|
||||
if ("result" in value) return value.result === undefined ? "" : raw(value.result as CellValue);
|
||||
if ("error" in value) return value.error;
|
||||
if ("text" in value) return value.text;
|
||||
return "";
|
||||
}
|
||||
|
||||
/** The text Excel would show: the cell's own number format applied to its value. */
|
||||
export function displayText(value: CellValue, numFmt?: string): string {
|
||||
const v = raw(value);
|
||||
const format = numFmt && numFmt !== "General" ? numFmt : undefined;
|
||||
try {
|
||||
if (v instanceof Date) return SSF.format(format ?? "yyyy-mm-dd", (v.getTime() - EXCEL_EPOCH) / 864e5);
|
||||
if (typeof v === "number" && format) return SSF.format(format, v);
|
||||
} catch {
|
||||
// An unparseable format falls back to the plain value rather than failing the sheet.
|
||||
}
|
||||
if (v instanceof Date) return v.toISOString().slice(0, 10);
|
||||
return String(v);
|
||||
}
|
||||
|
||||
function cellStyle(cell: Cell): string {
|
||||
const parts: string[] = [];
|
||||
const fill = cell.fill?.type === "pattern" ? color(cell.fill.fgColor?.argb) : "";
|
||||
if (fill) parts.push(`background:${fill}`);
|
||||
const font = cell.font;
|
||||
if (font?.bold) parts.push("font-weight:bold");
|
||||
if (font?.italic) parts.push("font-style:italic");
|
||||
const fg = color(font?.color?.argb);
|
||||
if (fg) parts.push(`color:${fg}`);
|
||||
if (typeof font?.size === "number" && font.size > 0 && font.size < 200) parts.push(`font-size:${font.size}pt`);
|
||||
const align = cell.alignment?.horizontal;
|
||||
if (align === "left" || align === "center" || align === "right") parts.push(`text-align:${align}`);
|
||||
else if (typeof raw(cell.value) === "number") parts.push("text-align:right");
|
||||
return parts.join(";");
|
||||
}
|
||||
|
||||
function decodeRange(range: string): { top: number; left: number; bottom: number; right: number } | undefined {
|
||||
const m = /^([A-Z]+)(\d+):([A-Z]+)(\d+)$/.exec(range);
|
||||
if (!m) return undefined;
|
||||
const col = (s: string) => [...s].reduce((n, ch) => n * 26 + ch.charCodeAt(0) - 64, 0);
|
||||
return { top: Number(m[2]), left: col(m[1]), bottom: Number(m[4]), right: col(m[3]) };
|
||||
}
|
||||
|
||||
export function sheetView(ws: Worksheet): SheetView {
|
||||
const rows = Math.min(ws.rowCount, SHEET_ROW_LIMIT);
|
||||
const columns = Math.min(ws.columnCount, SHEET_COLUMN_LIMIT);
|
||||
const span = new Map<string, { rows: number; cols: number }>();
|
||||
const covered = new Set<string>();
|
||||
for (const range of (ws.model as { merges?: string[] }).merges ?? []) {
|
||||
const r = decodeRange(range);
|
||||
if (!r) continue;
|
||||
span.set(`${r.top}:${r.left}`, { rows: r.bottom - r.top + 1, cols: r.right - r.left + 1 });
|
||||
for (let y = r.top; y <= r.bottom; y++) for (let x = r.left; x <= r.right; x++) if (y !== r.top || x !== r.left) covered.add(`${y}:${x}`);
|
||||
}
|
||||
const widths: string[] = [];
|
||||
for (let x = 1; x <= columns; x++) {
|
||||
const w = ws.getColumn(x).width;
|
||||
widths.push(`<col style="width:${Math.round(Math.min(Math.max(typeof w === "number" ? w : 9, 2), 120) * 7 + 10)}px">`);
|
||||
}
|
||||
let body = "";
|
||||
for (let y = 1; y <= rows; y++) {
|
||||
const row = ws.getRow(y);
|
||||
let cells = `<th>${y}</th>`;
|
||||
for (let x = 1; x <= columns; x++) {
|
||||
if (covered.has(`${y}:${x}`)) continue;
|
||||
const cell = row.getCell(x);
|
||||
const merge = span.get(`${y}:${x}`);
|
||||
const attrs = merge ? `${merge.rows > 1 ? ` rowspan="${merge.rows}"` : ""}${merge.cols > 1 ? ` colspan="${merge.cols}"` : ""}` : "";
|
||||
const style = cellStyle(cell);
|
||||
cells += `<td data-cell="${escape(cell.address)}"${attrs}${style ? ` style="${style}"` : ""}>${escape(displayText(cell.value, cell.numFmt))}</td>`;
|
||||
}
|
||||
body += `<tr>${cells}</tr>`;
|
||||
}
|
||||
let head = "<th></th>";
|
||||
for (let x = 1; x <= columns; x++) head += `<th>${escape(ws.getColumn(x).letter)}</th>`;
|
||||
const html = `<style>${SHEET_CSS}</style><table><colgroup><col style="width:40px">${widths.join("")}</colgroup><thead><tr>${head}</tr></thead><tbody>${body}</tbody></table>`;
|
||||
return { name: ws.name, html, rows: ws.rowCount, columns: ws.columnCount, clipped: ws.rowCount > rows || ws.columnCount > columns };
|
||||
}
|
||||
|
||||
const SHEET_CSS = "body{margin:0;font:13px system-ui,sans-serif;color:#1f1f1f;background:#fff}"
|
||||
+ "table{border-collapse:collapse;table-layout:fixed}"
|
||||
+ "td,th{border:1px solid #d9d9d9;padding:2px 6px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}"
|
||||
+ "th{background:#f3f3f3;color:#666;font-weight:normal;position:sticky;top:0}"
|
||||
+ "tbody th{position:sticky;left:0}"
|
||||
+ "td{cursor:cell;user-select:none}"
|
||||
+ ".sel{outline:2px solid #2f6fdf;outline-offset:-2px;background-image:linear-gradient(rgba(47,111,223,.16),rgba(47,111,223,.16))}";
|
||||
|
||||
export async function readWorkbook(data: ArrayBuffer): Promise<SheetView[]> {
|
||||
const { default: ExcelJS } = await import("exceljs");
|
||||
const book: Workbook = new ExcelJS.Workbook();
|
||||
await book.xlsx.load(data);
|
||||
return book.worksheets.filter((ws) => ws.state !== "hidden" && ws.state !== "veryHidden").map(sheetView);
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
const WORD_CSS = "body{margin:0;background:#e8e8e8}"
|
||||
+ ".docx-wrapper{background:#e8e8e8;padding:16px 0}"
|
||||
+ ".docx-wrapper>section.docx{margin:0 auto 16px;box-shadow:0 1px 4px rgba(0,0,0,.2)}";
|
||||
|
||||
// Word draws bullets with private-use code points from the Symbol and Wingdings
|
||||
// fonts, which the preview cannot load; these are the Unicode glyphs they stand for.
|
||||
const SYMBOL_BULLETS: Record<string, string> = {
|
||||
"": "•", "": "▪", "": "□", "": "●", "": "■",
|
||||
"": "➢", "": "❖", "": "✓", "": "➔", "": "◇",
|
||||
};
|
||||
|
||||
export interface WordView {
|
||||
html: string;
|
||||
/** The first page's width in CSS pixels, 0 when the file declares none. */
|
||||
pageWidth: number;
|
||||
}
|
||||
|
||||
function pixels(width: string): number {
|
||||
const m = /^([\d.]+)(pt|px|in|cm|mm)$/.exec(width.trim());
|
||||
if (!m) return 0;
|
||||
const per: Record<string, number> = { pt: 4 / 3, px: 1, in: 96, cm: 96 / 2.54, mm: 96 / 25.4 };
|
||||
return Number(m[1]) * per[m[2]];
|
||||
}
|
||||
|
||||
/** A Word file as static HTML: images inlined as data URLs, nothing fetched later. */
|
||||
export async function readWordDocument(data: ArrayBuffer): Promise<WordView> {
|
||||
const { renderAsync } = await import("docx-preview");
|
||||
const body = document.createElement("div");
|
||||
const styles = document.createElement("div");
|
||||
await renderAsync(data, body, styles, {
|
||||
inWrapper: true,
|
||||
breakPages: true,
|
||||
ignoreLastRenderedPageBreak: true,
|
||||
useBase64URL: true,
|
||||
renderHeaders: true,
|
||||
renderFooters: true,
|
||||
renderFootnotes: true,
|
||||
});
|
||||
const page = body.querySelector<HTMLElement>("section.docx");
|
||||
const html = `<style>${WORD_CSS}</style>${styles.innerHTML}${body.innerHTML}`
|
||||
.replace(/[-]/g, (c) => SYMBOL_BULLETS[c] ?? c);
|
||||
return { html, pageWidth: page ? pixels(page.style.width) : 0 };
|
||||
}
|
||||
|
||||
/** The zoom that fits a page of pageWidth into a frame of frameWidth, in 5% steps. */
|
||||
export function fitZoom(frameWidth: number, pageWidth: number): number {
|
||||
if (pageWidth <= 0 || frameWidth <= 0) return 1;
|
||||
return Math.max(0.25, Math.min(1, Math.floor(((frameWidth - 32) / pageWidth) * 20) / 20));
|
||||
}
|
||||
@@ -7,7 +7,8 @@ export const renderers: ReadonlyArray<{ kind: Kind; extensions: RegExp; source:
|
||||
{ kind: "pdf", extensions: /\.pdf$/i, source: false },
|
||||
{ kind: "table", extensions: /\.(csv|tsv)$/i, source: true },
|
||||
{ kind: "html", extensions: /\.html?$/i, source: true },
|
||||
{ kind: "unsupported", extensions: /\.(docx?|xlsx?|pptx?|zip|gz|7z|rar|exe|dll|bin)$/i, source: false },
|
||||
{ kind: "office", extensions: /\.(docx|xlsx)$/i, source: false },
|
||||
{ kind: "unsupported", extensions: /\.(doc|xls|pptx?|zip|gz|7z|rar|exe|dll|bin)$/i, source: false },
|
||||
];
|
||||
|
||||
export function previewKind(path: string): Kind {
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { AgentPort } from "../../port/port";
|
||||
import type { PreviewInfo } from "../../port/workspace";
|
||||
import { parseGrantedFile } from "../grantedfiles";
|
||||
import { reason } from "../../i18n/kernel";
|
||||
import { previewsOffice } from "../../state/prefs";
|
||||
|
||||
type Loaded = { info: PreviewInfo; data?: ArrayBuffer; error?: string };
|
||||
|
||||
@@ -31,7 +32,7 @@ export function useDocument(port: AgentPort, path: string, shown: boolean, refre
|
||||
observed = info;
|
||||
if (controller.signal.aborted) return;
|
||||
let value: Loaded;
|
||||
if (info.kind === "unsupported" || info.size > info.limit) value = { info };
|
||||
if (info.kind === "unsupported" || info.size > info.limit || (info.kind === "office" && !previewsOffice())) value = { info };
|
||||
else if (!force && cache.current?.data && cache.current.info.version === info.version) value = { ...cache.current, info, error: undefined };
|
||||
else {
|
||||
const loaded = await port.workspacePreviewContent(ref, controller.signal);
|
||||
|
||||
@@ -3,6 +3,7 @@ export interface DocumentViewState {
|
||||
left?: number;
|
||||
page?: number;
|
||||
zoom?: number;
|
||||
sheet?: number;
|
||||
}
|
||||
|
||||
export function documentState(states: Map<string, DocumentViewState>, path: string): DocumentViewState {
|
||||
|
||||
@@ -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,8 @@ 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: "office-preview", title: "Office 文档预览", scope: "machine", apply: "immediate", keywords: ["office", "word", "excel", "docx", "xlsx", "工作台", "预览", "文档", "表格"] },
|
||||
{ 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 +341,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));
|
||||
@@ -84,6 +123,22 @@ describe("queued submission and withdrawal", () => {
|
||||
expect(box().value).toBe("");
|
||||
});
|
||||
|
||||
it("names a running send with a skill card as queued, because only plain text can steer", async () => {
|
||||
const port = await open();
|
||||
const steer = vi.spyOn(port, "steer");
|
||||
const followup = vi.spyOn(port, "queueFollowup");
|
||||
type("先看一下 /in");
|
||||
await screen.findByRole("option", { name: /init/ });
|
||||
fireEvent.keyDown(box(), { key: "Enter" });
|
||||
await waitFor(() => expect(box().value).toBe("先看一下 /init "));
|
||||
type("先看一下 /init 再说");
|
||||
expect(screen.queryByRole("button", { name: "插话" })).toBeNull();
|
||||
expect(screen.getByText("Enter 排队 · Shift+Enter 换行")).toBeTruthy();
|
||||
fireEvent.click(screen.getByRole("button", { name: "排队发送" }));
|
||||
await waitFor(() => expect(followup).toHaveBeenCalled());
|
||||
expect(steer).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("stays quiet when the queue is not paused", async () => {
|
||||
const port = await open();
|
||||
vi.spyOn(port, "steer").mockResolvedValue({ itemId: "steer-1", disposition: "steer_accepted" });
|
||||
|
||||
@@ -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("发送已暂停");
|
||||
@@ -135,7 +141,10 @@ describe("what the pending strip says back", () => {
|
||||
// to reach them here.
|
||||
it("shows a host continuation that needs a decision", () => {
|
||||
const html = draw(snapshot({ items: [item({ origin: "host", state: "blocked", intent: "followup" })] }));
|
||||
expect(html).toContain("受阻");
|
||||
expect(html).toContain("后台 · 受阻");
|
||||
expect(html).toContain('title="后台任务续接"');
|
||||
const uncertain = draw(snapshot({ items: [item({ origin: "host", state: "uncertain", intent: "followup" })] }));
|
||||
expect(uncertain).toContain("后台 · 状态不明");
|
||||
});
|
||||
|
||||
it("names an accepted steer as taken, not as waiting", () => {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
@@ -326,6 +326,7 @@ function analysisContext(sources: Map<string, string | Node>, opts: Options) {
|
||||
const prop = (node.property as Node)?.name as string | undefined;
|
||||
if (/^(window|document|globalThis)$/.test(flat(node))) return "Window";
|
||||
if (prop === "currentTarget" || prop === "target") return "EventTarget";
|
||||
if (prop === "contentDocument" && targetTypeOf(node.object as Node, path, depth + 1, stack) === "HTMLIFrameElement") return "Document";
|
||||
if (prop !== "current" || (node.object as Node)?.type !== "Identifier") return null;
|
||||
return refArgs.get(path + "#" + ((node.object as Node).name as string)) ?? null;
|
||||
}
|
||||
|
||||
@@ -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"));
|
||||
});
|
||||
});
|
||||
Loaded 100 of 260 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user