mirror of
https://github.com/esengine/DeepSeek-Reasonix.git
synced 2026-11-07 10:45:59 -10:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cbcc2ebb1e | ||
|
|
674c2b45e7 | ||
|
|
ec6929c157 | ||
|
|
dcd4135049 | ||
|
|
08ea935ea9 | ||
|
|
2a3a3738db | ||
|
|
d1eb9c968e | ||
|
|
a2597fb17c | ||
|
|
41ea14ccc9 | ||
|
|
b3bea0d076 | ||
|
|
efff0a825f | ||
|
|
44712c3aad | ||
|
|
d188af4207 | ||
|
|
8a89f0874b | ||
|
|
350379ffff | ||
|
|
65ecbce661 | ||
|
|
2656f33f28 | ||
|
|
e31c2347b4 | ||
|
|
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 | ||
|
|
396b2c6b6d | ||
|
|
79a2232e55 | ||
|
|
af1628b8ea | ||
|
|
73da13ee39 | ||
|
|
955474f6fc | ||
|
|
1ba8ed87cb | ||
|
|
181e697147 | ||
|
|
bccf76ab7e | ||
|
|
8a1fda41a7 | ||
|
|
da91464adb | ||
|
|
1619856458 | ||
|
|
51eb1bb24f | ||
|
|
812e405a8d | ||
|
|
17fe3c7110 | ||
|
|
c58b1242c8 | ||
|
|
90f2ad2c3b | ||
|
|
9c973fae65 | ||
|
|
ce2c3acd44 | ||
|
|
6202465e3e | ||
|
|
bb2a71f959 | ||
|
|
7dec45ad05 | ||
|
|
9a89dc2d34 | ||
|
|
6ac89de76e | ||
|
|
54c5d01574 | ||
|
|
8412374533 | ||
|
|
35e79a8d46 | ||
|
|
b554637a26 | ||
|
|
3e269c1362 | ||
|
|
02bf511ba2 | ||
|
|
74ad7e5f39 | ||
|
|
25a97bb683 | ||
|
|
972e910722 | ||
|
|
a0f197a535 | ||
|
|
f665a00794 | ||
|
|
acb47daee9 | ||
|
|
fbf0b99ccd | ||
|
|
9b666634e6 | ||
|
|
40ab228ab6 | ||
|
|
19c7b76de1 | ||
|
|
70b90453a5 | ||
|
|
7205271b07 | ||
|
|
a6ffc30339 | ||
|
|
f3e3ac9f2e | ||
|
|
f133704176 | ||
|
|
382016dfe1 | ||
|
|
4fefec819c | ||
|
|
c1079d99ba | ||
|
|
6c1f68df48 | ||
|
|
9e68ee8f25 | ||
|
|
74f0bf4dc9 | ||
|
|
9ce320d1c2 | ||
|
|
8ccc09925f | ||
|
|
da10d503d5 | ||
|
|
96cdd810da | ||
|
|
d2f94a6bc3 | ||
|
|
ccd0f0ca9f | ||
|
|
a001b16563 | ||
|
|
cc08d5552b | ||
|
|
bf42f9ad83 | ||
|
|
e511357b6d | ||
|
|
79b96679e0 | ||
|
|
6d29079a09 | ||
|
|
787886669f | ||
|
|
8378f0834c | ||
|
|
600bd55461 | ||
|
|
dc92a55618 | ||
|
|
80ebd88bf7 | ||
|
|
8ae73f9332 | ||
|
|
7352385cc6 | ||
|
|
6ade8b3238 | ||
|
|
cbdc515ce7 | ||
|
|
fb3e8535e7 | ||
|
|
1117fb2eca | ||
|
|
430aea04a1 | ||
|
|
7972a60da7 | ||
|
|
61bbcd5a16 | ||
|
|
4713b74f1d | ||
|
|
57678cc06a | ||
|
|
32c323a553 | ||
|
|
e031cd0cf6 | ||
|
|
87f7238ab7 | ||
|
|
1bcded8b1b | ||
|
|
e0f288b8ed | ||
|
|
728a0ed165 | ||
|
|
4f915fe504 | ||
|
|
5d484c4fe2 | ||
|
|
25169a45f1 |
No files matched your search
@@ -0,0 +1,24 @@
|
||||
[
|
||||
{
|
||||
"package": "reasonix/internal/runtime/delegation",
|
||||
"tests": [
|
||||
"TestAncestorCopyAdmissionCoversUnpublishedRun",
|
||||
"TestAncestorCopyAdmissionCoversUnpublishedRun/continue_then_continue",
|
||||
"TestAncestorCopyAdmissionCoversUnpublishedRun/continue_then_legacy",
|
||||
"TestAncestorCopyAdmissionCoversUnpublishedRun/legacy_then_continue",
|
||||
"TestAncestorCopyAdmissionCoversUnpublishedRun/legacy_then_legacy",
|
||||
"TestAncestorCopyAdmissionAllowsDifferentParents",
|
||||
"TestAncestorCopyAdmissionReleasesAbandonedPreparation",
|
||||
"TestAncestorCopyAdmissionReleasesAfterInvalidCopy",
|
||||
"TestAncestorCopyAdmissionAllowsDifferentSources",
|
||||
"TestAncestorCopyAdmissionUsesNearestSourceIdentity",
|
||||
"TestAncestorCopyAdmissionReleasesAfterBusyCurrentReference",
|
||||
"TestTaskAncestorCopyAdmissionBeforeRunningMetadata",
|
||||
"TestTaskAncestorCopyAdmissionBeforeRunningMetadata/continue_from",
|
||||
"TestTaskAncestorCopyAdmissionBeforeRunningMetadata/fork_from",
|
||||
"TestTaskAncestorCopyAdmissionReleasesUnstartedBackground",
|
||||
"TestTaskAncestorCopyAdmissionReleasesUnstartedBackground/continue_from",
|
||||
"TestTaskAncestorCopyAdmissionReleasesUnstartedBackground/fork_from"
|
||||
]
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,26 @@
|
||||
[
|
||||
{
|
||||
"package": "reasonix/internal/runtime/agent",
|
||||
"tests": [
|
||||
"TestCarriedProofInvalidatesPermissionChange",
|
||||
"TestCarriedProofFileStateCompatibility",
|
||||
"TestCarriedProofFileStateCompatibility/legacy_file",
|
||||
"TestCarriedProofFileStateCompatibility/legacy_absent",
|
||||
"TestCarriedProofFileStateCompatibility/future",
|
||||
"TestCarriedProofFileStateCompatibility/current_file",
|
||||
"TestCarriedProofFileStateCompatibility/current_absent",
|
||||
"TestCarriedProofRejectsDirectoryState",
|
||||
"TestCarriedProofStandsOnlyForUnchangedContent",
|
||||
"TestCarriedProofSeesADeletion"
|
||||
]
|
||||
},
|
||||
{
|
||||
"package": "reasonix/internal/assembly/boot",
|
||||
"tests": [
|
||||
"TestEffectCarriedProofTracksFileState",
|
||||
"TestEffectCarriedProofTracksFileState/unchanged",
|
||||
"TestEffectCarriedProofTracksFileState/content",
|
||||
"TestEffectCarriedProofTracksFileState/mode"
|
||||
]
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,275 @@
|
||||
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 (for example studio-v2.51.0)"
|
||||
required: true
|
||||
type: string
|
||||
|
||||
permissions:
|
||||
contents: write # upload the APK to the release the tag belongs to
|
||||
|
||||
concurrency:
|
||||
group: android-${{ inputs.tag || 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 }}
|
||||
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 }}
|
||||
REF_NAME: ${{ github.ref_name }}
|
||||
run: |
|
||||
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"; } >> "$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
|
||||
|
||||
- uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7
|
||||
with:
|
||||
node-version: "24"
|
||||
cache: pnpm
|
||||
cache-dependency-path: desktop/frontend-next/pnpm-lock.yaml
|
||||
|
||||
- uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v4
|
||||
with:
|
||||
version: 10
|
||||
|
||||
- uses: actions/setup-java@cf277c60eb25467037889841efdb72551f06f6c3 # v4
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: "21"
|
||||
cache: gradle
|
||||
|
||||
# The SDK is the one thing nothing sets for you, per android/README.md. The
|
||||
# runner image carries one; this asserts the two pieces the build names
|
||||
# rather than re-installing them, so a missing package fails here by name
|
||||
# instead of surfacing later as an unresolved compileSdk.
|
||||
- name: Provide the Android SDK
|
||||
run: |
|
||||
if [ -z "${ANDROID_HOME:-}" ]; then
|
||||
echo "::error::ANDROID_HOME is unset; the runner image is expected to provide the Android SDK"
|
||||
exit 1
|
||||
fi
|
||||
for piece in "platforms/android-35" "build-tools/35.0.0"; do
|
||||
if [ ! -d "$ANDROID_HOME/$piece" ]; then
|
||||
echo "::error::the Android SDK at $ANDROID_HOME has no $piece; android/README.md names it as a requirement"
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
echo "ANDROID_SDK_ROOT=$ANDROID_HOME" >> "$GITHUB_ENV"
|
||||
|
||||
# A key that exists signs; a key that does not leaves the artifact
|
||||
# unsigned, which is what a release with no key has always produced. The
|
||||
# decision is the repository's, not this workflow's: it reads the four
|
||||
# secrets android/README.md already names.
|
||||
- name: Restore the signing key
|
||||
id: key
|
||||
env:
|
||||
KEYSTORE_BASE64: ${{ secrets.REASONIX_KEYSTORE_BASE64 }}
|
||||
STORE_PASSWORD: ${{ secrets.REASONIX_KEYSTORE_PASSWORD }}
|
||||
KEY_PASSWORD: ${{ secrets.REASONIX_KEY_PASSWORD }}
|
||||
KEY_ALIAS: ${{ secrets.REASONIX_KEY_ALIAS }}
|
||||
run: |
|
||||
if [ -z "$KEYSTORE_BASE64" ] || [ -z "$STORE_PASSWORD" ] || [ -z "$KEY_ALIAS" ]; then
|
||||
echo "signed=false" >> "$GITHUB_OUTPUT"
|
||||
echo "no signing key configured: the APK will be unsigned"
|
||||
exit 0
|
||||
fi
|
||||
mkdir -p android/keystore
|
||||
printf '%s' "$KEYSTORE_BASE64" | base64 --decode > android/keystore/release.jks
|
||||
{
|
||||
echo "storeFile=keystore/release.jks"
|
||||
echo "storePassword=${STORE_PASSWORD}"
|
||||
echo "keyAlias=${KEY_ALIAS}"
|
||||
# PKCS#12 keystores use one password for both; only write the second
|
||||
# when it is genuinely a different fact.
|
||||
[ -n "$KEY_PASSWORD" ] && echo "keyPassword=${KEY_PASSWORD}"
|
||||
} > android/keystore/keystore.properties
|
||||
chmod 600 android/keystore/*
|
||||
echo "signed=true" >> "$GITHUB_OUTPUT"
|
||||
echo "signing key restored"
|
||||
|
||||
- name: Build the kernel and the page
|
||||
working-directory: android
|
||||
run: |
|
||||
./scripts/build-kernel.sh
|
||||
./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" ./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'
|
||||
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
|
||||
+137
-1
@@ -170,6 +170,23 @@ jobs:
|
||||
shell: pwsh
|
||||
run: Get-CimInstance Win32_Processor | Select-Object -ExpandProperty Name
|
||||
|
||||
- name: test (Windows concurrency contracts)
|
||||
if: env.RUN_STEPS == 'true' && runner.os == 'Windows' && github.event_name == 'pull_request'
|
||||
timeout-minutes: 5
|
||||
shell: bash
|
||||
run: |
|
||||
node --test scripts/windows-concurrency-tests.test.mjs
|
||||
node scripts/windows-concurrency-tests.mjs "$RUNNER_TEMP/concurrency-windows.jsonl"
|
||||
|
||||
- name: upload Windows concurrency inventory
|
||||
if: always() && env.RUN_STEPS == 'true' && runner.os == 'Windows' && github.event_name == 'pull_request'
|
||||
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7
|
||||
with:
|
||||
name: concurrency-windows-${{ github.run_attempt }}
|
||||
path: ${{ runner.temp }}/concurrency-windows.jsonl
|
||||
if-no-files-found: error
|
||||
retention-days: 7
|
||||
|
||||
- name: test
|
||||
if: env.RUN_STEPS == 'true' && runner.os != 'Windows'
|
||||
env:
|
||||
@@ -179,6 +196,35 @@ jobs:
|
||||
REASONIX_RELEASE_CACHE_GUARD: "1"
|
||||
run: go test ./...
|
||||
|
||||
- name: test (Windows subagent status admission)
|
||||
if: env.RUN_STEPS == 'true' && runner.os == 'Windows' && github.event_name == 'pull_request'
|
||||
timeout-minutes: 5
|
||||
shell: bash
|
||||
run: |
|
||||
set -o pipefail
|
||||
go test -count=1 -timeout=3m -json -run '^TestSubagent(PersistedStatusAdmission|UnknownStatusStillReadableForDiagnosis)$' ./internal/runtime/delegation | tee "$RUNNER_TEMP/subagent-status-admission.jsonl"
|
||||
jq -se '
|
||||
[.[] | select(.Test == null and .Action == "pass") | .Package] as $packages |
|
||||
[.[] | select(.Test != null and .Action == "pass") | .Test] as $tests |
|
||||
["future-paused", "missing", "empty", "COMPLETED", "completed", "cancelled", "failed", "interrupted", "running"] as $states |
|
||||
["continue", "copy", "ancestor", "legacy_ancestor", "current_copy"] as $routes |
|
||||
(["TestSubagentPersistedStatusAdmission", "TestSubagentUnknownStatusStillReadableForDiagnosis"] +
|
||||
[$states[] | "TestSubagentPersistedStatusAdmission/" + .] +
|
||||
[$states[] as $state | $routes[] | "TestSubagentPersistedStatusAdmission/" + $state + "/" + .]) as $required |
|
||||
all(.[]; .Action != "skip" and .Action != "fail") and
|
||||
(["reasonix/internal/runtime/delegation"] - $packages | length) == 0 and
|
||||
($required - $tests | length) == 0
|
||||
' "$RUNNER_TEMP/subagent-status-admission.jsonl"
|
||||
|
||||
- name: upload Windows subagent status admission inventory
|
||||
if: always() && env.RUN_STEPS == 'true' && runner.os == 'Windows' && github.event_name == 'pull_request'
|
||||
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7
|
||||
with:
|
||||
name: subagent-status-admission-windows-${{ github.run_attempt }}
|
||||
path: ${{ runner.temp }}/subagent-status-admission.jsonl
|
||||
if-no-files-found: ignore
|
||||
retention-days: 7
|
||||
|
||||
- name: test (Windows composition links)
|
||||
if: env.RUN_STEPS == 'true' && runner.os == 'Windows' && github.event_name == 'pull_request'
|
||||
timeout-minutes: 5
|
||||
@@ -272,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'
|
||||
@@ -320,6 +392,38 @@ jobs:
|
||||
if-no-files-found: ignore
|
||||
retention-days: 7
|
||||
|
||||
- name: test (Windows delegated Goal usage scope)
|
||||
if: env.RUN_STEPS == 'true' && runner.os == 'Windows' && github.event_name == 'pull_request'
|
||||
shell: bash
|
||||
run: |
|
||||
set -o pipefail
|
||||
go test -count=1 -timeout=3m -json -run '^Test(UsageScope.*|DelegatedUsageKeepsGoalOwner|EffectDelegatedUsageKeepsGoalOwner)$' ./internal/contract/event ./internal/session/control ./internal/assembly/boot | tee "$RUNNER_TEMP/goal-usage-scope.jsonl"
|
||||
jq -se '
|
||||
["TestUsageScopePreservesCapturedOwnerAndWire", "TestUsageScopeKeepsEarlierEventOwner",
|
||||
"TestUsageScopeWithoutCapability", "TestDelegatedUsageKeepsGoalOwner",
|
||||
"TestDelegatedUsageKeepsGoalOwner/chat_to_goal", "TestDelegatedUsageKeepsGoalOwner/replaced_goal",
|
||||
"TestDelegatedUsageKeepsGoalOwner/cleared_goal", "TestDelegatedUsageKeepsGoalOwner/resumed_goal",
|
||||
"TestDelegatedUsageKeepsGoalOwner/same_goal_next_turn", "TestDelegatedUsageKeepsGoalOwner/between_turns",
|
||||
"TestDelegatedUsageKeepsGoalOwner/nested_after_replacement", "TestEffectDelegatedUsageKeepsGoalOwner",
|
||||
"TestEffectDelegatedUsageKeepsGoalOwner/chat_to_goal", "TestEffectDelegatedUsageKeepsGoalOwner/replaced_goal",
|
||||
"TestEffectDelegatedUsageKeepsGoalOwner/nested_chat_to_goal", "TestEffectDelegatedUsageKeepsGoalOwner/same_goal",
|
||||
"TestEffectDelegatedUsageKeepsGoalOwner/completed_before_goal"] as $required |
|
||||
[.[] | select(.Test != null and .Action == "pass") | .Test] as $passed |
|
||||
[.[] | select(.Test == null and .Action == "pass") | .Package] as $packages |
|
||||
all(.[]; .Action != "fail" and .Action != "skip") and
|
||||
(($required - $passed) | length == 0) and
|
||||
((["reasonix/internal/contract/event", "reasonix/internal/session/control", "reasonix/internal/assembly/boot"] - $packages) | length == 0)
|
||||
' "$RUNNER_TEMP/goal-usage-scope.jsonl"
|
||||
|
||||
- name: upload Windows delegated Goal usage scope inventory
|
||||
if: always() && env.RUN_STEPS == 'true' && runner.os == 'Windows' && github.event_name == 'pull_request'
|
||||
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7
|
||||
with:
|
||||
name: goal-usage-scope-windows-${{ github.run_attempt }}
|
||||
path: ${{ runner.temp }}/goal-usage-scope.jsonl
|
||||
if-no-files-found: error
|
||||
retention-days: 7
|
||||
|
||||
- name: test (Windows planner local budget)
|
||||
if: env.RUN_STEPS == 'true' && runner.os == 'Windows' && github.event_name == 'pull_request'
|
||||
timeout-minutes: 5
|
||||
@@ -495,7 +599,7 @@ jobs:
|
||||
shell: bash
|
||||
run: |
|
||||
set -o pipefail
|
||||
go test -count=1 -timeout=3m -json -run '^Test(RootToken|EffectRootToken)' ./internal/runtime/agent ./internal/assembly/boot | tee "$RUNNER_TEMP/root-token-budget.jsonl"
|
||||
go test -count=1 -timeout=3m -json -run '^Test(RootToken|EffectRootToken|BudgetCountsKnownReportedTokens|ReportedToken|EffectReportedTotalTokenBudget)' ./internal/runtime/agent ./internal/assembly/boot | tee "$RUNNER_TEMP/root-token-budget.jsonl"
|
||||
jq -se '
|
||||
[.[] | select(.Test == null and .Action == "pass") | .Package] as $packages |
|
||||
[.[] | select(.Test != null and .Action == "pass") | .Test] as $tests |
|
||||
@@ -512,6 +616,38 @@ jobs:
|
||||
"TestEffectRootTokenBudgetReachesDelegatedJobs/foreground_capability", "TestEffectRootTokenBudgetReachesDelegatedJobs/background",
|
||||
"TestEffectRootTokenBudgetReachesDelegatedJobs/background_capability", "TestEffectRootTokenBudgetReachesDelegatedJobs/background_fleet"] - $tests | length) == 0
|
||||
' "$RUNNER_TEMP/root-token-budget.jsonl"
|
||||
jq -se '
|
||||
. as $events |
|
||||
{"reasonix/internal/runtime/agent": [
|
||||
"TestBudgetCountsKnownReportedTokens",
|
||||
"TestBudgetCountsKnownReportedTokens/split",
|
||||
"TestBudgetCountsKnownReportedTokens/total_only",
|
||||
"TestBudgetCountsKnownReportedTokens/partial_split",
|
||||
"TestBudgetCountsKnownReportedTokens/lower_total",
|
||||
"TestBudgetCountsKnownReportedTokens/missing_total",
|
||||
"TestBudgetCountsKnownReportedTokens/negative_split",
|
||||
"TestBudgetCountsKnownReportedTokens/zero",
|
||||
"TestBudgetCountsKnownReportedTokens/negative",
|
||||
"TestReportedTokensEnforceLocalCapOnInheritedRoot",
|
||||
"TestReportedTokensFollowOriginalAuxiliaryRoot",
|
||||
"TestReportedTokenCountersSaturate"],
|
||||
"reasonix/internal/assembly/boot": [
|
||||
"TestEffectReportedTotalTokenBudget",
|
||||
"TestEffectReportedTotalTokenBudget/split",
|
||||
"TestEffectReportedTotalTokenBudget/total_only",
|
||||
"TestEffectReportedTotalTokenBudget/partial_split",
|
||||
"TestEffectReportedTotalTokenBudget/lower_total",
|
||||
"TestEffectReportedTotalTokenBudget/missing_total",
|
||||
"TestEffectReportedTotalTokenBudget/disabled"]} |
|
||||
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/root-token-budget.jsonl"
|
||||
|
||||
- name: upload Windows root token budget inventory
|
||||
if: always() && env.RUN_STEPS == 'true' && runner.os == 'Windows' && github.event_name == 'pull_request'
|
||||
|
||||
@@ -117,6 +117,22 @@ go test ./internal/runtime/agent/ -v # verbose, one package
|
||||
go test ./internal/tools/builtin/ -run TestGrep # one test
|
||||
```
|
||||
|
||||
`testenv.IsolateUserState` installs a disposable user home and config-edit lock
|
||||
scope. Default-path tests can clear `REASONIX_HOME` without losing the test
|
||||
registry, while explicitly isolated Reasonix homes retain their own locks.
|
||||
|
||||
`REASONIX_TEST_HOME` carries the scope to helper subprocesses. A helper that
|
||||
skips the isolation guard must install the inherited home with
|
||||
`testscope.InstallHome` before running tests.
|
||||
|
||||
The environment variable alone cannot activate a test scope. Production
|
||||
executables still use the OS account's home for shared config locks.
|
||||
|
||||
The guard removes its scratch home on normal completion. Scratch homes are
|
||||
created under the original home, so an interrupted run can still leave
|
||||
`.reasonix-test-home-*` directories; this is separate from persistent
|
||||
config-edit locks in the real `.reasonix/locks` directory.
|
||||
|
||||
### Code style
|
||||
|
||||
- `gofmt` is enforced by CI — format before committing
|
||||
|
||||
@@ -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}"
|
||||
|
||||
|
||||
+59
-3
@@ -420,7 +420,7 @@ own outcome).
|
||||
|
||||
| Flag | Default | Purpose |
|
||||
| --- | --- | --- |
|
||||
| `-mode` | `suite` | `suite` \| `diff` \| `swebench` \| `compare` \| `traj` (`diff` generates tests for the PR diff; `swebench` runs the official per-instance evaluation; `compare` renders KPI/Pareto readouts from 2+ `-json` reports; `traj` re-digests recorded trajectory files without spending tokens). |
|
||||
| `-mode` | `suite` | `suite` \| `diff` \| `swebench` \| `compare` \| `traj` (`diff` generates tests for the PR diff; `swebench` runs the official per-instance evaluation; `compare` renders KPI/Pareto readouts from 2+ `-json` reports; `traj` re-digests recorded trajectory files without spending tokens; `audit` checks a batch's published figures against its retained rows). |
|
||||
| `-suite` | `benchmarks/e2e` | Suite root (must contain `tasks/<id>/`). |
|
||||
| `-task` | *(all)* | Suite mode: run only these comma-separated task IDs (e.g. `-task fix-add-bug`); unknown IDs fail with the available list. |
|
||||
| `-attempts` | `1` | Suite and diff modes: retry a task until an attempt passes, up to N; enables the `Pass@≤N` KPI, and TTCS charges a retried solve with its failed attempts' wall. |
|
||||
@@ -433,6 +433,7 @@ own outcome).
|
||||
| `-ablate` | *(none)* | Ablation arm: comma-separated subsystems to switch off — `evidence`, `planner`, `subagent`, `retrieval`, `compaction`, `upstream` and `recall-search`; `none` \| `all`. |
|
||||
| `-out` | *(stdout)* | Write the markdown report here. |
|
||||
| `-json` | *(none)* | Write the JSON report here (optional). |
|
||||
| `-batch` | *(none)* | Suite mode: write `results.jsonl` (one row per attempt) and `batch.json` (manifest v1) here, so the report's figures can be audited later. Audit mode: read the batch from here. See [Auditing a batch](#auditing-a-batch). |
|
||||
| `-trajectories` | *(none)* | Suite mode: write one `<task-id>.trajectory.jsonl` per task into this directory (the agent's full event stream with timestamps — see `reasonix run --trajectory`). The report gains a time-attribution line (tools vs. model) and each JSON result a `trajectory` digest. |
|
||||
| `-force-planner` | `false` | Suite mode: prefix each prompt with a plan-first directive so the two-model turn engages regardless of the planner gate. Use for the "with planner" arm of an A/B; results carry `plan_forced` so arms are only comparable with equal forcing. |
|
||||
| `-anchor` | `blind` | Suite mode: which hypothesis the agent holds before it looks at anything — `blind` (none, the control) \| `correct` \| `wrong`. The seeded arms prefix each prompt with the task's authored seed and **skip** tasks that have none, so an unseeded control run never lands in a seeded denominator. Results carry `anchor`. See [Anchor resistance](#anchor-resistance). |
|
||||
@@ -455,10 +456,65 @@ Diff-mode flags:
|
||||
| `-timeout` | `1200` | Agent timeout in seconds (diff mode). |
|
||||
| `-attempts` | `1` | Diff mode: retry up to N times until a run passes (stochastic agent). |
|
||||
|
||||
## Auditing a batch
|
||||
|
||||
`-batch DIR` writes two files from a suite run: `results.jsonl` (one row per
|
||||
attempt — the raw record every figure is computed from) and `batch.json`
|
||||
(manifest v1). `-mode audit -batch DIR` re-derives the report's headline figures
|
||||
from `results.jsonl`, prints the manifest's claim beside the recomputed value,
|
||||
and exits non-zero naming any figure that moved. It also refuses a batch whose
|
||||
manifest is missing, records no commit, or whose rows are truncated.
|
||||
|
||||
This is an **aggregation audit, not a replay**: it proves the published figures
|
||||
follow from the retained rows. It does not re-execute the model, tools or
|
||||
environment, and cannot show the experiment would reproduce. A real replay would
|
||||
need the commit, a corpus fingerprint, the model and provider configuration, the
|
||||
seed, the flags, the raw results and the runtime version; it is not implemented.
|
||||
|
||||
`schema_version` is top level. The rest is three groups:
|
||||
|
||||
| Group | Fields | Answers |
|
||||
| --- | --- | --- |
|
||||
| `over` | `command.mode` and `command.axes` (every flag that changes what a figure means; paths omitted); `tasks.count` and `tasks.digest` (sha256 over sorted, NUL-separated task ids — the corpus identity, never the task text); `filters`; `denominator` (`unit`, `definition`, `value`) | what the figures are over |
|
||||
| `producer` | `commit` (or an explicit `unknown`, never a guess), `commit_source`, `dirty`, `model`, `provider`, `seed`, `started_at`, `finished_at`, `harness_version`, `go_version` | which run produced them |
|
||||
| `trust` | `results_sha256`, `claimed` (the headline figures), `ledger`, `retention` | why they can still be checked |
|
||||
|
||||
The ledger accounts for every planned run, and the audit checks that the counts
|
||||
form a funnel (`scored ≤ completed ≤ attempted`, `failed ≤ scored`,
|
||||
`scored = pass1 + failed`, `rows = attempted + skipped`). A row count is never a
|
||||
denominator: `denominator.value` is a count of scored tasks, and `rows` is
|
||||
published only to show what was retained.
|
||||
|
||||
| Count | Meaning |
|
||||
| --- | --- |
|
||||
| `planned` | graded runs the batch set out to attempt: filtered tasks × trials |
|
||||
| `attempted` | retained rows for runs that started (skipped rows are not attempts) |
|
||||
| `completed` | attempts that ended and left a row; this harness writes a row only after an attempt ends, so it equals `attempted` and the equality is checked, not assumed |
|
||||
| `scored` | completed first attempts of solvable, non-skipped tasks — the pass@1 denominator |
|
||||
| `failed` | scored attempts the grader did not pass |
|
||||
| `unmeasured` | attempts whose cost and tokens are unknown because no metrics landed; kept out of spend totals, never counted as zero |
|
||||
| `excluded` | rows kept out of the accuracy denominator: skipped, `no_solution`, and retry entries |
|
||||
|
||||
Spend and cache fields are omitted from `claimed` when nothing was measured, so
|
||||
an absent figure reads as unknown rather than as a measured zero. `seed` is
|
||||
omitted for the same reason: the harness does not set a sampling seed.
|
||||
`results_sha256` covers the values and the presence of every row; line endings,
|
||||
indentation and key order are normalised away because none of them is evidence,
|
||||
while number spelling is kept (a rewritten `1` is not `1.0`).
|
||||
A successful audit records the verifier version (`audit.verifier_version`) and
|
||||
the digest it checked in `batch.json`, so a re-verification can tell which code
|
||||
checked the batch.
|
||||
|
||||
`results.jsonl` is retained unredacted beside the manifest — task prompts and
|
||||
agent notes included — and is not public. The manifest itself carries a task-id
|
||||
digest, no prompt text, no absolute paths, no endpoints or keys and no model
|
||||
output. A claim whose rows are gone cannot be audited: there is nothing to
|
||||
recompute from, and this tool will not certify it.
|
||||
|
||||
## Dataset retention
|
||||
|
||||
Keep every `-json` report and `-trajectories` directory from real runs: they
|
||||
are the accumulating corpus — per-task contracts-to-be, full event
|
||||
Keep every `-json` report, `-trajectories` directory and `-batch` directory from
|
||||
real runs: they are the accumulating corpus — per-task contracts-to-be, full event
|
||||
trajectories, checkpoint oracle verdicts, stop curves and phase traces — that
|
||||
any future offline learning (routing, stop policies, budgets) would train
|
||||
and evaluate on. The control plane stays deterministic and interpretable
|
||||
|
||||
@@ -0,0 +1,428 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// runAuditMode prints both sides of the audit before returning the failure, so
|
||||
// a moved figure is visible even when the exit code is non-zero.
|
||||
func runAuditMode(dir, outMD string) error {
|
||||
report, err := auditBatch(dir)
|
||||
if report != "" {
|
||||
emit(report, outMD, "")
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// auditBatch re-derives the published figures from results.jsonl and checks
|
||||
// them against batch.json. It reports both sides and never re-runs anything.
|
||||
func auditBatch(dir string) (string, error) {
|
||||
if strings.TrimSpace(dir) == "" {
|
||||
return "", errors.New("audit needs -batch <directory>: the directory a suite run wrote")
|
||||
}
|
||||
manifestPath := filepath.Join(dir, batchManifestName)
|
||||
data, err := os.ReadFile(manifestPath)
|
||||
if err != nil {
|
||||
if errors.Is(err, fs.ErrNotExist) {
|
||||
return "", fmt.Errorf("no %s under %s: a batch that kept no manifest cannot be audited", batchManifestName, dir)
|
||||
}
|
||||
return "", fmt.Errorf("read %s: %w", batchManifestName, err)
|
||||
}
|
||||
var m batchManifest
|
||||
if err := json.Unmarshal(data, &m); err != nil {
|
||||
return "", fmt.Errorf("%s is not valid JSON: %w", batchManifestName, err)
|
||||
}
|
||||
if m.SchemaVersion != batchSchemaVersion {
|
||||
return "", fmt.Errorf("%s declares schema version %d; this audit implements v%d", batchManifestName, m.SchemaVersion, batchSchemaVersion)
|
||||
}
|
||||
if commit := strings.TrimSpace(m.Producer.Commit); commit == "" || commit == "unknown" {
|
||||
return "", fmt.Errorf("%s records no commit (%q): the figures cannot be tied to a version, so this audit stops", batchManifestName, m.Producer.Commit)
|
||||
}
|
||||
|
||||
rows, canonical, err := readBatch(filepath.Join(dir, batchResultsName))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
hash := hashOf(canonical)
|
||||
stats := gatherSuiteStats(rows)
|
||||
fromRows := ledgerFromRows(rows)
|
||||
figures, failures := compareClaims(m.Trust.Claimed, stats)
|
||||
ledger, ledgerFailures := compareLedger(m.Trust.Ledger, fromRows)
|
||||
failures = append(failures, ledgerFailures...)
|
||||
failures = append(failures, ledgerInvariants(m.Trust.Ledger)...)
|
||||
failures = append(failures, denominatorChecks(m, fromRows)...)
|
||||
if hash != m.Trust.ResultsSHA256 {
|
||||
failures = append([]string{fmt.Sprintf("results_sha256: manifest %s, rows %s", textOrUnknown(m.Trust.ResultsSHA256), hash)}, failures...)
|
||||
}
|
||||
report := renderAudit(dir, m, rows, hash, figures, ledger, failures)
|
||||
if len(failures) > 0 {
|
||||
return report, fmt.Errorf("%d mismatch(es): the published figures do not follow from the retained rows", len(failures))
|
||||
}
|
||||
if err := recordAuditReceipt(manifestPath, m, hash); err != nil {
|
||||
return report, err
|
||||
}
|
||||
return report, nil
|
||||
}
|
||||
|
||||
// readBatch parses results.jsonl once: the typed rows the figures come from,
|
||||
// and the canonical bytes results_sha256 covers.
|
||||
func readBatch(path string) ([]result, []byte, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
if errors.Is(err, fs.ErrNotExist) {
|
||||
return nil, nil, fmt.Errorf("no %s: the manifest describes rows that are not beside it", batchResultsName)
|
||||
}
|
||||
return nil, nil, fmt.Errorf("read %s: %w", batchResultsName, err)
|
||||
}
|
||||
if len(data) > 0 && data[len(data)-1] != '\n' {
|
||||
return nil, nil, fmt.Errorf("%s does not end in a newline: its last row is truncated", batchResultsName)
|
||||
}
|
||||
var rows []result
|
||||
var canonical bytes.Buffer
|
||||
line := 0
|
||||
for raw := range bytes.SplitSeq(data, []byte("\n")) {
|
||||
if len(bytes.TrimSpace(raw)) == 0 {
|
||||
continue
|
||||
}
|
||||
line++
|
||||
var r result
|
||||
if err := json.Unmarshal(raw, &r); err != nil {
|
||||
return nil, nil, fmt.Errorf("%s line %d is not a complete row (truncated or corrupt): %w", batchResultsName, line, err)
|
||||
}
|
||||
rows = append(rows, r)
|
||||
canon, err := canonicalJSON(raw)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("%s line %d: %w", batchResultsName, line, err)
|
||||
}
|
||||
canonical.Write(canon)
|
||||
canonical.WriteByte('\n')
|
||||
}
|
||||
return rows, canonical.Bytes(), nil
|
||||
}
|
||||
|
||||
// auditFigure is one number on both sides: what the manifest claimed and what
|
||||
// the retained rows give. Unknown is printed as unknown, never as zero.
|
||||
type auditFigure struct {
|
||||
name string
|
||||
claimed string
|
||||
actual string
|
||||
note string
|
||||
moved bool
|
||||
}
|
||||
|
||||
func compareClaims(c batchClaims, s suiteStats) ([]auditFigure, []string) {
|
||||
rows := []struct {
|
||||
name string
|
||||
claimed string
|
||||
actual string
|
||||
required bool
|
||||
}{
|
||||
{"solved", intText(c.Solved), strconv.Itoa(s.passed), true},
|
||||
{"pass1", intText(c.Pass1), strconv.Itoa(s.pass1), true},
|
||||
{"prompt_tokens", intText(c.PromptTokens), strconv.Itoa(s.pTok), false},
|
||||
{"completion_tokens", intText(c.CompletionTokens), strconv.Itoa(s.cTok), false},
|
||||
{"cache_hit_tokens", intText(c.CacheHitTokens), strconv.Itoa(s.hit), false},
|
||||
{"cache_miss_tokens", intText(c.CacheMissTokens), strconv.Itoa(s.miss), false},
|
||||
{"cost", floatText(c.Cost), floatText(new(s.cost)), false},
|
||||
{"currency", c.Currency, s.currency, false},
|
||||
{"accounted", intText(c.Accounted), strconv.Itoa(s.accounted), true},
|
||||
{"unaccounted", intText(c.Unaccounted), strconv.Itoa(s.unaccounted), true},
|
||||
{"partial", intText(c.Partial), strconv.Itoa(s.partial), true},
|
||||
{"tcts_median_ms", int64Text(c.TCTSMedianMs), strconv.FormatInt(median(s.ttcs), 10), false},
|
||||
}
|
||||
var figures []auditFigure
|
||||
var failures []string
|
||||
for _, row := range rows {
|
||||
f := auditFigure{name: row.name, claimed: row.claimed, actual: row.actual}
|
||||
switch {
|
||||
case row.claimed == "":
|
||||
f.claimed = "unknown"
|
||||
f.note = "not claimed"
|
||||
if row.required {
|
||||
f.note = "required, absent"
|
||||
failures = append(failures, row.name+": the manifest claims no "+row.name+"; an absent figure is required here")
|
||||
}
|
||||
case row.claimed != row.actual:
|
||||
f.moved = true
|
||||
failures = append(failures, fmt.Sprintf("%s: manifest %s, rows %s", row.name, row.claimed, row.actual))
|
||||
}
|
||||
figures = append(figures, f)
|
||||
}
|
||||
return figures, failures
|
||||
}
|
||||
|
||||
// compareLedger checks the counts the rows can answer. Planned cannot be
|
||||
// recomputed from rows — it is what the batch set out to do — so it is only
|
||||
// held to the consistency checks in ledgerInvariants.
|
||||
func compareLedger(declared, actual batchLedger) ([]auditFigure, []string) {
|
||||
type entry struct {
|
||||
name string
|
||||
claimed *int
|
||||
replayed *int
|
||||
}
|
||||
entries := []entry{
|
||||
{"planned", declared.Planned, nil},
|
||||
{"attempted", declared.Attempted, actual.Attempted},
|
||||
{"completed", declared.Completed, actual.Completed},
|
||||
{"scored", declared.Scored, actual.Scored},
|
||||
{"failed", declared.Failed, actual.Failed},
|
||||
{"unmeasured", declared.Unmeasured, actual.Unmeasured},
|
||||
{"excluded", declared.Excluded, actual.Excluded},
|
||||
{"rows", declared.Rows, actual.Rows},
|
||||
{"skipped", declared.Skipped, actual.Skipped},
|
||||
}
|
||||
required := map[string]bool{"planned": true, "attempted": true, "completed": true, "scored": true, "failed": true, "unmeasured": true, "excluded": true}
|
||||
var figures []auditFigure
|
||||
var failures []string
|
||||
for _, e := range entries {
|
||||
f := auditFigure{name: e.name}
|
||||
switch {
|
||||
case e.claimed != nil:
|
||||
f.claimed = strconv.Itoa(*e.claimed)
|
||||
default:
|
||||
f.claimed = "unknown"
|
||||
f.note = "not claimed"
|
||||
if required[e.name] {
|
||||
f.note = "required, absent"
|
||||
failures = append(failures, "ledger."+e.name+": absent; schema v1 requires this count, and absent is unknown, never zero")
|
||||
}
|
||||
}
|
||||
if e.replayed == nil {
|
||||
f.actual = "n/a"
|
||||
f.note = joinNote(f.note, "not recomputable from rows")
|
||||
} else {
|
||||
f.actual = strconv.Itoa(*e.replayed)
|
||||
}
|
||||
if e.claimed != nil && e.replayed != nil && *e.claimed != *e.replayed {
|
||||
f.moved = true
|
||||
failures = append(failures, fmt.Sprintf("ledger.%s: manifest %d, rows %d", e.name, *e.claimed, *e.replayed))
|
||||
}
|
||||
figures = append(figures, f)
|
||||
}
|
||||
return figures, failures
|
||||
}
|
||||
|
||||
// ledgerInvariants is the denominator guard: the counts must describe a
|
||||
// funnel, so a manifest cannot publish a numerator larger than its base.
|
||||
func ledgerInvariants(l batchLedger) []string {
|
||||
var out []string
|
||||
check := func(lower, upper *int, lowerName, upperName string) {
|
||||
if lower == nil || upper == nil || *lower <= *upper {
|
||||
return
|
||||
}
|
||||
out = append(out, fmt.Sprintf("ledger: %s %d exceeds %s %d", lowerName, *lower, upperName, *upper))
|
||||
}
|
||||
check(l.Scored, l.Planned, "scored", "planned")
|
||||
check(l.Completed, l.Attempted, "completed", "attempted")
|
||||
check(l.Scored, l.Completed, "scored", "completed")
|
||||
check(l.Failed, l.Scored, "failed", "scored")
|
||||
check(l.Unmeasured, l.Completed, "unmeasured", "completed")
|
||||
check(l.Excluded, l.Rows, "excluded", "rows")
|
||||
check(l.Unmeasured, l.Attempted, "unmeasured", "attempted")
|
||||
if l.Rows != nil && l.Attempted != nil && l.Skipped != nil && *l.Rows != *l.Attempted+*l.Skipped {
|
||||
out = append(out, fmt.Sprintf("ledger: rows %d is not attempted %d + skipped %d", *l.Rows, *l.Attempted, *l.Skipped))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// denominatorChecks ties the published denominator to the ledger and to the
|
||||
// outcome split, so passes and failures have to add up to it.
|
||||
func denominatorChecks(m batchManifest, fromRows batchLedger) []string {
|
||||
var out []string
|
||||
d := m.Over.Denominator
|
||||
if d.Unit != denominatorUnit || d.Definition != denominatorDefinition {
|
||||
out = append(out, "denominator: the manifest defines it as "+strconv.Quote(d.Unit+" / "+d.Definition)+", which this audit does not implement")
|
||||
}
|
||||
if fromRows.Scored != nil && d.Value != *fromRows.Scored {
|
||||
out = append(out, fmt.Sprintf("denominator: manifest %d, rows %d", d.Value, *fromRows.Scored))
|
||||
}
|
||||
if m.Trust.Ledger.Scored != nil && d.Value != *m.Trust.Ledger.Scored {
|
||||
out = append(out, fmt.Sprintf("denominator: manifest %d, ledger.scored %d", d.Value, *m.Trust.Ledger.Scored))
|
||||
}
|
||||
if m.Trust.Claimed.Pass1 != nil && m.Trust.Ledger.Failed != nil && m.Trust.Ledger.Scored != nil {
|
||||
if sum := *m.Trust.Claimed.Pass1 + *m.Trust.Ledger.Failed; sum != *m.Trust.Ledger.Scored {
|
||||
out = append(out, fmt.Sprintf("ledger: scored %d is not pass1 %d + failed %d", *m.Trust.Ledger.Scored, *m.Trust.Claimed.Pass1, *m.Trust.Ledger.Failed))
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// recordAuditReceipt keeps the verifier identity in the manifest, so a later
|
||||
// re-verification can tell which code checked it.
|
||||
func recordAuditReceipt(manifestPath string, m batchManifest, hash string) error {
|
||||
m.Audit = &batchAuditReceipt{
|
||||
VerifierVersion: auditToolVersion,
|
||||
VerifiedAt: time.Now().UTC().Format(time.RFC3339),
|
||||
ResultsSHA256: hash,
|
||||
}
|
||||
b, err := json.MarshalIndent(m, "", " ")
|
||||
if err != nil {
|
||||
return fmt.Errorf("encode audit receipt: %w", err)
|
||||
}
|
||||
if err := os.WriteFile(manifestPath, append(b, '\n'), 0o644); err != nil {
|
||||
return fmt.Errorf("record audit receipt: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func renderAudit(dir string, m batchManifest, rows []result, hash string, figures, ledger []auditFigure, failures []string) string {
|
||||
var b strings.Builder
|
||||
fmt.Fprintf(&b, "## e2ebench batch audit — `%s`\n\n", filepath.Base(strings.TrimSpace(dir)))
|
||||
b.WriteString("**What the figures are over**\n\n")
|
||||
fmt.Fprintf(&b, "- schema: manifest v%d, audited by `%s/%d`\n", m.SchemaVersion, auditToolName, auditToolVersion)
|
||||
fmt.Fprintf(&b, "- command: `%s` · axes: %s\n", m.Over.Command.Mode, axisText(m.Over.Command.Axes))
|
||||
fmt.Fprintf(&b, "- task ids: `%s` (%d ids; task text is represented by this digest, not stored)\n", textOrUnknown(m.Over.Tasks.Digest), m.Over.Tasks.Count)
|
||||
fmt.Fprintf(&b, "- filters: %s\n", filterText(m.Over.Filters))
|
||||
fmt.Fprintf(&b, "- denominator: **%d %ss** — %s\n", m.Over.Denominator.Value, m.Over.Denominator.Unit, m.Over.Denominator.Definition)
|
||||
b.WriteString("\n**Which run produced them**\n\n")
|
||||
fmt.Fprintf(&b, "- commit: `%s` (%s, %s)\n", m.Producer.Commit, sourceText(m.Producer.CommitSource), dirtyText(m.Producer.Dirty))
|
||||
fmt.Fprintf(&b, "- model: %s · provider: %s\n", nameText(m.Producer.Model), nameText(m.Producer.Provider))
|
||||
fmt.Fprintf(&b, "- seed: %s\n", seedText(m.Producer.Seed))
|
||||
fmt.Fprintf(&b, "- started: %s · finished: %s\n", m.Producer.StartedAt, m.Producer.FinishedAt)
|
||||
fmt.Fprintf(&b, "- harness: %s · runtime %s\n", m.Producer.HarnessVersion, m.Producer.GoVersion)
|
||||
b.WriteString("\n**Why they can still be trusted**\n\n")
|
||||
fmt.Fprintf(&b, "- rows retained: %d in `%s`\n", len(rows), batchResultsName)
|
||||
fmt.Fprintf(&b, "- results_sha256: %s\n", hashText(m.Trust.ResultsSHA256, hash))
|
||||
if m.Audit != nil {
|
||||
fmt.Fprintf(&b, "- last verified by `%s/%d` at %s over `%s`\n", auditToolName, m.Audit.VerifierVersion, m.Audit.VerifiedAt, m.Audit.ResultsSHA256)
|
||||
}
|
||||
fmt.Fprintf(&b, "- retention: %s\n", textOrUnknown(m.Trust.Retention))
|
||||
b.WriteString("\n| claimed figure | manifest | rows | |\n|---|---:|---:|---|\n")
|
||||
writeFigures(&b, figures)
|
||||
b.WriteString("\n| ledger | manifest | rows | |\n|---|---:|---:|---|\n")
|
||||
writeFigures(&b, ledger)
|
||||
b.WriteString("\n")
|
||||
if len(failures) > 0 {
|
||||
b.WriteString("**Mismatches**\n\n")
|
||||
for _, f := range failures {
|
||||
fmt.Fprintf(&b, "- %s\n", f)
|
||||
}
|
||||
b.WriteString("\n")
|
||||
}
|
||||
if len(failures) > 0 {
|
||||
fmt.Fprintf(&b, "**Verdict:** the published figures do **not** follow from the retained rows (%d mismatch(es)).\n\n", len(failures))
|
||||
} else {
|
||||
b.WriteString("**Verdict:** the published figures follow from the retained rows.\n\n")
|
||||
}
|
||||
b.WriteString("<sub>An aggregation audit, not a reproduction: the model, tools and environment are not re-executed, so this cannot show the experiment would reproduce. A real replay would need the commit, corpus fingerprint, model and provider configuration, seed, flags, raw results and runtime version; it is not implemented.</sub>\n")
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func writeFigures(b *strings.Builder, figures []auditFigure) {
|
||||
for _, f := range figures {
|
||||
note := f.note
|
||||
if f.moved {
|
||||
note = joinNote(note, "**moved**")
|
||||
}
|
||||
fmt.Fprintf(b, "| %s | %s | %s | %s |\n", f.name, f.claimed, f.actual, note)
|
||||
}
|
||||
}
|
||||
|
||||
func axisText(axes []batchFlag) string {
|
||||
if len(axes) == 0 {
|
||||
return "none recorded"
|
||||
}
|
||||
parts := make([]string, len(axes))
|
||||
for i, a := range axes {
|
||||
parts[i] = a.Name + "=" + a.Value
|
||||
}
|
||||
return "`" + strings.Join(parts, "` · `") + "`"
|
||||
}
|
||||
|
||||
func filterText(f batchFilters) string {
|
||||
if strings.TrimSpace(f.Task) == "" {
|
||||
return "none (`-task` unset: every task in the suite)"
|
||||
}
|
||||
return "`-task " + f.Task + "`"
|
||||
}
|
||||
|
||||
func sourceText(source string) string {
|
||||
if source == "" {
|
||||
return "source unknown"
|
||||
}
|
||||
return "source " + source
|
||||
}
|
||||
|
||||
func dirtyText(dirty *bool) string {
|
||||
if dirty == nil {
|
||||
return "dirty state unknown"
|
||||
}
|
||||
if *dirty {
|
||||
return "dirty tree: the commit does not identify the code that ran"
|
||||
}
|
||||
return "clean tree"
|
||||
}
|
||||
|
||||
// nameText prints an identity name, and says unknown when the field is absent
|
||||
// rather than printing an empty name that reads as "none".
|
||||
func nameText(name string) string {
|
||||
if strings.TrimSpace(name) == "" {
|
||||
return "unknown (absent from the manifest; absent is not zero)"
|
||||
}
|
||||
return "`" + name + "`"
|
||||
}
|
||||
|
||||
func seedText(seed *int64) string {
|
||||
if seed == nil {
|
||||
return "unknown (absent from the manifest; an absent seed is not seed 0)"
|
||||
}
|
||||
return strconv.FormatInt(*seed, 10)
|
||||
}
|
||||
|
||||
func hashText(claimed, actual string) string {
|
||||
if claimed == "" {
|
||||
return "unknown (the manifest claims no digest)"
|
||||
}
|
||||
if claimed != actual {
|
||||
return "**MISMATCH** — manifest `" + claimed + "`, rows `" + actual + "`"
|
||||
}
|
||||
return "ok (`" + actual + "`)"
|
||||
}
|
||||
|
||||
func textOrUnknown(s string) string {
|
||||
if strings.TrimSpace(s) == "" {
|
||||
return "unknown"
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func joinNote(a, b string) string {
|
||||
switch {
|
||||
case a == "":
|
||||
return b
|
||||
case b == "":
|
||||
return a
|
||||
default:
|
||||
return a + "; " + b
|
||||
}
|
||||
}
|
||||
|
||||
func intText(p *int) string {
|
||||
if p == nil {
|
||||
return ""
|
||||
}
|
||||
return strconv.Itoa(*p)
|
||||
}
|
||||
|
||||
func int64Text(p *int64) string {
|
||||
if p == nil {
|
||||
return ""
|
||||
}
|
||||
return strconv.FormatInt(*p, 10)
|
||||
}
|
||||
|
||||
func floatText(p *float64) string {
|
||||
if p == nil {
|
||||
return ""
|
||||
}
|
||||
return strconv.FormatFloat(*p, 'f', -1, 64)
|
||||
}
|
||||
@@ -0,0 +1,458 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"runtime/debug"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"reasonix/internal/contract/config"
|
||||
)
|
||||
|
||||
const (
|
||||
batchSchemaVersion = 1
|
||||
batchResultsName = "results.jsonl"
|
||||
batchManifestName = "batch.json"
|
||||
// auditToolVersion is the version of the code below; every successful
|
||||
// audit stamps it into the manifest, so a later reader can tell which
|
||||
// verifier checked the batch.
|
||||
auditToolVersion = 1
|
||||
auditToolName = "e2ebench-audit"
|
||||
// batchScope names the claim: an aggregation audit, not a replay.
|
||||
batchScope = "aggregation audit: the claimed figures are recomputed from the retained rows; the model, tools and environment are not re-executed, so this is not a reproduction of the experiment"
|
||||
// denominatorUnit and denominatorDefinition say what pass@1 divides by.
|
||||
denominatorUnit = "task"
|
||||
denominatorDefinition = "first attempts of solvable, non-skipped tasks: pass@1 divides passes by this count, never by the retained row count"
|
||||
// batchRetention is the one line a reader needs before sharing the batch.
|
||||
batchRetention = "results.jsonl is retained unredacted beside this manifest (task prompts and agent notes included) and is not public; the manifest carries a task-id digest, no prompt text, no absolute paths, no endpoints or keys and no model output"
|
||||
)
|
||||
|
||||
// batchOver is what the figures are over: the command, the task set identity,
|
||||
// the filters, and the denominator the accuracy is divided by.
|
||||
type batchOver struct {
|
||||
Command batchCommand `json:"command"`
|
||||
Tasks batchTaskSet `json:"tasks"`
|
||||
Filters batchFilters `json:"filters"`
|
||||
Denominator batchDenominator `json:"denominator"`
|
||||
}
|
||||
|
||||
// batchCommand records the mode and every flag that changes what a figure
|
||||
// means. Output paths are deliberately absent: they are not evidence, and an
|
||||
// absolute path is the operator's, not the batch's.
|
||||
type batchCommand struct {
|
||||
Mode string `json:"mode"`
|
||||
Axes []batchFlag `json:"axes"`
|
||||
}
|
||||
|
||||
type batchFlag struct {
|
||||
Name string `json:"name"`
|
||||
Value string `json:"value"`
|
||||
}
|
||||
|
||||
// batchTaskSet identifies the corpus without carrying its text: a stable
|
||||
// digest over the sorted task ids and how many there were.
|
||||
type batchTaskSet struct {
|
||||
Count int `json:"count"`
|
||||
Digest string `json:"digest"`
|
||||
}
|
||||
|
||||
type batchFilters struct {
|
||||
// Task is -task as given; empty means no filter.
|
||||
Task string `json:"task,omitempty"`
|
||||
}
|
||||
|
||||
// batchDenominator names the unit pass@1 divides by, so a row count can never
|
||||
// stand in for it.
|
||||
type batchDenominator struct {
|
||||
Unit string `json:"unit"`
|
||||
Definition string `json:"definition"`
|
||||
Value int `json:"value"`
|
||||
}
|
||||
|
||||
// batchProducer is which run produced the figures. Absent optional fields are
|
||||
// unknown, never zero: a missing seed is not seed 0.
|
||||
type batchProducer struct {
|
||||
Commit string `json:"commit"`
|
||||
CommitSource string `json:"commit_source,omitempty"`
|
||||
Dirty *bool `json:"dirty,omitempty"`
|
||||
Model string `json:"model,omitempty"`
|
||||
Provider string `json:"provider,omitempty"`
|
||||
Seed *int64 `json:"seed,omitempty"`
|
||||
StartedAt string `json:"started_at"`
|
||||
FinishedAt string `json:"finished_at"`
|
||||
HarnessVersion string `json:"harness_version"`
|
||||
GoVersion string `json:"go_version"`
|
||||
}
|
||||
|
||||
// batchTrust is why the figures can still be checked: the digest of the rows,
|
||||
// the headline figures as published, and the ledger every planned run is
|
||||
// accounted for in.
|
||||
type batchTrust struct {
|
||||
ResultsSHA256 string `json:"results_sha256"`
|
||||
Claimed batchClaims `json:"claimed"`
|
||||
Ledger batchLedger `json:"ledger"`
|
||||
Retention string `json:"retention"`
|
||||
}
|
||||
|
||||
// batchClaims are the headline figures the report publishes. A figure the run
|
||||
// could not measure is absent, not zero.
|
||||
type batchClaims struct {
|
||||
Solved *int `json:"solved"`
|
||||
Pass1 *int `json:"pass1"`
|
||||
PromptTokens *int `json:"prompt_tokens,omitempty"`
|
||||
CompletionTokens *int `json:"completion_tokens,omitempty"`
|
||||
CacheHitTokens *int `json:"cache_hit_tokens,omitempty"`
|
||||
CacheMissTokens *int `json:"cache_miss_tokens,omitempty"`
|
||||
Cost *float64 `json:"cost,omitempty"`
|
||||
Currency string `json:"currency,omitempty"`
|
||||
Accounted *int `json:"accounted"`
|
||||
Unaccounted *int `json:"unaccounted"`
|
||||
Partial *int `json:"partial"`
|
||||
TCTSMedianMs *int64 `json:"tcts_median_ms,omitempty"`
|
||||
}
|
||||
|
||||
// batchLedger accounts for every planned run. Rows is the retained record, not
|
||||
// a denominator; Scored is the denominator.
|
||||
type batchLedger struct {
|
||||
Planned *int `json:"planned"`
|
||||
Attempted *int `json:"attempted"`
|
||||
Completed *int `json:"completed"`
|
||||
Scored *int `json:"scored"`
|
||||
Failed *int `json:"failed"`
|
||||
Unmeasured *int `json:"unmeasured"`
|
||||
Excluded *int `json:"excluded"`
|
||||
Rows *int `json:"rows,omitempty"`
|
||||
Skipped *int `json:"skipped,omitempty"`
|
||||
Definitions map[string]string `json:"definitions,omitempty"`
|
||||
}
|
||||
|
||||
// batchAuditReceipt is what a successful audit leaves behind.
|
||||
type batchAuditReceipt struct {
|
||||
VerifierVersion int `json:"verifier_version"`
|
||||
VerifiedAt string `json:"verified_at"`
|
||||
ResultsSHA256 string `json:"results_sha256"`
|
||||
}
|
||||
|
||||
// batchManifest is written beside results.jsonl so the report never has to be
|
||||
// reconstructed from a terminal scrollback.
|
||||
type batchManifest struct {
|
||||
SchemaVersion int `json:"schema_version"`
|
||||
Scope string `json:"scope"`
|
||||
Over batchOver `json:"over"`
|
||||
Producer batchProducer `json:"producer"`
|
||||
Trust batchTrust `json:"trust"`
|
||||
Audit *batchAuditReceipt `json:"audit,omitempty"`
|
||||
}
|
||||
|
||||
// ledgerDefinitions explains each count in the manifest itself, so the
|
||||
// denominator cannot be reinterpreted after the fact.
|
||||
var ledgerDefinitions = map[string]string{
|
||||
"planned": "graded runs the batch set out to attempt: filtered tasks x trials",
|
||||
"attempted": "retained rows for runs that started (skipped rows are not attempts)",
|
||||
"completed": "attempts that ended and left a row; this harness writes a row only after an attempt ends",
|
||||
"scored": "completed first attempts of solvable, non-skipped tasks: the pass@1 denominator",
|
||||
"failed": "scored attempts the grader did not pass",
|
||||
"unmeasured": "attempts whose cost and tokens are unknown because no metrics landed; kept out of spend totals",
|
||||
"excluded": "rows kept out of the accuracy denominator: skipped, no_solution, and retry entries",
|
||||
"rows": "rows retained in results.jsonl; a record of what was kept, never the denominator",
|
||||
"skipped": "planned runs deliberately not started (anchor has no seed, or the token budget ran out)",
|
||||
}
|
||||
|
||||
// batchProvenance is what the batch knows before any run happens. Everything
|
||||
// else in the manifest is derived from the rows that were written.
|
||||
type batchProvenance struct {
|
||||
dir string
|
||||
started time.Time
|
||||
planned int
|
||||
axes []batchFlag
|
||||
tasks batchTaskSet
|
||||
filters batchFilters
|
||||
commit string
|
||||
dirty *bool
|
||||
model string
|
||||
provider string
|
||||
}
|
||||
|
||||
// captureBatchProvenance reads the checkout and model identity before the
|
||||
// batch runs: git cannot describe a tree the agent has since changed.
|
||||
func captureBatchProvenance(dir string, cfg suiteConfig, tasks []task, taskFilter string) batchProvenance {
|
||||
commit, dirty := checkoutCommit(".")
|
||||
model, provider := modelIdentity(cfg.model)
|
||||
count, digest := taskSetDigest(tasks)
|
||||
return batchProvenance{
|
||||
dir: dir, started: time.Now().UTC(), planned: count * max(cfg.trials, 1),
|
||||
axes: batchAxes(cfg), tasks: batchTaskSet{Count: count, Digest: digest},
|
||||
filters: batchFilters{Task: taskFilter},
|
||||
commit: commit, dirty: dirty, model: model, provider: provider,
|
||||
}
|
||||
}
|
||||
|
||||
// writeBatch writes the rows first and the manifest second, so a manifest
|
||||
// never describes rows that are not beside it.
|
||||
func writeBatch(p batchProvenance, results []result) error {
|
||||
if err := os.MkdirAll(p.dir, 0o750); err != nil {
|
||||
return fmt.Errorf("create batch directory: %w", err)
|
||||
}
|
||||
var raw, canonical bytes.Buffer
|
||||
for _, r := range results {
|
||||
line, err := json.Marshal(r)
|
||||
if err != nil {
|
||||
return fmt.Errorf("encode row: %w", err)
|
||||
}
|
||||
raw.Write(line)
|
||||
raw.WriteByte('\n')
|
||||
canon, err := canonicalJSON(line)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
canonical.Write(canon)
|
||||
canonical.WriteByte('\n')
|
||||
}
|
||||
// Rows carry task prompts and agent notes, so they stay the operator's.
|
||||
if err := os.WriteFile(filepath.Join(p.dir, batchResultsName), raw.Bytes(), 0o600); err != nil {
|
||||
return fmt.Errorf("write %s: %w", batchResultsName, err)
|
||||
}
|
||||
manifest := buildBatchManifest(p, results, canonical.Bytes())
|
||||
b, err := json.MarshalIndent(manifest, "", " ")
|
||||
if err != nil {
|
||||
return fmt.Errorf("encode manifest: %w", err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(p.dir, batchManifestName), append(b, '\n'), 0o644); err != nil {
|
||||
return fmt.Errorf("write %s: %w", batchManifestName, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func buildBatchManifest(p batchProvenance, results []result, canonical []byte) batchManifest {
|
||||
stats := gatherSuiteStats(results)
|
||||
harness, goVersion := harnessIdentity()
|
||||
source := ""
|
||||
if p.commit != "unknown" {
|
||||
source = "checkout"
|
||||
}
|
||||
return batchManifest{
|
||||
SchemaVersion: batchSchemaVersion,
|
||||
Scope: batchScope,
|
||||
Over: batchOver{
|
||||
Command: batchCommand{Mode: "suite", Axes: p.axes},
|
||||
Tasks: p.tasks,
|
||||
Filters: p.filters,
|
||||
Denominator: batchDenominator{Unit: denominatorUnit, Definition: denominatorDefinition, Value: stats.ran},
|
||||
},
|
||||
Producer: batchProducer{
|
||||
Commit: p.commit, CommitSource: source, Dirty: p.dirty,
|
||||
Model: p.model, Provider: p.provider,
|
||||
StartedAt: p.started.Format(time.RFC3339), FinishedAt: time.Now().UTC().Format(time.RFC3339),
|
||||
HarnessVersion: harness, GoVersion: goVersion,
|
||||
},
|
||||
Trust: batchTrust{
|
||||
ResultsSHA256: hashOf(canonical),
|
||||
Claimed: claimsFor(stats),
|
||||
Ledger: ledgerFor(results, p.planned),
|
||||
Retention: batchRetention,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// claimsFor publishes the report's headline figures. Without a single
|
||||
// accounted run there is no measured spend: those fields stay absent.
|
||||
func claimsFor(s suiteStats) batchClaims {
|
||||
c := batchClaims{
|
||||
Solved: new(s.passed), Pass1: new(s.pass1),
|
||||
Accounted: new(s.accounted), Unaccounted: new(s.unaccounted), Partial: new(s.partial),
|
||||
}
|
||||
if s.accounted > 0 {
|
||||
c.PromptTokens, c.CompletionTokens = new(s.pTok), new(s.cTok)
|
||||
c.Cost, c.Currency = new(s.cost), s.currency
|
||||
if s.hit+s.miss > 0 {
|
||||
c.CacheHitTokens, c.CacheMissTokens = new(s.hit), new(s.miss)
|
||||
}
|
||||
}
|
||||
if s.passed > 0 {
|
||||
c.TCTSMedianMs = new(median(s.ttcs))
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
// ledgerFor accounts for every planned run. A retry is a row, not a planned
|
||||
// unit: the plan is tasks x trials, which is why Scored and Attempted differ.
|
||||
func ledgerFor(results []result, planned int) batchLedger {
|
||||
l := ledgerFromRows(results)
|
||||
l.Planned = new(planned)
|
||||
l.Definitions = ledgerDefinitions
|
||||
return l
|
||||
}
|
||||
|
||||
func ledgerFromRows(results []result) batchLedger {
|
||||
var attempted, completed, scored, failed, unmeasured, excluded, skipped int
|
||||
for _, r := range results {
|
||||
inDenominator := !r.Skipped && !r.NoSolution && r.Attempt <= 1
|
||||
if r.Skipped {
|
||||
skipped++
|
||||
excluded++
|
||||
continue
|
||||
}
|
||||
attempted++
|
||||
completed++
|
||||
if r.Unaccounted {
|
||||
unmeasured++
|
||||
}
|
||||
if !inDenominator {
|
||||
excluded++
|
||||
continue
|
||||
}
|
||||
scored++
|
||||
if !r.Passed {
|
||||
failed++
|
||||
}
|
||||
}
|
||||
return batchLedger{
|
||||
Attempted: new(attempted), Completed: new(completed), Scored: new(scored),
|
||||
Failed: new(failed), Unmeasured: new(unmeasured), Excluded: new(excluded),
|
||||
Rows: new(len(results)), Skipped: new(skipped),
|
||||
}
|
||||
}
|
||||
|
||||
// canonicalJSON is what results_sha256 covers: line endings, indentation and
|
||||
// key order are dropped because none of them is evidence, while a changed value
|
||||
// or a missing row still changes the digest. Number spelling is kept, so a
|
||||
// rewritten 1 is not silently equal to 1.0.
|
||||
func canonicalJSON(line []byte) ([]byte, error) {
|
||||
dec := json.NewDecoder(bytes.NewReader(line))
|
||||
dec.UseNumber()
|
||||
var row any
|
||||
if err := dec.Decode(&row); err != nil {
|
||||
return nil, fmt.Errorf("row is not valid JSON: %w", err)
|
||||
}
|
||||
return json.Marshal(row)
|
||||
}
|
||||
|
||||
func hashOf(data []byte) string {
|
||||
sum := sha256.Sum256(data)
|
||||
return "sha256:" + hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
// checkoutCommit reports the checkout the batch runs from. Unknown is written
|
||||
// explicitly rather than guessed: a directory git cannot describe is not a
|
||||
// commit, and a dirty tree is not the commit it names.
|
||||
func checkoutCommit(dir string) (string, *bool) {
|
||||
commit := "unknown"
|
||||
out, err := exec.Command("git", "-C", dir, "rev-parse", "HEAD").Output()
|
||||
if err == nil {
|
||||
if sha := strings.TrimSpace(string(out)); sha != "" {
|
||||
commit = sha
|
||||
}
|
||||
}
|
||||
var dirty *bool
|
||||
out, err = exec.Command("git", "-C", dir, "status", "--porcelain").Output()
|
||||
if err == nil {
|
||||
state := strings.TrimSpace(string(out)) != ""
|
||||
dirty = &state
|
||||
}
|
||||
return commit, dirty
|
||||
}
|
||||
|
||||
// modelIdentity resolves the model and provider names the agent will run
|
||||
// under. Names only: no endpoint, key or credential reaches the manifest.
|
||||
func modelIdentity(flagModel string) (model, provider string) {
|
||||
ref := strings.TrimSpace(flagModel)
|
||||
cfg, err := config.Load()
|
||||
if err == nil && cfg != nil {
|
||||
if resolved, _, ok := cfg.ResolveModelWithFallback(ref); ok {
|
||||
if name, rest, found := strings.Cut(resolved, "/"); found {
|
||||
return rest, name
|
||||
}
|
||||
return resolved, ""
|
||||
}
|
||||
if ref == "" {
|
||||
ref = strings.TrimSpace(cfg.DefaultModel)
|
||||
}
|
||||
}
|
||||
if name, rest, found := strings.Cut(ref, "/"); found {
|
||||
return rest, name
|
||||
}
|
||||
return ref, ""
|
||||
}
|
||||
|
||||
func harnessIdentity() (string, string) {
|
||||
version := "(devel)"
|
||||
if bi, ok := debug.ReadBuildInfo(); ok && bi.Main.Version != "" {
|
||||
version = bi.Main.Version
|
||||
}
|
||||
return version, runtime.Version()
|
||||
}
|
||||
|
||||
// taskSetDigest identifies the corpus by sorted id, not by name order: a
|
||||
// reordered -task list is the same set. Ids are NUL-separated because a path
|
||||
// name cannot contain NUL, so no two id sets share a digest string.
|
||||
func taskSetDigest(tasks []task) (int, string) {
|
||||
ids := make([]string, len(tasks))
|
||||
for i, t := range tasks {
|
||||
ids[i] = t.ID
|
||||
}
|
||||
sort.Strings(ids)
|
||||
sum := sha256.Sum256([]byte(strings.Join(ids, "\x00")))
|
||||
return len(ids), "sha256:" + hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
// batchAxes lists the flags that change what a figure means, with their
|
||||
// effective values. Paths and free text stay out: -steer records its count.
|
||||
func batchAxes(cfg suiteConfig) []batchFlag {
|
||||
tape := "off"
|
||||
switch cfg.tape.mode {
|
||||
case tapeRecord:
|
||||
tape = "record"
|
||||
case tapeReplay:
|
||||
tape = "replay"
|
||||
}
|
||||
return []batchFlag{
|
||||
{"profile", cfg.profile},
|
||||
{"ablate", cfg.arm.Arm()},
|
||||
{"cache", cfg.cacheArm},
|
||||
{"anchor", cfg.anchor},
|
||||
{"effort", valueOr(cfg.effort, "default")},
|
||||
{"policy", valueOr(cfg.policy, "baseline")},
|
||||
{"force-planner", formatBool(cfg.forcePlanner)},
|
||||
{"checkpoints", formatBool(cfg.checkpoints)},
|
||||
{"attempts", strconv.Itoa(max(cfg.attempts, 1))},
|
||||
{"trials", strconv.Itoa(max(cfg.trials, 1))},
|
||||
{"budget", strconv.Itoa(cfg.budget)},
|
||||
{"segments", strconv.Itoa(max(cfg.segments, 1))},
|
||||
{"steers", strconv.Itoa(len(cfg.steers))},
|
||||
{"meter", onOff(cfg.meterConfig != "")},
|
||||
{"faults", onOff(!cfg.meterFaults.empty())},
|
||||
{"tape", tape},
|
||||
{"reply-language", valueOr(cfg.replyLanguage, "auto")},
|
||||
}
|
||||
}
|
||||
|
||||
func valueOr(v, fallback string) string {
|
||||
if strings.TrimSpace(v) == "" {
|
||||
return fallback
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
func formatBool(v bool) string {
|
||||
if v {
|
||||
return "true"
|
||||
}
|
||||
return "false"
|
||||
}
|
||||
|
||||
func onOff(v bool) string {
|
||||
if v {
|
||||
return "on"
|
||||
}
|
||||
return "off"
|
||||
}
|
||||
@@ -0,0 +1,519 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// fixtureRows is a synthetic batch: three solvable tasks, one of them solved on
|
||||
// a retry. Numbers are chosen so every headline figure is non-zero and the
|
||||
// ledger has one of each interesting category.
|
||||
func fixtureRows() []result {
|
||||
return []result{
|
||||
{
|
||||
task: task{ID: "task-a", Prompt: "fixture prompt a"},
|
||||
runMetrics: runMetrics{PromptTokens: 1000, CompletionTokens: 100, CacheHitTokens: 600, CacheMissTokens: 400, Cost: 0.01, Currency: "USD"},
|
||||
Attempt: 1, Passed: true, WallMs: 1200, TTCSMs: 1200,
|
||||
},
|
||||
{
|
||||
task: task{ID: "task-b", Prompt: "fixture prompt b"},
|
||||
runMetrics: runMetrics{PromptTokens: 2000, CompletionTokens: 200, CacheHitTokens: 1500, CacheMissTokens: 500, Cost: 0.02, Currency: "USD"},
|
||||
Attempt: 1, Passed: true, WallMs: 800, TTCSMs: 800,
|
||||
},
|
||||
{
|
||||
task: task{ID: "task-c", Prompt: "fixture prompt c"},
|
||||
runMetrics: runMetrics{PromptTokens: 3000, CompletionTokens: 300, CacheHitTokens: 0, CacheMissTokens: 3000, Cost: 0.03, Currency: "USD"},
|
||||
Attempt: 1, Passed: false, WallMs: 500,
|
||||
},
|
||||
{
|
||||
task: task{ID: "task-c"},
|
||||
runMetrics: runMetrics{PromptTokens: 500, CompletionTokens: 50, Cost: 0.005, Currency: "USD"},
|
||||
Attempt: 2, Passed: true, WallMs: 400, TTCSMs: 900,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func fixtureProvenance(dir string) batchProvenance {
|
||||
count, digest := taskSetDigest([]task{{ID: "task-a"}, {ID: "task-b"}, {ID: "task-c"}})
|
||||
return batchProvenance{
|
||||
dir: dir,
|
||||
started: timeAt("2026-10-28T00:00:00Z"),
|
||||
planned: count,
|
||||
axes: []batchFlag{{"profile", "baseline"}, {"trials", "1"}},
|
||||
tasks: batchTaskSet{Count: count, Digest: digest},
|
||||
commit: "0123456789abcdef0123456789abcdef01234567",
|
||||
dirty: new(false),
|
||||
model: "deepseek-flash",
|
||||
}
|
||||
}
|
||||
|
||||
func timeAt(s string) time.Time {
|
||||
t, err := time.Parse(time.RFC3339, s)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return t
|
||||
}
|
||||
|
||||
func writeFixtureBatch(t *testing.T) (string, batchManifest) {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
if err := writeBatch(fixtureProvenance(dir), fixtureRows()); err != nil {
|
||||
t.Fatalf("write batch: %v", err)
|
||||
}
|
||||
return dir, readManifest(t, dir)
|
||||
}
|
||||
|
||||
func readManifest(t *testing.T, dir string) batchManifest {
|
||||
t.Helper()
|
||||
data, err := os.ReadFile(filepath.Join(dir, batchManifestName))
|
||||
if err != nil {
|
||||
t.Fatalf("read manifest: %v", err)
|
||||
}
|
||||
var m batchManifest
|
||||
if err := json.Unmarshal(data, &m); err != nil {
|
||||
t.Fatalf("decode manifest: %v", err)
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
func writeManifest(t *testing.T, dir string, m batchManifest) {
|
||||
t.Helper()
|
||||
data, err := json.MarshalIndent(m, "", " ")
|
||||
if err != nil {
|
||||
t.Fatalf("encode manifest: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dir, batchManifestName), append(data, '\n'), 0o644); err != nil {
|
||||
t.Fatalf("write manifest: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// writeRows rewrites results.jsonl without touching the manifest, which is what
|
||||
// a tampered batch looks like.
|
||||
func writeRows(t *testing.T, dir string, rows []result) {
|
||||
t.Helper()
|
||||
var b bytes.Buffer
|
||||
for _, r := range rows {
|
||||
line, err := json.Marshal(r)
|
||||
if err != nil {
|
||||
t.Fatalf("encode row: %v", err)
|
||||
}
|
||||
b.Write(line)
|
||||
b.WriteByte('\n')
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dir, batchResultsName), b.Bytes(), 0o600); err != nil {
|
||||
t.Fatalf("write rows: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// A batch written by the producer audits clean: the figures the manifest
|
||||
// published are the figures the retained rows give.
|
||||
func TestBatchAuditRoundTrip(t *testing.T) {
|
||||
dir, m := writeFixtureBatch(t)
|
||||
if m.SchemaVersion != batchSchemaVersion || m.Scope == "" {
|
||||
t.Fatalf("manifest schema = %d scope = %q, want v%d with a named claim", m.SchemaVersion, m.Scope, batchSchemaVersion)
|
||||
}
|
||||
if m.Over.Denominator.Value != 3 || m.Over.Denominator.Unit != denominatorUnit || m.Over.Denominator.Definition != denominatorDefinition {
|
||||
t.Fatalf("denominator = %+v, want 3 tasks under the harness definition", m.Over.Denominator)
|
||||
}
|
||||
if m.Producer.Commit != "0123456789abcdef0123456789abcdef01234567" || m.Producer.CommitSource != "checkout" {
|
||||
t.Fatalf("producer = %+v, want the checkout commit recorded", m.Producer)
|
||||
}
|
||||
if m.Trust.Ledger.Planned == nil || *m.Trust.Ledger.Planned != 3 {
|
||||
t.Fatalf("planned = %v, want 3", m.Trust.Ledger.Planned)
|
||||
}
|
||||
if m.Trust.Ledger.Scored == nil || *m.Trust.Ledger.Scored != 3 {
|
||||
t.Fatalf("scored = %v, want 3", m.Trust.Ledger.Scored)
|
||||
}
|
||||
if m.Trust.Ledger.Attempted == nil || *m.Trust.Ledger.Attempted != 4 || m.Trust.Ledger.Excluded == nil || *m.Trust.Ledger.Excluded != 1 {
|
||||
t.Fatalf("ledger attempted/excluded = %v/%v, want 4/1", m.Trust.Ledger.Attempted, m.Trust.Ledger.Excluded)
|
||||
}
|
||||
|
||||
// The manifest must not carry the raw task text the rows legitimately do.
|
||||
raw, err := os.ReadFile(filepath.Join(dir, batchManifestName))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if bytes.Contains(raw, []byte("fixture prompt")) {
|
||||
t.Fatalf("the manifest carries raw task text:\n%s", raw)
|
||||
}
|
||||
|
||||
out, err := auditBatch(dir)
|
||||
if err != nil {
|
||||
t.Fatalf("a producer-written batch did not audit: %v\n%s", err, out)
|
||||
}
|
||||
for _, want := range []string{
|
||||
"| solved | 3 | 3 |",
|
||||
"| pass1 | 2 | 2 |",
|
||||
"| prompt_tokens | 6500 | 6500 |",
|
||||
"| cost | 0.065 | 0.065 |",
|
||||
"| scored | 3 | 3 |",
|
||||
"| failed | 1 | 1 |",
|
||||
"| planned | 3 | n/a |",
|
||||
"results_sha256: ok",
|
||||
"the published figures follow from the retained rows.",
|
||||
"aggregation audit, not a reproduction",
|
||||
} {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Fatalf("audit output is missing %q:\n%s", want, out)
|
||||
}
|
||||
}
|
||||
receipt := readManifest(t, dir).Audit
|
||||
if receipt == nil || receipt.VerifierVersion != auditToolVersion || receipt.ResultsSHA256 != m.Trust.ResultsSHA256 {
|
||||
t.Fatalf("audit receipt = %+v, want verifier v%d over the same digest", receipt, auditToolVersion)
|
||||
}
|
||||
}
|
||||
|
||||
// The axes carry what changes a figure's meaning, never the operator's machine:
|
||||
// meter/tape/trajectory paths and steer text stay out of the manifest.
|
||||
func TestBatchAxesKeepPathsAndFreeTextOut(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
prov := fixtureProvenance(dir)
|
||||
prov.axes = batchAxes(suiteConfig{
|
||||
profile: "delivery", cacheArm: "warm", effort: "high", anchor: "blind",
|
||||
attempts: 1, trials: 2, budget: 0, segments: 3,
|
||||
meterConfig: "/Users/someone/.reasonix/config.toml",
|
||||
meterFaults: faultScript{at: map[int]int{3: 429}},
|
||||
tape: tapeConfig{mode: tapeRecord, root: "/tmp/tapes"},
|
||||
steers: map[int]string{2: "secret steer text"},
|
||||
trajDir: "/tmp/traj",
|
||||
})
|
||||
if err := writeBatch(prov, fixtureRows()); err != nil {
|
||||
t.Fatalf("write batch: %v", err)
|
||||
}
|
||||
raw, err := os.ReadFile(filepath.Join(dir, batchManifestName))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, forbidden := range []string{"/Users", "/tmp", "secret steer text", ".reasonix"} {
|
||||
if bytes.Contains(raw, []byte(forbidden)) {
|
||||
t.Fatalf("the manifest carries %q:\n%s", forbidden, raw)
|
||||
}
|
||||
}
|
||||
axes := map[string]string{}
|
||||
for _, a := range readManifest(t, dir).Over.Command.Axes {
|
||||
axes[a.Name] = a.Value
|
||||
}
|
||||
want := map[string]string{
|
||||
"profile": "delivery", "effort": "high", "trials": "2", "segments": "3",
|
||||
"meter": "on", "faults": "on", "tape": "record", "steers": "1", "ablate": "full",
|
||||
}
|
||||
for name, value := range want {
|
||||
if axes[name] != value {
|
||||
t.Fatalf("axis %s = %q, want %q (axes: %v)", name, axes[name], value, axes)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The corpus digest is a set digest: order must not move it, and ids that only
|
||||
// differ by a separator must not collide.
|
||||
func TestTaskSetDigestIsSeparatedAndOrderIndependent(t *testing.T) {
|
||||
n1, d1 := taskSetDigest([]task{{ID: "b"}, {ID: "a"}})
|
||||
n2, d2 := taskSetDigest([]task{{ID: "a"}, {ID: "b"}})
|
||||
if n1 != 2 || n2 != 2 || d1 != d2 {
|
||||
t.Fatalf("order moved the corpus digest: %d/%s vs %d/%s", n1, d1, n2, d2)
|
||||
}
|
||||
_, joined := taskSetDigest([]task{{ID: "a\nb"}})
|
||||
if joined == d1 {
|
||||
t.Fatal("a newline inside one id collides with two ids")
|
||||
}
|
||||
}
|
||||
|
||||
// One token count moved after publication: the audit must fail and name the
|
||||
// figure that moved, not just say the digest differs.
|
||||
func TestBatchAuditNamesTheFigureThatMoved(t *testing.T) {
|
||||
dir, m := writeFixtureBatch(t)
|
||||
tampered := fixtureRows()
|
||||
tampered[0].PromptTokens += 1000
|
||||
writeRows(t, dir, tampered)
|
||||
|
||||
out, err := auditBatch(dir)
|
||||
if err == nil {
|
||||
t.Fatalf("a tampered row audited clean:\n%s", out)
|
||||
}
|
||||
for _, want := range []string{
|
||||
"prompt_tokens: manifest 6500, rows 7500",
|
||||
"| prompt_tokens | 6500 | 7500 | **moved** |",
|
||||
"MISMATCH",
|
||||
"the published figures do **not** follow from the retained rows",
|
||||
} {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Fatalf("audit output is missing %q:\n%s", want, out)
|
||||
}
|
||||
}
|
||||
if readManifest(t, dir).Audit != nil {
|
||||
t.Fatal("a failed audit recorded a verification receipt")
|
||||
}
|
||||
if m.Trust.ResultsSHA256 == "" {
|
||||
t.Fatal("fixture manifest carries no digest")
|
||||
}
|
||||
}
|
||||
|
||||
// The three ways a batch can be unauditable are three different messages: no
|
||||
// manifest, no commit to tie the figures to, and rows cut short.
|
||||
func TestBatchAuditRefusesUnverifiableBatches(t *testing.T) {
|
||||
t.Run("missing manifest", func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
writeRows(t, dir, fixtureRows())
|
||||
out, err := auditBatch(dir)
|
||||
if err == nil {
|
||||
t.Fatalf("rows without a manifest audited clean:\n%s", out)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "no batch.json") || !strings.Contains(err.Error(), "kept no manifest") {
|
||||
t.Fatalf("missing-manifest error = %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("unknown commit", func(t *testing.T) {
|
||||
dir, m := writeFixtureBatch(t)
|
||||
m.Producer.Commit = "unknown"
|
||||
m.Producer.CommitSource = ""
|
||||
writeManifest(t, dir, m)
|
||||
out, err := auditBatch(dir)
|
||||
if err == nil {
|
||||
t.Fatalf("a manifest with an unknown commit audited clean:\n%s", out)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "records no commit") {
|
||||
t.Fatalf("no-commit error = %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("absent commit", func(t *testing.T) {
|
||||
dir, m := writeFixtureBatch(t)
|
||||
m.Producer.Commit = ""
|
||||
writeManifest(t, dir, m)
|
||||
if _, err := auditBatch(dir); err == nil || !strings.Contains(err.Error(), "records no commit") {
|
||||
t.Fatalf("an absent commit answered %v, want the no-commit refusal", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("truncated rows", func(t *testing.T) {
|
||||
dir, _ := writeFixtureBatch(t)
|
||||
path := filepath.Join(dir, batchResultsName)
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(path, data[:len(data)-40], 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
out, err := auditBatch(dir)
|
||||
if err == nil {
|
||||
t.Fatalf("truncated rows audited clean:\n%s", out)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "truncated") {
|
||||
t.Fatalf("truncated-rows error = %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("half a row with a newline", func(t *testing.T) {
|
||||
dir, _ := writeFixtureBatch(t)
|
||||
path := filepath.Join(dir, batchResultsName)
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cut := bytes.LastIndexByte(data[:len(data)-1], '\n')
|
||||
if cut < 0 {
|
||||
t.Fatal("fixture has fewer than two rows")
|
||||
}
|
||||
broken := append(append([]byte{}, data[:cut+1]...), data[cut+1:len(data)-20]...)
|
||||
broken = append(broken, '\n')
|
||||
if err := os.WriteFile(path, broken, 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := auditBatch(dir); err == nil || !strings.Contains(err.Error(), "not a complete row") {
|
||||
t.Fatalf("a half-written row answered %v, want a truncation refusal", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// manifestWithoutOptionals is pinned JSON, not a re-marshal of the writer's
|
||||
// struct: seed, provider, dirty and cost are absent, and the audit must read
|
||||
// each as unknown rather than as zero. This is the record the contract is
|
||||
// about, so it is written out in full.
|
||||
const manifestWithoutOptionals = `{
|
||||
"schema_version": 1,
|
||||
"scope": %q,
|
||||
"over": {
|
||||
"command": {"mode": "suite", "axes": [{"name": "profile", "value": "baseline"}, {"name": "trials", "value": "1"}]},
|
||||
"tasks": {"count": 3, "digest": %q},
|
||||
"denominator": {"unit": "task", "definition": %q, "value": 3}
|
||||
},
|
||||
"producer": {
|
||||
"commit": "0123456789abcdef0123456789abcdef01234567",
|
||||
"commit_source": "checkout",
|
||||
"model": "deepseek-flash",
|
||||
"started_at": "2026-10-28T00:00:00Z",
|
||||
"finished_at": "2026-10-28T00:10:00Z",
|
||||
"harness_version": "(devel)",
|
||||
"go_version": "go1.26.5"
|
||||
},
|
||||
"trust": {
|
||||
"results_sha256": %q,
|
||||
"claimed": {
|
||||
"solved": 3,
|
||||
"pass1": 2,
|
||||
"prompt_tokens": 6500,
|
||||
"completion_tokens": 650,
|
||||
"cache_hit_tokens": 2100,
|
||||
"cache_miss_tokens": 3900,
|
||||
"accounted": 4,
|
||||
"unaccounted": 0,
|
||||
"partial": 0,
|
||||
"tcts_median_ms": 900
|
||||
},
|
||||
"ledger": {
|
||||
"planned": 3,
|
||||
"attempted": 4,
|
||||
"completed": 4,
|
||||
"scored": 3,
|
||||
"failed": 1,
|
||||
"unmeasured": 0,
|
||||
"excluded": 1,
|
||||
"rows": 4,
|
||||
"skipped": 0
|
||||
},
|
||||
"retention": %q
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
func TestBatchAuditReadsAbsentFieldsAsUnknown(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
writeRows(t, dir, fixtureRows())
|
||||
_, canonical, err := readBatch(filepath.Join(dir, batchResultsName))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, digest := taskSetDigest([]task{{ID: "task-a"}, {ID: "task-b"}, {ID: "task-c"}})
|
||||
manifest := fmt.Sprintf(manifestWithoutOptionals, batchScope, digest, denominatorDefinition, hashOf(canonical), batchRetention)
|
||||
if err := os.WriteFile(filepath.Join(dir, batchManifestName), []byte(manifest), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
out, err := auditBatch(dir)
|
||||
if err != nil {
|
||||
t.Fatalf("a manifest with only optional fields absent did not audit: %v\n%s", err, out)
|
||||
}
|
||||
for _, want := range []string{
|
||||
"seed: unknown (absent from the manifest; an absent seed is not seed 0)",
|
||||
"provider: unknown (absent from the manifest; absent is not zero)",
|
||||
"| cost | unknown | 0.065 | not claimed |",
|
||||
} {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Fatalf("audit output is missing %q:\n%s", want, out)
|
||||
}
|
||||
}
|
||||
for _, forbidden := range []string{"seed: 0", "| cost | 0 |", "provider ``"} {
|
||||
if strings.Contains(out, forbidden) {
|
||||
t.Fatalf("an absent field read as %q:\n%s", forbidden, out)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The digest covers values, not spelling: CRLF line endings and reordered keys
|
||||
// are the same rows, so the same manifest still audits.
|
||||
func TestBatchDigestIgnoresLineEndingsAndKeyOrder(t *testing.T) {
|
||||
dir, m := writeFixtureBatch(t)
|
||||
path := filepath.Join(dir, batchResultsName)
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var rewritten bytes.Buffer
|
||||
for line := range bytes.SplitSeq(data, []byte("\n")) {
|
||||
if len(line) == 0 {
|
||||
continue
|
||||
}
|
||||
rewritten.Write(reorderKeys(t, line))
|
||||
}
|
||||
if !bytes.Contains(rewritten.Bytes(), []byte("\r\n")) {
|
||||
t.Fatal("fixture rewrite did not use CRLF")
|
||||
}
|
||||
if bytes.Equal(rewritten.Bytes(), data) {
|
||||
t.Fatal("fixture rewrite left the bytes unchanged, so it proves nothing")
|
||||
}
|
||||
if err := os.WriteFile(path, rewritten.Bytes(), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
_, canonical, err := readBatch(path)
|
||||
if err != nil {
|
||||
t.Fatalf("re-spelled rows did not parse: %v", err)
|
||||
}
|
||||
if got := hashOf(canonical); got != m.Trust.ResultsSHA256 {
|
||||
t.Fatalf("digest moved with spelling: %s, want %s", got, m.Trust.ResultsSHA256)
|
||||
}
|
||||
if out, err := auditBatch(dir); err != nil {
|
||||
t.Fatalf("re-spelled rows did not audit: %v\n%s", err, out)
|
||||
}
|
||||
}
|
||||
|
||||
// reorderKeys emits the row with its keys in reverse order and a CRLF ending:
|
||||
// both differences are outside the values results_sha256 covers.
|
||||
func reorderKeys(t *testing.T, line []byte) []byte {
|
||||
t.Helper()
|
||||
var obj map[string]json.RawMessage
|
||||
if err := json.Unmarshal(line, &obj); err != nil {
|
||||
t.Fatalf("decode row: %v", err)
|
||||
}
|
||||
keys := make([]string, 0, len(obj))
|
||||
for k := range obj {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Sort(sort.Reverse(sort.StringSlice(keys)))
|
||||
var b bytes.Buffer
|
||||
b.WriteByte('{')
|
||||
for i, k := range keys {
|
||||
if i > 0 {
|
||||
b.WriteByte(',')
|
||||
}
|
||||
key, err := json.Marshal(k)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
b.Write(key)
|
||||
b.WriteByte(':')
|
||||
b.Write(obj[k])
|
||||
}
|
||||
b.WriteString("}\r\n")
|
||||
return b.Bytes()
|
||||
}
|
||||
|
||||
// A ledger is a funnel. Counts that cannot all be true together are rejected,
|
||||
// so the published denominator cannot be larger than the runs behind it.
|
||||
func TestBatchAuditRejectsALedgerThatDoesNotAddUp(t *testing.T) {
|
||||
t.Run("scored above completed", func(t *testing.T) {
|
||||
dir, m := writeFixtureBatch(t)
|
||||
m.Trust.Ledger.Scored = new(5)
|
||||
writeManifest(t, dir, m)
|
||||
out, err := auditBatch(dir)
|
||||
if err == nil {
|
||||
t.Fatalf("a ledger with scored > completed audited clean:\n%s", out)
|
||||
}
|
||||
if !strings.Contains(out, "ledger: scored 5 exceeds completed 4") {
|
||||
t.Fatalf("audit output does not name the broken invariant:\n%s", out)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("denominator is not passes plus failures", func(t *testing.T) {
|
||||
dir, m := writeFixtureBatch(t)
|
||||
m.Trust.Ledger.Failed = new(0)
|
||||
writeManifest(t, dir, m)
|
||||
out, err := auditBatch(dir)
|
||||
if err == nil {
|
||||
t.Fatalf("a ledger whose denominator is not pass1+failed audited clean:\n%s", out)
|
||||
}
|
||||
if !strings.Contains(out, "ledger: scored 3 is not pass1 2 + failed 0") {
|
||||
t.Fatalf("audit output does not name the denominator split:\n%s", out)
|
||||
}
|
||||
})
|
||||
}
|
||||
+22
-15
@@ -233,20 +233,9 @@ func (r result) class() string {
|
||||
const defaultSuiteTokenBudget = 800_000
|
||||
|
||||
func main() {
|
||||
flag.Usage = func() {
|
||||
fmt.Fprintf(flag.CommandLine.Output(), "e2ebench — Reasonix end-to-end benchmark.\n\n")
|
||||
fmt.Fprintf(flag.CommandLine.Output(), "Usage of %s:\n", flag.CommandLine.Name())
|
||||
flag.PrintDefaults()
|
||||
fmt.Fprintf(flag.CommandLine.Output(), "\nExamples:\n")
|
||||
fmt.Fprintf(flag.CommandLine.Output(), " # Run the committed suite:\n")
|
||||
fmt.Fprintf(flag.CommandLine.Output(), " %[1]s\n\n", strings.Replace(flag.CommandLine.Name(), "e2ebench", "go run ./cmd/e2ebench", 1))
|
||||
fmt.Fprintf(flag.CommandLine.Output(), " # Grade a PR's diff with a retry budget:\n")
|
||||
fmt.Fprintf(flag.CommandLine.Output(), " %[1]s -mode diff -base origin/main-v2 -repo . -attempts 3 -timeout 1800\n", strings.Replace(flag.CommandLine.Name(), "e2ebench", "go run ./cmd/e2ebench", 1))
|
||||
fmt.Fprintf(flag.CommandLine.Output(), "\n # Run the same suite with the delivery contract:\n")
|
||||
fmt.Fprintf(flag.CommandLine.Output(), " %[1]s -profile delivery\n", strings.Replace(flag.CommandLine.Name(), "e2ebench", "go run ./cmd/e2ebench", 1))
|
||||
}
|
||||
flag.Usage = printUsage
|
||||
|
||||
mode := flag.String("mode", "suite", "suite | diff | swebench | compare | rollout | traj | barrier | sft | serve")
|
||||
mode := flag.String("mode", "suite", "suite | diff | swebench | compare | rollout | traj | barrier | sft | serve | audit")
|
||||
addr := flag.String("addr", "127.0.0.1:7480", "serve mode: live dashboard listen address")
|
||||
subset := flag.String("subset", "benchmarks/swebench/subset.json", "swebench mode: instance subset file")
|
||||
reportIn := flag.String("report", "", "sft mode: the run report (-json output) whose grader verdicts decide which runs are exported")
|
||||
@@ -276,6 +265,7 @@ func main() {
|
||||
forcePlanner := flag.Bool("force-planner", false, "suite mode: prefix each prompt with a plan-first directive so the two-model turn engages regardless of the planner gate")
|
||||
anchorFlag := flag.String("anchor", anchorBlind, "suite mode: hypothesis arm — blind (no hypothesis) | correct | wrong; correct/wrong prefix each prompt with the task's authored seed and skip tasks that have none")
|
||||
outJSON := flag.String("json", "", "write the JSON report here (optional)")
|
||||
batchDir := flag.String("batch", "", "suite mode: write results.jsonl (one row per run) and batch.json (manifest v1) here, so a later -mode audit can re-derive the report's figures; audit mode: read the batch from here")
|
||||
budget := flag.Int("budget", defaultSuiteTokenBudget, "abort once total tokens cross this (0 = no cap)")
|
||||
// diff-mode flags
|
||||
repo := flag.String("repo", ".", "repo root (diff mode)")
|
||||
@@ -298,6 +288,10 @@ func main() {
|
||||
os.Exit(2)
|
||||
}
|
||||
profile, arm, cache, anchor := axes.profile, axes.arm, axes.cache, axes.anchor
|
||||
if *batchDir != "" && *mode != "suite" && *mode != "audit" {
|
||||
fmt.Fprintln(os.Stderr, "e2ebench: -batch is written by -mode suite and read by -mode audit; -mode "+*mode+" does neither")
|
||||
os.Exit(2)
|
||||
}
|
||||
|
||||
if *mode == "swebench" {
|
||||
if _, err := permissionFlag(*permission); err != nil {
|
||||
@@ -321,6 +315,7 @@ func main() {
|
||||
|
||||
if dispatchOfflineMode(*mode, offlineModeArgs{
|
||||
trajDir: *trajDir, suite: *suite, reportIn: *reportIn, outMD: *outMD, outJSON: *outJSON, addr: *addr,
|
||||
batchDir: *batchDir,
|
||||
}) {
|
||||
return
|
||||
}
|
||||
@@ -342,10 +337,10 @@ func main() {
|
||||
cacheArm: cache, effort: *effort, checkpoints: *checkpoints, policy: *policyFlag,
|
||||
meterConfig: meterSource, meterFaults: faults, segments: segments, steers: steers,
|
||||
replyLanguage: agentReplyLanguage(),
|
||||
}, *suite, *taskFilter, *outMD, *outJSON)
|
||||
}, *suite, *taskFilter, *outMD, *outJSON, *batchDir)
|
||||
}
|
||||
|
||||
func runSuiteMode(cfg suiteConfig, suite, taskFilter, outMD, outJSON string) {
|
||||
func runSuiteMode(cfg suiteConfig, suite, taskFilter, outMD, outJSON, batchDir string) {
|
||||
tasks, err := loadTasks(suite)
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, "load suite:", err)
|
||||
@@ -363,7 +358,19 @@ func runSuiteMode(cfg suiteConfig, suite, taskFilter, outMD, outJSON string) {
|
||||
os.Exit(2)
|
||||
}
|
||||
|
||||
// The provenance is captured before the first run: the commit a run ends
|
||||
// on is not necessarily the commit it started from.
|
||||
var batch batchProvenance
|
||||
if batchDir != "" {
|
||||
batch = captureBatchProvenance(batchDir, cfg, tasks, taskFilter)
|
||||
}
|
||||
results := runSuite(cfg, tasks)
|
||||
if batchDir != "" {
|
||||
if err := writeBatch(batch, results); err != nil {
|
||||
fmt.Fprintln(os.Stderr, "write batch:", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
report := render(results)
|
||||
if outMD != "" {
|
||||
|
||||
@@ -58,6 +58,7 @@ type offlineModeArgs struct {
|
||||
outMD string
|
||||
outJSON string
|
||||
addr string
|
||||
batchDir string
|
||||
}
|
||||
|
||||
// dispatchOfflineMode runs mode if it is one of the offline ones and reports
|
||||
@@ -82,6 +83,8 @@ func dispatchOfflineMode(mode string, a offlineModeArgs) bool {
|
||||
emit(report, a.outMD, "")
|
||||
case "sft":
|
||||
fail("sft", runSFTMode(a.trajDir, a.suite, a.reportIn, a.outMD))
|
||||
case "audit":
|
||||
fail("audit", runAuditMode(a.batchDir, a.outMD))
|
||||
case "serve":
|
||||
fail("serve", runServeMode(a.trajDir, a.suite, a.addr))
|
||||
default:
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// printUsage is the harness's help text. It names one thing -mode audit does
|
||||
// not do — replay the experiment — and what a replay would need instead.
|
||||
func printUsage() {
|
||||
fmt.Fprintf(flag.CommandLine.Output(), "e2ebench — Reasonix end-to-end benchmark.\n\n")
|
||||
fmt.Fprintf(flag.CommandLine.Output(), "Usage of %s:\n", flag.CommandLine.Name())
|
||||
flag.PrintDefaults()
|
||||
fmt.Fprintf(flag.CommandLine.Output(), "\nExamples:\n")
|
||||
fmt.Fprintf(flag.CommandLine.Output(), " # Run the committed suite:\n")
|
||||
fmt.Fprintf(flag.CommandLine.Output(), " %[1]s\n\n", strings.Replace(flag.CommandLine.Name(), "e2ebench", "go run ./cmd/e2ebench", 1))
|
||||
fmt.Fprintf(flag.CommandLine.Output(), " # Grade a PR's diff with a retry budget:\n")
|
||||
fmt.Fprintf(flag.CommandLine.Output(), " %[1]s -mode diff -base origin/main-v2 -repo . -attempts 3 -timeout 1800\n", strings.Replace(flag.CommandLine.Name(), "e2ebench", "go run ./cmd/e2ebench", 1))
|
||||
fmt.Fprintf(flag.CommandLine.Output(), "\n # Run the same suite with the delivery contract:\n")
|
||||
fmt.Fprintf(flag.CommandLine.Output(), " %[1]s -profile delivery\n", strings.Replace(flag.CommandLine.Name(), "e2ebench", "go run ./cmd/e2ebench", 1))
|
||||
fmt.Fprintf(flag.CommandLine.Output(), "\n # Keep a batch's rows and manifest, then audit its figures against them:\n")
|
||||
fmt.Fprintf(flag.CommandLine.Output(), " %[1]s -batch runs/today\n", strings.Replace(flag.CommandLine.Name(), "e2ebench", "go run ./cmd/e2ebench", 1))
|
||||
fmt.Fprintf(flag.CommandLine.Output(), " %[1]s -mode audit -batch runs/today\n\n", strings.Replace(flag.CommandLine.Name(), "e2ebench", "go run ./cmd/e2ebench", 1))
|
||||
fmt.Fprintf(flag.CommandLine.Output(), "Auditing is not replaying: -mode audit re-derives the report's headline figures\n"+
|
||||
"from results.jsonl and checks them against batch.json, naming every figure that moved.\n"+
|
||||
"It never re-executes the model, tools or environment, so it cannot show the experiment\n"+
|
||||
"reproduces. A real replay would need the commit, corpus fingerprint, model and provider\n"+
|
||||
"configuration, seed, flags, raw results and runtime version; it is not implemented.\n")
|
||||
}
|
||||
@@ -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**
|
||||
|
||||
@@ -121,3 +121,77 @@
|
||||
${EndIf}
|
||||
Pop $R9
|
||||
!macroend
|
||||
|
||||
# Closing a running copy before an install, an update or an uninstall. The
|
||||
# uninstaller runs from inside the install folder, so the process counting skips
|
||||
# this process by id: matching on the folder alone would find the uninstaller
|
||||
# itself and end it.
|
||||
!macro rxScript
|
||||
FileOpen $0 "$PLUGINSDIR\rxclose.ps1" w
|
||||
FileWrite $0 "param([string]$$Dir, [string]$$Mode, [int]$$Self)$\r$\n"
|
||||
FileWrite $0 "$$names = @('${APP_EXECUTABLE_FILENAME}', 'reasonix-studio-host.exe', 'cua-driver.exe')$\r$\n"
|
||||
FileWrite $0 "$$hit = @(Get-CimInstance -ClassName Win32_Process | ? { $$_.ProcessId -ne $$Self -and (($$_.Path -and $$_.Path.StartsWith($$Dir, 'CurrentCultureIgnoreCase')) -or (-not $$_.Path -and $$names -contains $$_.Name)) })$\r$\n"
|
||||
FileWrite $0 "if ($$Mode -eq 'find') { if ($$hit.Count -gt 0) { exit 0 } else { exit 1 } }$\r$\n"
|
||||
FileWrite $0 "$$hit | % { Stop-Process -Id $$_.ProcessId -Force -ErrorAction SilentlyContinue }$\r$\n"
|
||||
FileClose $0
|
||||
!macroend
|
||||
|
||||
!macro rxFind
|
||||
nsExec::Exec `"$SYSDIR\WindowsPowerShell\v1.0\powershell.exe" -NoProfile -NonInteractive -ExecutionPolicy Bypass -File "$PLUGINSDIR\rxclose.ps1" "$INSTDIR" find $R2`
|
||||
Pop $R0
|
||||
!macroend
|
||||
|
||||
!macro rxKill
|
||||
nsExec::Exec `"$SYSDIR\WindowsPowerShell\v1.0\powershell.exe" -NoProfile -NonInteractive -ExecutionPolicy Bypass -File "$PLUGINSDIR\rxclose.ps1" "$INSTDIR" kill $R2`
|
||||
Pop $0
|
||||
!macroend
|
||||
|
||||
!macro rxKillElevated
|
||||
nsExec::Exec `"$SYSDIR\WindowsPowerShell\v1.0\powershell.exe" -NoProfile -NonInteractive -C "Start-Process -FilePath powershell -ArgumentList '-NoProfile','-ExecutionPolicy','Bypass','-File','$PLUGINSDIR\rxclose.ps1','$INSTDIR','kill','$R2' -Verb RunAs -Wait -WindowStyle Hidden"`
|
||||
Pop $0
|
||||
!macroend
|
||||
|
||||
!macro customCheckAppRunning
|
||||
InitPluginsDir
|
||||
System::Call 'kernel32::GetCurrentProcessId() i .R2'
|
||||
!insertmacro rxScript
|
||||
!insertmacro rxFind
|
||||
${if} $R0 == 0
|
||||
${ifNot} ${isUpdated}
|
||||
${andIfNot} ${Silent}
|
||||
DetailPrint "$(appClosing)"
|
||||
${endIf}
|
||||
StrCpy $R1 0
|
||||
rxCloseLoop:
|
||||
IntOp $R1 $R1 + 1
|
||||
!insertmacro rxKill
|
||||
Sleep 1000
|
||||
!insertmacro rxFind
|
||||
${if} $R0 != 0
|
||||
Goto rxClosed
|
||||
${endIf}
|
||||
${if} $R1 == 4
|
||||
${andIfNot} ${Silent}
|
||||
DetailPrint "Asking Windows for permission to close the elevated copy of ${PRODUCT_NAME}..."
|
||||
!insertmacro rxKillElevated
|
||||
Sleep 1000
|
||||
!insertmacro rxFind
|
||||
${if} $R0 != 0
|
||||
Goto rxClosed
|
||||
${endIf}
|
||||
${endIf}
|
||||
${if} $R1 < 6
|
||||
Goto rxCloseLoop
|
||||
${endIf}
|
||||
StrCmp $LANGUAGE 2052 rxZh
|
||||
MessageBox MB_RETRYCANCEL|MB_ICONEXCLAMATION "${PRODUCT_NAME} is still running and the installer could not close it.$\r$\n$\r$\nRight-click its icon in the system tray (click the ^ if it is hidden) and choose Quit, or press Ctrl+Shift+Esc, end every process named $\"${PRODUCT_NAME}$\" (run Task Manager as administrator if the app was started as administrator), then click Retry." /SD IDCANCEL IDRETRY rxRetry
|
||||
Quit
|
||||
rxZh:
|
||||
MessageBox MB_RETRYCANCEL|MB_ICONEXCLAMATION "${PRODUCT_NAME} 仍在运行,安装程序无法自动关闭它。$\r$\n$\r$\n请在任务栏右下角的托盘图标(看不到就点 ^)上右键,选择“退出”;或按 Ctrl+Shift+Esc 打开任务管理器,结束所有名为“${PRODUCT_NAME}”的进程(如果它是以管理员身份启动的,请用管理员身份打开任务管理器),然后点“重试”。" /SD IDCANCEL IDRETRY rxRetry
|
||||
Quit
|
||||
rxRetry:
|
||||
StrCpy $R1 0
|
||||
Goto rxCloseLoop
|
||||
rxClosed:
|
||||
${endIf}
|
||||
!macroend
|
||||
@@ -16,6 +16,7 @@
|
||||
"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",
|
||||
"browser-fit-pages": "electron test/browser_fit_pages.js",
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -16,6 +16,17 @@ reviewed: 2026-09-22
|
||||
- An unsent draft takes precedence while the page is open.
|
||||
- No feedback body or screenshots are persisted by this preference.
|
||||
|
||||
## Pane workspace metadata
|
||||
|
||||
- Each pane owns one workspace-metadata read after its initial status settles.
|
||||
Chrome and the composer's workspace chip consume that same result.
|
||||
- A failed initial status still allows the read using the runtime's known root;
|
||||
history loads independently of both status and workspace metadata.
|
||||
- Changing the pane's port or directory reads again and discards superseded
|
||||
responses. Focusing an already-open pane reuses its own result.
|
||||
- Opening the workspace chooser still refreshes its branches and worktree
|
||||
sources; it does not issue another workspace-metadata read.
|
||||
|
||||
## Tests
|
||||
|
||||
`pnpm test` runs one worker per test file on the threads pool. Threads rather
|
||||
|
||||
@@ -39,9 +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 # 右栏:端点自己写的一句话有多长,都不该改变栏宽
|
||||
@@ -83,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每区会话数`,逗号分隔),
|
||||
@@ -99,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),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { chromium } from "playwright";
|
||||
|
||||
const browser = await chromium.launch();
|
||||
try {
|
||||
const page = await browser.newPage({ viewport: { width: 2400, height: 1200 }, locale: "en-US" });
|
||||
const errors = [];
|
||||
page.on("pageerror", (error) => errors.push(error.message));
|
||||
const url = new URL(process.env.PERF_URL ?? "http://localhost:4399/perf.html");
|
||||
url.searchParams.set("pref", "en");
|
||||
await page.goto(url.href, { waitUntil: "networkidle" });
|
||||
await page.locator(".compose textarea").waitFor();
|
||||
await page.evaluate(() => {
|
||||
window.__feed({ kind: "turn_started" });
|
||||
window.__feed({ kind: "text", text: "A populated conversation must remain readable when a custom width meets a narrow window." });
|
||||
window.__feed({ kind: "message" });
|
||||
window.__feed({ kind: "turn_done" });
|
||||
});
|
||||
await page.locator(".flow .out .txt").waitFor();
|
||||
await page.getByRole("button", { name: "Settings", exact: true }).click();
|
||||
await page.locator("#prefs-appearance").click();
|
||||
await page.getByRole("button", { name: "Custom percentage", exact: true }).click();
|
||||
const slider = page.getByRole("slider", { name: "Conversation width", exact: true });
|
||||
await slider.fill("67");
|
||||
await page.getByRole("button", { name: "Full width", exact: true }).click();
|
||||
await page.getByRole("button", { name: "Custom percentage", exact: true }).click();
|
||||
assert.equal(await slider.inputValue(), "67", "preset switching retains the custom percentage");
|
||||
await page.getByRole("button", { name: "Close Esc", exact: true }).click();
|
||||
await page.waitForFunction(() => {
|
||||
const pane = document.querySelector(".pane").clientWidth;
|
||||
return Math.abs(document.querySelector(".compose").getBoundingClientRect().width / pane - 0.67) < 0.01;
|
||||
});
|
||||
for (const selector of [".flow", ".compose"]) {
|
||||
const ratio = await page.locator(selector).evaluate((el) => el.getBoundingClientRect().width / document.querySelector(".pane").clientWidth);
|
||||
assert.ok(Math.abs(ratio - 0.67) < 0.01, `${selector} uses 67% of the conversation pane: ${ratio}`);
|
||||
}
|
||||
for (const [name, wideFlow, wideComposer] of [
|
||||
["Full width", true, false], ["Full width, composer too", true, true], ["Reading", false, false],
|
||||
]) {
|
||||
await page.getByRole("button", { name: "Settings", exact: true }).click();
|
||||
await page.locator("#prefs-appearance").click();
|
||||
await page.getByRole("button", { name, exact: true }).click();
|
||||
await page.getByRole("button", { name: "Close Esc", exact: true }).click();
|
||||
await page.waitForFunction(([flow, composer]) => {
|
||||
const fits = (selector, wide) => {
|
||||
const width = document.querySelector(selector).getBoundingClientRect().width;
|
||||
return wide ? width > 1800 : Math.abs(width - 760) < 1;
|
||||
};
|
||||
return fits(".flow", flow) && fits(".compose", composer);
|
||||
}, [wideFlow, wideComposer]);
|
||||
}
|
||||
console.log("PASS existing presets: reading, wide transcript and wide composer keep their geometry");
|
||||
console.log("PASS 67%: transcript and composer resize together; presets retain the value");
|
||||
|
||||
await page.getByRole("button", { name: "Settings", exact: true }).click();
|
||||
await page.locator("#prefs-appearance").click();
|
||||
await page.getByRole("button", { name: "Custom percentage", exact: true }).click();
|
||||
await slider.fill("30");
|
||||
await page.getByRole("button", { name: "Close Esc", exact: true }).click();
|
||||
await page.setViewportSize({ width: 1366, height: 900 });
|
||||
await page.waitForFunction(() => document.querySelector(".compose").getBoundingClientRect().width < 350);
|
||||
const outside = await page.locator(".compose").evaluate((el) => {
|
||||
const box = el.getBoundingClientRect();
|
||||
return [...el.querySelectorAll("button")].filter((button) => {
|
||||
const rect = button.getBoundingClientRect();
|
||||
return button.checkVisibility({ checkOpacity: true, checkVisibilityCSS: true })
|
||||
&& rect.width > 0 && (rect.right > box.right + 1 || rect.left < box.left - 1);
|
||||
}).map((el) => el.textContent);
|
||||
});
|
||||
assert.deepEqual(outside, [], "toolbar actions stay within a 30% composer on a laptop");
|
||||
const workspace = page.locator(".compose .worktree-trigger").first();
|
||||
await workspace.click();
|
||||
assert.equal(await workspace.getAttribute("aria-expanded"), "true", "workspace configuration remains accessible in a 30% composer");
|
||||
await page.keyboard.press("Escape");
|
||||
console.log("PASS 30%: composer actions remain contained on a laptop");
|
||||
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await page.waitForFunction(() => document.querySelector(".flow").getBoundingClientRect().width > 300);
|
||||
for (const selector of [".flow", ".compose"]) {
|
||||
const width = await page.locator(selector).evaluate((el) => el.getBoundingClientRect().width);
|
||||
assert.ok(width > 300 && width <= 390, `${selector} uses the phone layout rather than 30%: ${width}`);
|
||||
}
|
||||
assert.equal(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth), true, "phone has no horizontal page overflow");
|
||||
await workspace.click();
|
||||
assert.equal(await workspace.getAttribute("aria-expanded"), "true", "workspace configuration remains accessible on a phone");
|
||||
await page.keyboard.press("Escape");
|
||||
assert.deepEqual(errors, []);
|
||||
console.log("PASS phone: populated conversation and composer use the available width");
|
||||
} 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,54 @@ 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);
|
||||
const tree = page.getByRole("tree", { name: "项目与会话", exact: true });
|
||||
const rows = tree.getByRole("treeitem");
|
||||
await rows.first().focus();
|
||||
await page.keyboard.press("End");
|
||||
const last = rows.last();
|
||||
check(`${scheme}: End reaches the final local or remote row`,
|
||||
await last.evaluate((row) => row === document.activeElement));
|
||||
const bottom = await last.evaluate((row) => {
|
||||
const scroller = row.closest(".scroll");
|
||||
const r = row.getBoundingClientRect(), s = scroller.getBoundingClientRect();
|
||||
return { top: scroller.scrollTop, height: scroller.clientHeight, visible: r.top >= s.top && r.bottom <= s.bottom };
|
||||
});
|
||||
check(`${scheme}: a long project list scrolls to its final rows`, bottom.top > bottom.height && bottom.visible);
|
||||
const addAtEnd = await add.evaluate((button) => {
|
||||
const r = button.getBoundingClientRect();
|
||||
return { top: r.top, reached: button.contains(document.elementFromPoint(r.left + r.width / 2, r.top + r.height / 2)) };
|
||||
});
|
||||
check(`${scheme}: project controls stay fixed and clickable while the tree scrolls`,
|
||||
addAtEnd.top === headerTop && addAtEnd.reached, JSON.stringify(addAtEnd));
|
||||
await page.keyboard.press("Home");
|
||||
check(`${scheme}: Home returns to the first project`,
|
||||
await rows.first().evaluate((row) => row === document.activeElement));
|
||||
await page.keyboard.press("Shift+F10");
|
||||
await page.getByRole("menu", { name: "项目操作", exact: true }).waitFor();
|
||||
await page.keyboard.press("Escape");
|
||||
await more.click();
|
||||
await page.getByRole("menuitem", { name: "选择本机会话", exact: true }).click();
|
||||
await page.getByRole("button", { name: "完成选择", exact: true }).click();
|
||||
check(`${scheme}: leaving batch selection returns focus to the fixed header`,
|
||||
await more.evaluate((button) => button === document.activeElement));
|
||||
await add.click();
|
||||
await dialog.waitFor();
|
||||
check(`${scheme}: the fixed add action retains local and remote project locations`,
|
||||
await dialog.getByRole("button", { name: "选择本机文件夹", exact: true }).isEnabled()
|
||||
&& await dialog.getByRole("button", { name: "gpu-box · SSH", exact: true }).isEnabled());
|
||||
await page.keyboard.press("Escape");
|
||||
await dialog.waitFor({ state: "hidden" });
|
||||
await page.close();
|
||||
}
|
||||
} finally {
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -108,6 +108,8 @@ export const ACTIONS: UIAction[] = [
|
||||
{ id: "appearance.live-stream-budget", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "appearance.session-quick-actions", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "appearance.new-answer-marker", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "appearance.plan-progress", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "appearance.composer-hint", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "appearance.graphics", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "window.global-shortcut", kind: "shell-native", target: "none", proof: "browser" },
|
||||
{ id: "appearance.session-tabs", kind: "view", target: "none", proof: "interaction" },
|
||||
@@ -124,6 +126,10 @@ export const ACTIONS: UIAction[] = [
|
||||
{ id: "workspace.worktree-toggle", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "workspace.search", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "session.filter", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "session.recent", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "session.recent-environment", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "session.recent-page", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "session.recent-retry", kind: "repeatable", target: "none", proof: "interaction" },
|
||||
{ id: "session.more", kind: "repeatable", target: "none", proof: "interaction" },
|
||||
{ id: "session.selection", kind: "view", target: "optional", proof: "interaction" },
|
||||
{ id: "session.batch-archive", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
|
||||
@@ -148,6 +154,7 @@ export const ACTIONS: UIAction[] = [
|
||||
{ id: "chrome.receipt-line", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "chrome.keep-awake", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "composer.send-shortcut", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "composer.running-message-intent", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "transcript.fold", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "transcript.fold-preset", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "transcript.fold-preview", kind: "view", target: "none", proof: "interaction" },
|
||||
@@ -510,6 +517,7 @@ export const ACTIONS: UIAction[] = [
|
||||
{ id: "versions.notes", kind: "repeatable", target: "none", proof: "interaction" },
|
||||
{ id: "versions.notes-retry", kind: "repeatable", target: "none", proof: "interaction" },
|
||||
{ id: "versions.notes-close", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "versions.full-installer", kind: "repeatable", target: "none", proof: "interaction" },
|
||||
{ id: "versions.notes-lang", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "shell.executor", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
|
||||
{ id: "shell.custom-path", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
|
||||
@@ -561,11 +569,7 @@ export const ACTIONS: UIAction[] = [
|
||||
// different capability from changing its opacity, so the four sliders share
|
||||
// one id and say which parameter on data-value.
|
||||
//
|
||||
// These stay apart on purpose. theme.activate installs a palette pack;
|
||||
// appearance.scheme is light, dark or follow-the-system. wallpaper.change
|
||||
// and wallpaper.remove decide which image there is; appearance.background is
|
||||
// how the image that is already there is drawn. And weight and contrast are
|
||||
// two different things a reader wants, however alike they look in CSS.
|
||||
// A pack supplies colours; the scheme decides when its light or dark set is used.
|
||||
{ id: "appearance.language", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
|
||||
{ id: "appearance.zoom", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
|
||||
{ id: "web-access.backend", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
|
||||
@@ -590,6 +594,7 @@ export const ACTIONS: UIAction[] = [
|
||||
// Kept where the window keeps them: these are this browser's, and no kernel
|
||||
// state moves.
|
||||
{ id: "appearance.scheme", kind: "view", target: "none", proof: "browser" },
|
||||
{ id: "appearance.schedule", kind: "view", target: "entity", proof: "browser" },
|
||||
{ id: "appearance.weight", kind: "view", target: "none", proof: "browser" },
|
||||
{ id: "appearance.contrast", kind: "view", target: "none", proof: "browser" },
|
||||
{ id: "appearance.effects", kind: "view", target: "none", proof: "browser" },
|
||||
|
||||
@@ -606,8 +606,10 @@ export const EN: Record<string, string> = {
|
||||
"读取代码、运行测试与修改文件均只在你选定的文件夹内进行。": "Reading code, running tests and editing files all happen inside the folder you choose.",
|
||||
"使用当前位置": "Stay where I am",
|
||||
"隔离": "Isolated",
|
||||
"原目录": "Local checkout",
|
||||
"隔离目录": "Worktree",
|
||||
"独立工作树": "Isolated worktree",
|
||||
"直接在已打开的项目文件夹中工作,与使用该目录的其他会话共享文件。": "Work directly in the opened project folder, sharing files with other sessions using it.",
|
||||
"在单独的 Git 工作目录中修改代码;端口、数据库等运行资源仍可能共享。": "Edit code in a separate Git working directory; ports, databases, and other runtime resources may still be shared.",
|
||||
"发送消息时创建独立工作树,基于所选分支的已提交版本,不包含未提交修改;端口、数据库等运行资源仍可能共享。": "Create an isolated worktree when you send the message, using the selected branch's committed version without uncommitted changes; ports, databases, and other runtime resources may still be shared.",
|
||||
"移除": "Remove",
|
||||
"删除": "Delete",
|
||||
"删除该会话": "Delete this session",
|
||||
@@ -788,7 +790,12 @@ export const EN: Record<string, string> = {
|
||||
"内置": "Built in",
|
||||
"第三方": "Third party",
|
||||
"{n} 个已装": "{n} installed",
|
||||
"跟随系统时会随系统主题切换;手动选择后将保持固定。": "Following the system switches with it; once you choose a theme yourself, it stays fixed.",
|
||||
"跟随系统时会随系统主题切换;浅色和深色保持固定;定时按本机时间每天切换。": "Follow the system switches with your OS; Light and Dark stay fixed; Scheduled switches daily using this device's clock.",
|
||||
"定时": "Scheduled",
|
||||
"深色开始时间": "Dark mode starts",
|
||||
"恢复浅色时间": "Light mode resumes",
|
||||
"每天按本机时间切换,可跨午夜;开始时进入深色,结束时恢复浅色。": "Switches daily in local time, including overnight: dark at the start, light at the end.",
|
||||
"开始和结束时间相同时全天使用浅色。": "Equal start and end times keep light mode all day.",
|
||||
|
||||
// ── 连接与来源 ───────────────────────────────────────────────────
|
||||
"添加模型来源": "Add a model source",
|
||||
@@ -1694,6 +1701,10 @@ export const EN: Record<string, string> = {
|
||||
"正文和输入框都跟随窗口宽度;输入框很宽时一行不易回到行首": "Prose and the composer both follow the window; a very wide composer makes a line harder to find the start of",
|
||||
"每行长度控制在一眼能回到行首的范围": "Keeps a line short enough that your eye finds the next one",
|
||||
"正文跟随窗口宽度,宽屏上不留两侧空白": "Prose follows the window, with nothing left empty at the sides",
|
||||
"自定义比例": "Custom percentage",
|
||||
"按比例调整对话和输入框占用的区域": "Set the conversation and composer width as a percentage",
|
||||
"对话区域宽度": "Conversation width",
|
||||
"自定义比例同时调整对话和输入框;窄窗口会优先保证可用空间。": "Custom percentage resizes both the conversation and composer; narrow windows prioritize usable space.",
|
||||
"行宽只管正文:代码、命令与工具输出始终占满可用宽度。": "Line width applies to prose only — code, commands and tool output always use the width available.",
|
||||
"决策模型": "Decision models",
|
||||
"为 system_one 工具配置辅助决策后端,不会替换当前对话的主模型。": "Configure auxiliary decision backends for the system_one tool without replacing the main chat model.",
|
||||
|
||||
@@ -59,13 +59,10 @@ export const EN_COMPOSER: Record<string, string> = {
|
||||
"创建已取消,任务尚未发送": "Creation was cancelled. The task has not been sent.",
|
||||
"创建等待超时,任务和附件已保留,可查看详情后重试": "Workspace creation timed out. The draft and attachments are retained. Check the details before retrying.",
|
||||
"配置工作目录": "Configure workspace",
|
||||
"独立 worktree · {branch}": "Isolated worktree · {branch}",
|
||||
"当前目录 · {branch}": "Current folder · {branch}",
|
||||
"新建 worktree": "New worktree",
|
||||
"独立 worktree": "Isolated worktree",
|
||||
"独立工作树 · {branch}": "Isolated worktree · {branch}",
|
||||
"当前目录": "Current folder",
|
||||
"此会话已绑定独立 worktree。后续任务继续使用此目录。": "This session is bound to an isolated worktree. Future tasks will use this folder.",
|
||||
"如需创建新的 worktree,请在源目录新建会话。需继续本会话时可直接切换已有分支。": "To create another worktree, start a new session in the source folder. You can switch existing branches to continue this session.",
|
||||
"使用当前目录": "Use current folder",
|
||||
"关闭工作目录配置": "Close workspace configuration",
|
||||
"发送消息时开始创建。": " Creation starts when you send the message.",
|
||||
"选择分支会立即切换,并影响共享当前目录的其他会话。": "Switching branches takes effect immediately and affects other sessions sharing this folder.",
|
||||
@@ -78,7 +75,6 @@ export const EN_COMPOSER: Record<string, string> = {
|
||||
"选择起始分支": "Choose source branch",
|
||||
"搜索分支": "Search branches",
|
||||
"搜索分支…": "Search branches…",
|
||||
"当前目录": "Current folder",
|
||||
"查看完整路径": "Show full path",
|
||||
"复制完整路径": "Copy full path",
|
||||
"复制操作编号": "Copy operation ID",
|
||||
@@ -108,6 +104,10 @@ export const EN_COMPOSER: Record<string, string> = {
|
||||
"停下": "Stop",
|
||||
"发送": "Send",
|
||||
"插话": "Steer",
|
||||
"排队发送": "Queue",
|
||||
"点按排队 · 回车换行": "Tap Queue · Enter for a new line",
|
||||
"{key} 排队 · Enter 换行": "{key} to queue · Enter for a new line",
|
||||
"Enter 排队 · Shift+Enter 换行": "Enter to queue · Shift+Enter for a new line",
|
||||
"正在停止…": "Stopping…",
|
||||
"正在发送…": "Sending…",
|
||||
"描述任务、问题或要改的内容…": "Describe a task, question, or change…",
|
||||
|
||||
@@ -102,6 +102,11 @@ export const EN_SETTINGS: Record<string, string> = {
|
||||
"开启(15 分钟后)": "On (after 15 minutes)",
|
||||
"保持打开": "Keep open",
|
||||
"发送快捷键": "Send shortcut",
|
||||
"运行中发送消息": "Messages sent while running",
|
||||
"选择运行中发送消息的默认方式。仅保存在此设备,不改变已排队的消息。": "Choose the default for messages sent during a run. Saved on this device only; messages already queued are unchanged.",
|
||||
"下个工具边界插话": "Steer at the next tool boundary",
|
||||
"整轮结束后发送": "Send after the whole run",
|
||||
"可在待发送队列中选择“立即发送”来停止当前回复并开始新一轮。暂停、编辑和取回仍然可用。": "Use Send now in the queue to stop the current response and start a new turn. Hold, edit, and take back remain available.",
|
||||
"用于任务输入、待发送消息编辑与历史消息重发。输入法确认候选词时不会发送。": "Applies to task input, editing queued messages, and resending past messages. Confirming an input-method candidate never sends.",
|
||||
"Enter 发送": "Enter to send",
|
||||
"{key} 发送": "{key} to send",
|
||||
@@ -185,7 +190,6 @@ export const EN_SETTINGS: Record<string, string> = {
|
||||
"搜索设置…": "Search settings…",
|
||||
"搜索设置": "Search settings",
|
||||
"没有匹配的设置": "No matching setting",
|
||||
"设置项:{n}": "Settings: {n}",
|
||||
"立即生效": "In effect now",
|
||||
"当前会话": "This session",
|
||||
"当前模型": "This model",
|
||||
@@ -1031,6 +1035,8 @@ export const EN_SETTINGS: Record<string, string> = {
|
||||
"会话显示": "Conversation display",
|
||||
"导航与侧栏": "Navigation and sidebar",
|
||||
"窗口与性能": "Window and performance",
|
||||
"位于项目文件夹内": "Inside a project folder",
|
||||
"位于项目文件夹 {path} 内,出于安全不从这里启动": "Inside the project folder {path}, so it is not started from here for safety",
|
||||
"运行环境": "Where it runs",
|
||||
"记录与用量": "What it remembers",
|
||||
"这台机器": "This machine",
|
||||
@@ -1150,6 +1156,11 @@ export const EN_SETTINGS: Record<string, string> = {
|
||||
"思考自动": "Thinking automatic",
|
||||
"会话折叠": "Transcript folding",
|
||||
"会话中各部分默认展开还是收起。失败的步骤与正在运行的操作可以单独处理;已手动展开或收起的块保持原样。": "Whether each part of a conversation starts open or folded. Failed steps and work still running can be handled on their own; a block you have already opened or closed stays as it is.",
|
||||
"收起时显示计划进度": "Show collapsed plan progress",
|
||||
"在收起的计划卡片下方显示进度条,随已完成步骤更新。默认关闭,仅保存在本机。": "Show a progress bar below the collapsed plan card, updated as steps complete. Off by default; saved only on this machine.",
|
||||
"输入框提示": "Composer hint",
|
||||
"在空白任务输入框中显示任务和排队提示。默认显示,仅保存在本机,不影响输入和发送。": "Show task and queue guidance in the empty composer. On by default; saved only on this machine. Does not affect typing or sending.",
|
||||
"计划进度": "Plan progress",
|
||||
"折叠预设": "Folding preset",
|
||||
"精简": "Compact",
|
||||
"详尽": "Detailed",
|
||||
|
||||
@@ -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)" —
|
||||
|
||||
@@ -101,6 +101,8 @@ export interface OpenWithTarget {
|
||||
id: string;
|
||||
name: string;
|
||||
available: boolean;
|
||||
why?: string;
|
||||
within?: string;
|
||||
}
|
||||
|
||||
export interface TreeWorkspace {
|
||||
@@ -109,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[];
|
||||
@@ -123,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[];
|
||||
}
|
||||
@@ -133,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;
|
||||
@@ -150,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);
|
||||
@@ -220,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
|
||||
@@ -473,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[];
|
||||
|
||||
@@ -94,10 +94,10 @@ export interface Appearance {
|
||||
textSize?: number;
|
||||
fontUi?: string;
|
||||
fontMono?: string;
|
||||
// How far prose may run: "standard" holds it to a reading line length,
|
||||
// "full" lets it use the window. Cards, commands and output are not
|
||||
// governed by it.
|
||||
// Custom width sizes the conversation and composer as a percentage of the pane.
|
||||
width?: string;
|
||||
widthPercent?: number;
|
||||
widthRange?: ZoomRange;
|
||||
wallpaper?: Wallpaper;
|
||||
}
|
||||
|
||||
|
||||
@@ -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: [] });
|
||||
|
||||
@@ -17,15 +17,17 @@ export class MockLook {
|
||||
|
||||
// What the kernel announces; the zoom test holds it equal to config.ZoomMin..ZoomMax.
|
||||
private static readonly ZOOM = { min: 0.8, max: 1.8, step: 0.05 };
|
||||
private static readonly WIDTH = { min: 30, max: 100, step: 1 };
|
||||
|
||||
async appearance(): Promise<Appearance> {
|
||||
return { ...this.look, zoomRange: MockLook.ZOOM };
|
||||
return { ...this.look, zoomRange: MockLook.ZOOM, widthPercent: this.look.widthPercent || 80, widthRange: MockLook.WIDTH };
|
||||
}
|
||||
|
||||
async saveAppearance(look: Appearance): Promise<Appearance> {
|
||||
const { min, max } = MockLook.ZOOM;
|
||||
const zoom = look.zoom ? Math.min(Math.max(look.zoom, min), max) : look.zoom;
|
||||
this.look = { ...look, zoom, wallpaper: this.look.wallpaper && { ...this.look.wallpaper, ...look.wallpaper } };
|
||||
const widthPercent = look.widthPercent ? Math.min(Math.max(Math.round(look.widthPercent), MockLook.WIDTH.min), MockLook.WIDTH.max) : this.look.widthPercent;
|
||||
this.look = { ...look, zoom, widthPercent, wallpaper: this.look.wallpaper && { ...this.look.wallpaper, ...look.wallpaper } };
|
||||
return this.appearance();
|
||||
}
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@ export class SseLook extends SseHttp {
|
||||
fontUi: look.fontUi ?? "",
|
||||
fontMono: look.fontMono ?? "",
|
||||
width: look.width ?? "",
|
||||
widthPercent: look.widthPercent ?? 0,
|
||||
opacity: look.wallpaper?.opacity ?? 0,
|
||||
dim: look.wallpaper?.dim ?? 0,
|
||||
focusX: look.wallpaper?.focusX ?? 0.5,
|
||||
|
||||
@@ -1,8 +1,65 @@
|
||||
// @vitest-environment jsdom
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import { liveGraceMinutes, LIVE_GRACE_CHOICES, onLiveGraceChange, onOpenSessionLimitChange, openSessionLimit, setLiveGraceMinutes, setOpenSessionLimit } 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", () => {
|
||||
expect(showsPlanProgress()).toBe(false);
|
||||
for (const raw of ["", "true", "off", "garbage"]) {
|
||||
localStorage.setItem("rx-plan-progress", raw);
|
||||
expect(showsPlanProgress()).toBe(false);
|
||||
}
|
||||
setShowsPlanProgress(true);
|
||||
expect(showsPlanProgress()).toBe(true);
|
||||
expect(localStorage.getItem("rx-plan-progress")).toBe("on");
|
||||
setShowsPlanProgress(false);
|
||||
expect(showsPlanProgress()).toBe(false);
|
||||
});
|
||||
|
||||
it("notifies mounted subscribers and removes them on unsubscribe", () => {
|
||||
const heard = vi.fn();
|
||||
const stop = onPlanProgressChange(heard);
|
||||
setShowsPlanProgress(true);
|
||||
stop();
|
||||
setShowsPlanProgress(false);
|
||||
expect(heard).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("stays usable with unavailable storage", () => {
|
||||
const get = vi.spyOn(Storage.prototype, "getItem").mockImplementation(() => { throw new Error("unavailable"); });
|
||||
const set = vi.spyOn(Storage.prototype, "setItem").mockImplementation(() => { throw new Error("unavailable"); });
|
||||
try {
|
||||
expect(showsPlanProgress()).toBe(false);
|
||||
expect(() => setShowsPlanProgress(true)).not.toThrow();
|
||||
expect(showsPlanProgress()).toBe(false);
|
||||
} finally {
|
||||
get.mockRestore();
|
||||
set.mockRestore();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("how long a finished session stays in the running list", () => {
|
||||
it("is off until this machine chooses otherwise", () => {
|
||||
|
||||
@@ -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 PLAN_PROGRESS_KEY = "rx-plan-progress";
|
||||
const planProgressListeners = new Set<() => void>();
|
||||
|
||||
export function showsPlanProgress(): boolean {
|
||||
try {
|
||||
return localStorage.getItem(PLAN_PROGRESS_KEY) === "on";
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function onPlanProgressChange(fn: () => void): () => void {
|
||||
planProgressListeners.add(fn);
|
||||
return () => {
|
||||
planProgressListeners.delete(fn);
|
||||
};
|
||||
}
|
||||
|
||||
export function setShowsPlanProgress(on: boolean): void {
|
||||
try {
|
||||
localStorage.setItem(PLAN_PROGRESS_KEY, on ? "on" : "off");
|
||||
} catch {
|
||||
/* unavailable storage keeps the default */
|
||||
}
|
||||
planProgressListeners.forEach((fn) => fn());
|
||||
}
|
||||
|
||||
// How long a session that finished its turn stays in the sidebar's running list,
|
||||
// in minutes. Off (0) unless this machine chose a value: leaving the list the
|
||||
// moment a turn ends is the long-standing behaviour, and keeping a row there is
|
||||
@@ -488,6 +542,40 @@ export function useSendShortcut(): SendShortcut {
|
||||
return useSyncExternalStore(subscribeSendShortcut, sendShortcut, sendShortcut);
|
||||
}
|
||||
|
||||
export type RunningMessageIntent = "steer" | "followup";
|
||||
const RUNNING_MESSAGE_KEY = "rx-running-message-intent";
|
||||
const runningMessageListeners = new Set<() => void>();
|
||||
let transientRunningMessage: RunningMessageIntent = "steer";
|
||||
let runningMessageStorageFailed = false;
|
||||
|
||||
export function runningMessageIntent(): RunningMessageIntent {
|
||||
if (runningMessageStorageFailed) return transientRunningMessage;
|
||||
try {
|
||||
return localStorage.getItem(RUNNING_MESSAGE_KEY) === "followup" ? "followup" : "steer";
|
||||
} catch {
|
||||
return transientRunningMessage;
|
||||
}
|
||||
}
|
||||
|
||||
export function setRunningMessageIntent(next: RunningMessageIntent): void {
|
||||
transientRunningMessage = next;
|
||||
try { localStorage.setItem(RUNNING_MESSAGE_KEY, next); runningMessageStorageFailed = false; } catch { runningMessageStorageFailed = true; }
|
||||
runningMessageListeners.forEach((fn) => fn());
|
||||
}
|
||||
|
||||
function subscribeRunningMessage(fn: () => void): () => void {
|
||||
runningMessageListeners.add(fn);
|
||||
const storage = (event: StorageEvent) => {
|
||||
if (event.key === RUNNING_MESSAGE_KEY || event.key === null) fn();
|
||||
};
|
||||
window.addEventListener("storage", storage);
|
||||
return () => { runningMessageListeners.delete(fn); window.removeEventListener("storage", storage); };
|
||||
}
|
||||
|
||||
export function useRunningMessageIntent(): RunningMessageIntent {
|
||||
return useSyncExternalStore(subscribeRunningMessage, runningMessageIntent, runningMessageIntent);
|
||||
}
|
||||
|
||||
// The sidebar remains the default session switcher. Tabs are a local display
|
||||
// choice and do not change which sessions stay open in the kernel.
|
||||
const SESSION_TABS_KEY = "rx-show-session-tabs";
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
export interface ThemeSchedule { start: string; end: string }
|
||||
|
||||
const KEY = "rx-theme-schedule";
|
||||
const DEFAULT: ThemeSchedule = { start: "20:00", end: "07:00" };
|
||||
const listeners = new Set<() => void>();
|
||||
let cached = DEFAULT;
|
||||
let lastRaw: string | null | undefined;
|
||||
let volatile: ThemeSchedule | null = null;
|
||||
|
||||
function valid(value: unknown): value is ThemeSchedule {
|
||||
if (!value || typeof value !== "object") return false;
|
||||
const { start, end } = value as ThemeSchedule;
|
||||
return typeof start === "string" && typeof end === "string" &&
|
||||
/^([01]\d|2[0-3]):[0-5]\d$/.test(start) && /^([01]\d|2[0-3]):[0-5]\d$/.test(end);
|
||||
}
|
||||
|
||||
export function themeSchedule(): ThemeSchedule {
|
||||
if (volatile) return volatile;
|
||||
try {
|
||||
const raw = localStorage.getItem(KEY);
|
||||
if (raw !== lastRaw) {
|
||||
lastRaw = raw;
|
||||
cached = DEFAULT;
|
||||
try {
|
||||
const value: unknown = JSON.parse(raw ?? "null");
|
||||
if (valid(value)) cached = { start: value.start, end: value.end };
|
||||
} catch { /* unreadable preferences keep the default */ }
|
||||
}
|
||||
return cached;
|
||||
} catch {
|
||||
return cached;
|
||||
}
|
||||
}
|
||||
|
||||
export function onThemeScheduleChange(fn: () => void): () => void {
|
||||
listeners.add(fn);
|
||||
return () => { listeners.delete(fn); };
|
||||
}
|
||||
|
||||
export function setThemeSchedule(next: ThemeSchedule): void {
|
||||
if (!valid(next)) return;
|
||||
cached = { start: next.start, end: next.end };
|
||||
try {
|
||||
lastRaw = JSON.stringify(cached);
|
||||
localStorage.setItem(KEY, lastRaw);
|
||||
volatile = null;
|
||||
} catch {
|
||||
volatile = cached;
|
||||
}
|
||||
listeners.forEach((fn) => fn());
|
||||
}
|
||||
@@ -21,11 +21,15 @@ and selected states, while long titles truncate within the remaining width.
|
||||
|
||||
## Project header actions
|
||||
|
||||
- The project header is a non-shrinking sibling of the tree scroller. Local and
|
||||
remote rows share that scroller; adding a project, folding all projects and
|
||||
entering batch selection remain reachable at either end of the list.
|
||||
- Batch selection returns focus to the header menu when it ends.
|
||||
- The open-project and more-actions buttons share `--overlay` hover fill and
|
||||
`--text` ink. The more-actions button retains both while its menu is open.
|
||||
Project-row actions keep their `--raised` fill.
|
||||
- `perf/project-header.mjs` checks hover parity, menu dismissal, keyboard focus
|
||||
and the folder picker in both schemes.
|
||||
- `perf/project-header.mjs` checks hover parity, menu dismissal, keyboard focus,
|
||||
the folder picker and a long mixed local/remote list in both schemes.
|
||||
|
||||
## Reset and the window
|
||||
|
||||
@@ -257,8 +261,8 @@ and selected states, while long titles truncate within the remaining width.
|
||||
back into scope, draws its tick and nothing else. `data-just-done` carries
|
||||
that event and `data-run="done"` carries the state; keeping them apart is
|
||||
what stops history from replaying as news.
|
||||
- Unread is a fact the kernel owns, drawn beside the run tick and never merged with it: an 8px dot at the row end, so titles keep their column and it cannot be read as the project's status dot, a semibold title, and a spoken "未读" on the dot.
|
||||
- In dark an unread row also takes a 4% tint, below hover and selection. In light it would sit one step from the selected row, so light has none. A remote row keeps its dot left of the delete button, and its turn count shifts left to make room.
|
||||
- Unread is a fact the kernel owns, separate from run state: a green 8px dot replaces the session's date until it is marked viewed, with a semibold title and a localized spoken "未读" on the dot. Local and remote rows use the same date slot; a missing timestamp does not hide an unread completion.
|
||||
- In dark an unread row also takes a 4% tint, below hover and selection. In light it would sit one step from the selected row, so light has none.
|
||||
- A folded project row sums the unread sessions under it into an outlined count among its action buttons, unlike the filled feedback badge, and drops it when the project opens. A pane tab carries a 6px dot; the tab strip is currently hidden by the Studio sheet. Nothing here animates.
|
||||
|
||||
## The queue
|
||||
@@ -457,7 +461,25 @@ and selected states, while long titles truncate within the remaining width.
|
||||
keeps the reading width, where the line being typed stays one glance. The
|
||||
full-all measure is the opt-in that moves `--column` with it, for a reader who
|
||||
wants the composer on the same edges as the transcript.
|
||||
- Below 680px both columns take the phone rule instead (see *Phones*).
|
||||
- Custom percentage sets `--doc`, `--column` and `--wide` from
|
||||
`--measure-width`: 30–100% of the conversation pane, with rail clearance.
|
||||
- Settings → Appearance → Size → Line width offers presets and a 1% slider,
|
||||
initially 80%. `desktop.appearance.width_percent` persists independently of
|
||||
`desktop.conversation_width`, so selecting a preset retains the custom value.
|
||||
- An unchanged effective percentage stays unset in the stored configuration.
|
||||
- Only custom mode shows the conversation-and-composer hint; presets retain
|
||||
the prose-width guidance for code, commands and tool output.
|
||||
- The composer is a `conversation` container as well as the pane: toolbar labels
|
||||
respond to the width of the composer, including a custom percentage, rather
|
||||
than assuming it fills the pane.
|
||||
- The default reading-width composer retains mode and workspace labels: labels
|
||||
retire at 720px of actual composer space rather than at the pane's old 780px.
|
||||
- The workspace control remains available even below 360px of composer space.
|
||||
Model and effort labels shrink together before the toolbar wraps below 280px;
|
||||
compacting labels must not remove access to workspace configuration.
|
||||
- Below 760px the transcript and composer take the phone width instead. The
|
||||
populated flow selector must retain enough specificity to override its
|
||||
percentage width, not just the empty conversation's rule.
|
||||
- Docking the agent's browser puts the pane on a grid, and the browser's column
|
||||
spans the body's row and the composer's. Inside the conversation column `100%`
|
||||
is that column, so the composer's own centring keeps it off the divider, and
|
||||
@@ -850,6 +872,9 @@ what exists.
|
||||
only content.
|
||||
- A very faint gilt line under the header says this is the window's own thing,
|
||||
not content.
|
||||
- The header's expand/restore and close buttons share a 32px hit-region height,
|
||||
matching the search control's minimum height. Inline-flex centres SVG and text
|
||||
content independently of font fallback; native window controls retain their own sizing.
|
||||
- The search entry sits beside the title. That position used to hold one
|
||||
explanation addressed to two of the three tiers; that sentence now lives on
|
||||
each settings block (`.apply`). Results are laid under the header without
|
||||
@@ -1601,7 +1626,7 @@ A sub-agent row, by the list's own width:
|
||||
- The title takes every pixel the row's visible content leaves it. The age sits against the row's right padding and the earlier-versions badge beside it; nothing is held back on the right for a control that is not showing.
|
||||
- The hover buttons (archive) take no width until the row is hovered or focused, then 24px plus 6px; on a device without hover they are always drawn.
|
||||
- Taking the room only while shown means the title shortens when the buttons appear, never before. Pinning stays an overlay at the left, so the pin never moves the title.
|
||||
- An unread dot is an overlay 11px from the right edge: an unread row keeps 22px after its age (or after its badge, 26px) for it, and the dot hides while the buttons are shown.
|
||||
- The unread dot takes the age's place in normal flow, before the versions or legacy badge. It stays visible beside hover actions without overlay spacing; clearing canonical unread restores the original age.
|
||||
- A legacy `margin-right: 44px` on the age made room for a delete button the rail has long since hidden; the rail's rule sets it to zero.
|
||||
- The current session reads as current in its fill and a 1px neutral outline, never in a state colour. The fill is a 13% text tint against hover's 4%.
|
||||
- The outline is drawn inside the border box (`outline-offset: -1px`): the row's transparent 2px left border would push an inset shadow off the edge.
|
||||
@@ -1622,6 +1647,10 @@ A sub-agent row, by the list's own width:
|
||||
the edited title; pending requests disable all editing and dismissal.
|
||||
- The native dialog owns focus containment and the backdrop. Its colours follow
|
||||
the active light or dark palette.
|
||||
- Typography follows the shared modal hierarchy: `--fs-title`/`--w-semi` for
|
||||
the heading and `--fs-ui`/`--w-reg` for hints, input, errors and actions.
|
||||
Interface size and weight preferences apply at every width; narrow screens
|
||||
retain the same type roles rather than introducing another font size.
|
||||
|
||||
## Card shell
|
||||
|
||||
@@ -1661,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.
|
||||
@@ -1713,3 +1743,9 @@ A sub-agent row, by the list's own width:
|
||||
- Thinking expansion and collapsed previews are independent display preferences. Folding presets change expansion only; manual disclosure choices keep their priority.
|
||||
- The optional preview occupies one line below the header, with a left rule and width-based ellipsis. It shows the opening text, including during streaming, rather than following the newest tokens.
|
||||
- The preview remains inside the disclosure summary so it is visible and clickable while collapsed. Expanded reasoning replaces it; the settings demonstration uses the same preview styling.
|
||||
|
||||
## Collapsed plan progress
|
||||
|
||||
- Appearance → Conversation display offers “Show collapsed plan progress,” off by default and persisted on this machine. Changing it immediately updates mounted plan cards.
|
||||
- A nonempty collapsed plan draws the expanded plan's `.prog2` track below its summary, using completed steps over total steps from `stepDone`. Pending and running steps do not count as complete.
|
||||
- Opening the card replaces the optional track with the existing expanded progress. Goal-only, empty and hidden cards have no progress track; collapse, Escape, outside dismissal and focus return remain unchanged.
|
||||
@@ -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 |
|
||||
@@ -335,6 +344,10 @@ pack states a shape, never a number the layout has to agree with.
|
||||
and fails on a size outside the scale.
|
||||
- `settings-type.test.ts` holds the stylesheet to tokens, so a literal size or
|
||||
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.
|
||||
|
||||
## Weight and contrast
|
||||
|
||||
|
||||
@@ -201,7 +201,7 @@
|
||||
.btn.sm { padding: 2.5px 8px; font-size: var(--fs-meta); }
|
||||
|
||||
.picker { position: relative; flex: none }
|
||||
.isolab { flex: none; white-space: nowrap; font: var(--w-med) var(--fs-micro) var(--mono); letter-spacing: var(--track-cap); color: var(--ok-ink); padding: 1.5px 6px; border-radius: var(--r-pill); background: color-mix(in srgb, var(--ok) 13%, transparent) }
|
||||
.isolab { flex: none; white-space: nowrap; font: var(--w-med) var(--fs-micro) var(--ui); letter-spacing: var(--track-cap); color: var(--muted); padding: 1.5px 6px; border-radius: var(--r-pill); background: var(--overlay) }
|
||||
.isolab[hidden] { display: none }
|
||||
|
||||
.hostlab { color: var(--net-ink); background: color-mix(in srgb, var(--net) 13%, transparent) }
|
||||
@@ -1731,7 +1731,7 @@
|
||||
.turntools > .picker:first-of-type .nm { flex: 1; min-width: 0 }
|
||||
.go { display: inline-flex; align-items: center; gap: 7px; flex: none }
|
||||
|
||||
.compose .row > * { position: relative; z-index: 1; animation: rowin var(--m-panel) both }
|
||||
.compose .row > * { position: relative; z-index: 8; animation: rowin var(--m-panel) both }
|
||||
@keyframes rowin { from { opacity: 0; transform: translateY(4px) } to { opacity: 1; transform: none } }
|
||||
|
||||
.compose .row [data-pending] { pointer-events: none; opacity: .5; transition: opacity var(--m-line) }
|
||||
@@ -2470,7 +2470,7 @@
|
||||
margin: 0; flex: 1; min-width: 0; font: var(--w-reg) var(--fs-ui) var(--ui); color: var(--faint);
|
||||
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
||||
}
|
||||
.prefs-hd .btn { flex: none; align-self: center }
|
||||
.prefs-hd > .btn { flex: none; align-self: center; height: 32px; display: inline-flex; align-items: center; justify-content: center }
|
||||
.prefs-hd .esc { margin-left: 5px; font: var(--w-reg) var(--fs-micro) var(--mono); color: var(--ghost) }
|
||||
|
||||
.prefs-body { flex: 1; display: grid; grid-template-columns: 240px minmax(0, 1fr); min-height: 0 }
|
||||
@@ -2533,33 +2533,6 @@
|
||||
|
||||
.prefs-main[data-sec="ext"] .prefs-col { max-width: 980px }
|
||||
|
||||
.prefs-intro {
|
||||
display: grid; grid-template-columns: auto minmax(0, 1fr) auto; align-items: center; gap: 13px;
|
||||
margin-bottom: 20px; padding: 0 2px 17px; border-bottom: 1px solid var(--hair);
|
||||
}
|
||||
|
||||
.prefs-intro-mark {
|
||||
display: grid; place-items: center; width: 34px; height: 34px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--r-md); background: var(--overlay); color: var(--muted);
|
||||
}
|
||||
.prefs-intro-mark svg {
|
||||
width: 17px; height: 17px; fill: none; stroke: currentColor; stroke-width: 1.45;
|
||||
stroke-linecap: round; stroke-linejoin: round;
|
||||
}
|
||||
.prefs-intro-copy { display: flex; flex-direction: column; gap: 2px; min-width: 0 }
|
||||
.prefs-intro-overline {
|
||||
font: var(--w-semi) var(--fs-micro) var(--ui); letter-spacing: .14em; text-transform: uppercase; color: var(--faint);
|
||||
}
|
||||
.prefs-intro h2 { margin: 0; font: var(--w-semi) var(--fs-title)/1.25 var(--ui); color: var(--text); letter-spacing: -.01em }
|
||||
.prefs-intro-meta { display: flex; flex-direction: column; align-items: flex-end; gap: 3px; min-width: 0 }
|
||||
.prefs-intro-meta strong {
|
||||
max-width: 210px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
||||
font: var(--w-med) var(--fs-ui) var(--mono); color: var(--muted);
|
||||
}
|
||||
.prefs-intro-meta > span { font: var(--w-reg) var(--fs-meta) var(--ui); color: var(--ghost) }
|
||||
.prefs-intro-meta[data-danger] strong { color: var(--err-ink) }
|
||||
|
||||
.grp {
|
||||
margin-bottom: 16px; padding: 19px 20px 17px; outline: 3px solid transparent; outline-offset: 2px;
|
||||
border-radius: var(--r-md); background: var(--surface);
|
||||
@@ -2659,11 +2632,6 @@
|
||||
.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) }
|
||||
.prefs-intro { grid-template-columns: auto minmax(0, 1fr); gap: 11px; margin-bottom: 16px }
|
||||
.prefs-intro-meta {
|
||||
grid-column: 2; flex-direction: row; align-items: baseline; justify-content: space-between;
|
||||
}
|
||||
.prefs-intro-meta strong { max-width: 60% }
|
||||
.grp { padding: 17px 15px 15px }
|
||||
.advset { padding-inline: 15px }
|
||||
}
|
||||
@@ -5059,6 +5027,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);
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const SHEETS = import.meta.glob(["./app.css", "./studio.css"], { query: "?raw", import: "default", eager: true }) as Record<string, string>;
|
||||
const layer = (sheet: string, selector: string) => {
|
||||
const esc = selector.replace(/[.*+?^${}()|[\]\\>]/g, "\\$&");
|
||||
const body = SHEETS[sheet].match(new RegExp(`(?:^|\\n)\\s*${esc}\\s*\\{([^}]*)\\}`))?.[1] ?? "";
|
||||
return Number(body.match(/z-index:\s*(\d+)/)?.[1]);
|
||||
};
|
||||
|
||||
describe("composer layers", () => {
|
||||
it("keeps the controls row, and the menus it opens, above the plan bar that floats over it", () => {
|
||||
expect(layer("./app.css", ".compose .row > *")).toBeGreaterThan(layer("./studio.css", ".studio-todo"));
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,3 @@
|
||||
.prefs-main[data-sec="persona"]:has(.preset-workspace) .prefs-intro { display: none; }
|
||||
.prefs-main[data-sec="persona"]:has(.preset-page) { padding: 0; overflow: hidden; }
|
||||
.prefs-col:has(.preset-page) { height: 100%; max-width: none; animation: none; }
|
||||
.preset-workspace[data-editor] { height: 100%; min-height: 0; }
|
||||
|
||||
@@ -35,7 +35,7 @@ const MARKS: Record<string, string> = {
|
||||
// change per call site rather than per rule — recorded so the count cannot grow
|
||||
// while the work is outstanding. Never widen this to land a change.
|
||||
const MIXED = new Set([
|
||||
"c", "cnt", "cost", "cur", "d", "delta", "dt", "fail", "isolab", "k",
|
||||
"c", "cnt", "cost", "cur", "d", "delta", "dt", "fail", "k",
|
||||
"lb", "meta", "mk", "n", "nm", "ntimes", "path", "pill", "pt", "r", "rmtcand", "rt",
|
||||
"sc", "st", "sym", "sz", "tag", "u", "uspan", "v",
|
||||
]);
|
||||
|
||||
@@ -5,13 +5,19 @@
|
||||
.studio-model-control { flex: 0 1 auto; min-width: 0; }
|
||||
.studio-model-control .model-picker .nm { max-width: none; }
|
||||
}
|
||||
@container conversation (max-width: 360px) {
|
||||
.studio-effort-control { flex: 0 1 auto; }
|
||||
.studio-effort-control > .picker { min-width: 0; max-width: 100%; }
|
||||
.studio-effort-picker { min-width: 0; max-width: 100%; }
|
||||
.studio-effort-picker > span { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
}
|
||||
.chrome .r-fold { display: contents; }
|
||||
.chrome .crumb-context { display: flex; align-items: center; gap: 8px; min-width: 0; overflow: hidden; }
|
||||
.chrome .crumb-context .isolab { flex: 0 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; }
|
||||
[data-fold~="bar"] .chrome .r-fold { display: none; position: absolute; top: calc(100% + 6px); right: 12px; z-index: 80; flex-wrap: wrap; gap: 5px; padding: 6px; border: 1px solid var(--border); border-radius: 10px; background: var(--raised); box-shadow: 0 12px 32px -14px #000; }
|
||||
[data-fold~="bar"] .chrome .r-fold[data-open] { display: flex; }
|
||||
[data-fold~="bar"] .tab { flex: 0 1 auto; min-width: 0; padding-inline: 6px; overflow: hidden; text-overflow: ellipsis; }
|
||||
@container conversation (max-width: 360px) {
|
||||
@container conversation (max-width: 280px) {
|
||||
.turntools { flex-wrap: wrap; }
|
||||
.studio-effort-picker::before { display: none; }
|
||||
.studio-model-group { flex-basis: 100%; flex-wrap: wrap; }
|
||||
|
||||
@@ -8,12 +8,12 @@
|
||||
background: var(--surface);
|
||||
color: var(--text);
|
||||
box-shadow: var(--shadow-float);
|
||||
font-family: var(--ui);
|
||||
font: var(--w-reg) var(--fs-ui)/1.55 var(--ui);
|
||||
}
|
||||
.session-rename-dialog::backdrop { background: rgb(0 0 0 / .28); }
|
||||
.session-rename-dialog header { display: flex; align-items: center; justify-content: space-between; gap: 16px; }
|
||||
.session-rename-dialog h2 { margin: 0; font-size: calc(24px * var(--fs-scale, 1)); font-weight: 650; line-height: 1.3; }
|
||||
.session-rename-dialog p { margin: 5px 0 18px; color: var(--muted); font-size: calc(16px * var(--fs-scale, 1)); }
|
||||
.session-rename-dialog h2 { margin: 0; font: var(--w-semi) var(--fs-title)/1.3 var(--ui); }
|
||||
.session-rename-dialog p { margin: 5px 0 18px; color: var(--muted); }
|
||||
.session-rename-dialog input {
|
||||
display: block;
|
||||
box-sizing: border-box;
|
||||
@@ -25,7 +25,6 @@
|
||||
background: var(--raised);
|
||||
color: var(--text);
|
||||
font: inherit;
|
||||
font-size: calc(16px * var(--fs-scale, 1));
|
||||
}
|
||||
.session-rename-dialog footer { display: flex; align-items: center; gap: 12px; margin-top: 16px; }
|
||||
.session-rename-dialog button {
|
||||
@@ -36,18 +35,17 @@
|
||||
background: var(--raised);
|
||||
color: var(--text);
|
||||
font: inherit;
|
||||
font-size: calc(16px * var(--fs-scale, 1));
|
||||
cursor: pointer;
|
||||
}
|
||||
.session-rename-dialog .session-rename-auto { margin-right: auto; }
|
||||
.session-rename-dialog .session-rename-save { border-color: var(--text); background: var(--text); color: var(--surface); }
|
||||
.session-rename-dialog .session-rename-close { display: grid; place-items: center; min-height: 28px; width: 28px; padding: 0; border: 0; background: transparent; }
|
||||
.session-rename-dialog .session-rename-close svg { width: 16px; height: 16px; }
|
||||
.session-rename-dialog .session-rename-error { margin: 12px 0 0; color: var(--err); font-size: calc(14px * var(--fs-scale, 1)); }
|
||||
.session-rename-dialog .session-rename-error { margin: 12px 0 0; color: var(--err); }
|
||||
.session-rename-dialog button:disabled { opacity: .5; cursor: default; }
|
||||
.session-rename-dialog :focus-visible { outline: 2px solid var(--focus); outline-offset: 2px; }
|
||||
@media (max-width: 400px) {
|
||||
.session-rename-dialog { padding: 20px; }
|
||||
.session-rename-dialog footer { gap: 8px; }
|
||||
.session-rename-dialog button { padding: 0 12px; font-size: calc(14px * var(--fs-scale, 1)); }
|
||||
.session-rename-dialog button { padding: 0 12px; }
|
||||
}
|
||||
@@ -30,7 +30,6 @@
|
||||
.rail .sessrow:has(> .sessedit) > :is(.sesspin-action, .sesspin-marker, .sessquick) { visibility: hidden; pointer-events: none; }
|
||||
.rail .sessrow:has(.sesspin-action):is(:hover, :focus-within)::before,
|
||||
.rail .sessrow:has(.sesspin-action):is(:hover, :focus-within) > .pip { visibility: hidden; }
|
||||
.rail .sessrow:has(.sessquick):is(:hover, :focus-within) > .unread-dot { visibility: hidden; }
|
||||
@media (hover: none) {
|
||||
.rail .sessquick { width: 24px; margin-inline-end: 6px; opacity: 1; pointer-events: auto; }
|
||||
.rail .sesspin-action { opacity: 1; pointer-events: auto; }
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
.prefs-col :is(strong, b, h4, th, .nm, .who, .lbl, .kind, .me-name, .spon-link, .paperpick, .rmtadd, .uhero-scope, .ttl, .comm-name, .btn:not(.sm)) { font-size: var(--fs-ui); font-weight: var(--w-semi); color: var(--text); }
|
||||
.prefs-col .grp-hd h3 { font-size: var(--fs-lead); font-weight: var(--w-semi); color: var(--text); }
|
||||
.prefs-col :is(button.act, label.act, .btn.sm) { font-size: var(--fs-meta); font-weight: var(--w-reg); color: var(--text); }
|
||||
.prefs-col :is(.prefs-intro-overline, .k, .uhero-k, .uhero-estk, .composition-builtin-badge) { font-size: var(--fs-meta); font-weight: var(--w-reg); color: var(--muted); }
|
||||
.prefs-col :is(.k, .uhero-k, .uhero-estk, .composition-builtin-badge) { font-size: var(--fs-meta); font-weight: var(--w-reg); color: var(--muted); }
|
||||
.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); }
|
||||
@@ -18,6 +18,11 @@ it("keeps the settings scope on two neutral text tiers and one emphasis weight",
|
||||
expect(scope).toMatch(/--w-med:\s*var\(--w-semi\)/);
|
||||
});
|
||||
|
||||
it("only ever names a neutral text colour from the two tiers", () => {
|
||||
for (const d of declarations.filter((x) => x.prop === "color")) expect(d.value).toMatch(/^var\(--(text|muted)\)$/);
|
||||
it("only ever names a neutral text colour from the two tiers, bar a primary action's paired foreground", () => {
|
||||
for (const d of declarations.filter((x) => x.prop === "color" && x.value !== "var(--accent-fg)")) expect(d.value).toMatch(/^var\(--(text|muted)\)$/);
|
||||
});
|
||||
|
||||
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\)/);
|
||||
});
|
||||
@@ -871,7 +871,7 @@ body { background: var(--page) !important; }
|
||||
.activity-group { --fs-scale: var(--activity-scale, 1); margin: var(--sp-4) 0 0; overflow: clip; }
|
||||
.activity-group > summary {
|
||||
min-height: 28px; padding: 0; display: flex; align-items: center; gap: var(--sp-3);
|
||||
color: var(--faint); font-size: calc(11px * var(--fs-scale, 1)); font-weight: 500; line-height: 1.5;
|
||||
color: var(--faint); font-size: var(--fs-meta); font-weight: 500; line-height: 1.5;
|
||||
cursor: pointer; user-select: none;
|
||||
}
|
||||
.activity-group > summary:hover { color: var(--muted); }
|
||||
@@ -894,11 +894,11 @@ body { background: var(--page) !important; }
|
||||
border-radius: 4px;
|
||||
color: var(--net);
|
||||
background: var(--net-wash);
|
||||
font: 500 calc(11px * var(--fs-scale, 1))/1.5 var(--ui);
|
||||
font: 500 var(--fs-meta)/1.5 var(--ui);
|
||||
}
|
||||
.activity-title { color: inherit; font: 500 calc(11px * var(--fs-scale, 1))/1.5 var(--ui); }
|
||||
.activity-errors { color: var(--err-ink); font: 500 calc(11px * var(--fs-scale, 1))/1.5 var(--ui); }
|
||||
.activity-count { margin-left: 0; color: var(--faint); font: 500 calc(11px * var(--fs-scale, 1))/1.5 var(--ui); }
|
||||
.activity-title { color: inherit; font: 500 var(--fs-meta)/1.5 var(--ui); }
|
||||
.activity-errors { color: var(--err-ink); font: 500 var(--fs-meta)/1.5 var(--ui); }
|
||||
.activity-count { margin-left: 0; color: var(--faint); font: 500 var(--fs-meta)/1.5 var(--ui); }
|
||||
.activity-fold-icon { margin-left: 2px; color: var(--faint); transition: transform 140ms ease; }
|
||||
.activity-group[open] .activity-fold-icon { transform: rotate(180deg); }
|
||||
.activity-group[open] > summary { position: sticky; top: 0; z-index: 1; }
|
||||
@@ -1683,7 +1683,7 @@ body { background: var(--page) !important; }
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.studio-brand-sub { display: none; }
|
||||
.flow { width: calc(100% - 20px); margin-inline: auto; padding-inline: 0; }
|
||||
.flow, .flow:not([data-empty]) { width: calc(100% - 20px); margin-inline: auto; padding-inline: 0; }
|
||||
.compose { width: calc(100% - 20px); margin-inline: auto; }
|
||||
}
|
||||
|
||||
@@ -1928,11 +1928,7 @@ button, input, select, textarea { font-size: calc(13.3333px * var(--fs-scale, 1)
|
||||
}
|
||||
.rail .sessrow[data-run="done"] { animation: none; }
|
||||
.rail .sessrow[data-just-done] { animation: studio-session-done 1.2s ease-out both; }
|
||||
.rail .sessrow > .unread-dot { position: absolute; inset-inline-end: 11px; top: 50%; translate: 0 -50%; width: 8px; height: 8px; border-radius: 50%; background: var(--accent-ink); pointer-events: none; }
|
||||
.rail .sessrow[data-unread] > .sesscopies { margin-inline-end: 26px; }
|
||||
.rail .sessrow-context[data-unread] > .sesstitle { padding-inline-end: 26px; }
|
||||
.rail .sessrow-remote > .unread-dot { inset-inline-end: 36px; }
|
||||
.rail .sessrow-remote[data-unread] > .sessmeta { margin-inline-end: 58px; }
|
||||
.rail .sessrow > .unread-dot { flex: none; margin-inline-start: auto; width: 8px; height: 8px; border-radius: 50%; background: var(--ok); pointer-events: none; }
|
||||
.rail .sessrow[data-unread] .sesstitle { color: var(--text); font-weight: var(--w-semi); }
|
||||
:root[data-theme="dark"] .rail .sessrow[data-unread]:not(:hover):not([aria-selected="true"]) { background: color-mix(in srgb, var(--text) 4%, transparent); }
|
||||
@media (prefers-color-scheme: dark) { :root:not([data-theme="light"]) .rail .sessrow[data-unread]:not(:hover):not([aria-selected="true"]) { background: color-mix(in srgb, var(--text) 4%, transparent); } }
|
||||
@@ -1940,7 +1936,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;
|
||||
@@ -2042,7 +2037,7 @@ button, input, select, textarea { font-size: calc(13.3333px * var(--fs-scale, 1)
|
||||
.studio-todo-sum > span { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.studio-todo-sum small { flex: none; color: var(--faint); font: 600 calc(9px * var(--fs-scale, 1))/1 var(--mono); font-variant-numeric: tabular-nums; }
|
||||
.studio-todo-fold { transition: transform var(--m-line) }
|
||||
.studio-todo[data-open] .studio-todo-fold { transform: rotate(180deg) }
|
||||
.studio-todo[data-open] .studio-todo-fold { transform: rotate(180deg) } .studio-todo-progress { padding: 0 12px 8px; }
|
||||
.studio-todo-body {
|
||||
position: absolute; left: -1px; right: -1px; bottom: calc(100% + 6px); z-index: 8; max-height: min(46vh, 380px); overflow-y: auto; overscroll-behavior: contain;
|
||||
padding: 10px 12px; border: 1px solid var(--border); border-radius: 12px; background: var(--surface); box-shadow: var(--shadow-float); animation: todo-in var(--m-card) var(--ease-out) both;
|
||||
@@ -2384,14 +2379,12 @@ button, input, select, textarea { font-size: calc(13.3333px * var(--fs-scale, 1)
|
||||
.compose .policy > .mode { min-height: 32px; padding-inline: 7px; font-size: calc(10px * var(--fs-scale, 1)); }
|
||||
.btn.send { width: 32px; height: 32px; }
|
||||
|
||||
/* The composer adapts to its conversation column, including when Browser is
|
||||
open. Retire labels by priority before allowing controls to wrap. */
|
||||
.pane { container: conversation / inline-size; }
|
||||
.pane, .compose { container: conversation / inline-size; }
|
||||
.turntools { min-width: 0; min-height: 32px; align-items: center; }
|
||||
.studio-model-control .model-picker { width: auto; max-width: 100%; }
|
||||
.studio-model-control .model-picker .nm { min-width: 0; }
|
||||
|
||||
@container conversation (max-width: 780px) {
|
||||
@container conversation (max-width: 720px) {
|
||||
.studio-branch-pop .studio-branch { width: 30px; justify-content: center; padding-inline: 0; }
|
||||
.studio-branch-pop .studio-branch .lb,
|
||||
.studio-branch-pop .studio-branch small { display: none; }
|
||||
@@ -2430,7 +2423,6 @@ button, input, select, textarea { font-size: calc(13.3333px * var(--fs-scale, 1)
|
||||
}
|
||||
|
||||
@container conversation (max-width: 360px) {
|
||||
.studio-branch-pop { display: none; }
|
||||
.studio-meterrail .studio-meter-tariff i,
|
||||
.studio-meterrail .studio-meter-cost,
|
||||
.studio-meterrail .studio-meter-mask { display: none; }
|
||||
@@ -3356,8 +3348,6 @@ button, input, select, textarea { font-size: calc(13.3333px * var(--fs-scale, 1)
|
||||
background: color-mix(in srgb, var(--page) 82%, var(--float)) !important;
|
||||
scrollbar-color: color-mix(in srgb, var(--muted) 34%, transparent) transparent;
|
||||
}
|
||||
.prefs-intro { border-color: var(--hair); }
|
||||
.prefs-intro-mark { background: var(--accent-wash); border-color: var(--hair); color: var(--accent); }
|
||||
.grp,
|
||||
.advset,
|
||||
.setting {
|
||||
@@ -3585,7 +3575,6 @@ button, input, select, textarea { font-size: calc(13.3333px * var(--fs-scale, 1)
|
||||
.prefs {
|
||||
--prefs-body: var(--fs-ui);
|
||||
--prefs-meta: var(--fs-meta);
|
||||
--prefs-title: var(--fs-title);
|
||||
font-family: var(--ui);
|
||||
font-size: var(--prefs-body);
|
||||
}
|
||||
@@ -3593,17 +3582,13 @@ button, input, select, textarea { font-size: calc(13.3333px * var(--fs-scale, 1)
|
||||
.prefs-find,
|
||||
.prefs-nav button,
|
||||
.prefs-found .what { font-size: var(--fs-ui); }
|
||||
.prefs-nav .navsec,
|
||||
.prefs-intro-overline {
|
||||
.prefs-nav .navsec {
|
||||
font-size: calc(11.5px * var(--fs-scale, 1));
|
||||
letter-spacing: .08em;
|
||||
text-transform: none;
|
||||
}
|
||||
.prefs-nav .nv,
|
||||
.prefs-intro-meta > span,
|
||||
.apply { font-size: var(--prefs-meta); }
|
||||
.prefs-intro h2 { font-size: var(--prefs-title); }
|
||||
.prefs-intro-meta strong { font-family: var(--ui); font-size: var(--fs-ui); }
|
||||
.grp > .grp-hd h3, .grp > h3.grp-hd { font-size: var(--fs-lead); }
|
||||
.grp .hint,
|
||||
.grp .note,
|
||||
|
||||
@@ -216,6 +216,7 @@
|
||||
}
|
||||
:root[data-measure="full"], :root[data-measure="full-all"] { --doc: 100%; --wide: 100% }
|
||||
:root[data-measure="full-all"] { --column: 100% }
|
||||
:root[data-measure="custom"] { --doc: var(--measure-width); --column: var(--measure-width); --wide: var(--measure-width) }
|
||||
|
||||
:root[data-weight="heavy"] { --w-reg: 500; --w-med: 600; --w-semi: 700 }
|
||||
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
.projects-header { margin: 8px 15px 6px; }
|
||||
.projects-header { flex: none; margin: 8px 15px 6px; }
|
||||
.projects-heading { display: flex; align-items: center; justify-content: space-between; min-height: 28px; padding-inline: 7px; color: var(--faint); font: var(--w-med) var(--fs-meta)/1.4 var(--ui); }
|
||||
.projects-heading-actions { display: flex; align-items: center; gap: 2px; }
|
||||
.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; }
|
||||
@@ -47,8 +48,7 @@
|
||||
.rail .rmtws-node .sessdel { opacity: 0; }
|
||||
.rail .rmtws-node .sessrow:is(:hover, :focus-within) .sessdel { opacity: 1; }
|
||||
.rail .sessage { margin-left: auto; margin-right: 0; font-size: var(--fs-micro); }
|
||||
.rail .sessage + .sesscopies { margin-inline-start: 6px; }
|
||||
.rail .sessrow[data-unread]:not(:has(> .sesscopies)) > .sessage { margin-inline-end: 22px; }
|
||||
.rail :is(.sessage, .unread-dot) + .sesscopies { margin-inline-start: 6px; }
|
||||
.rail .sessmore { width: calc(100% - 12px); margin: 2px 0 5px 3px; padding-left: 9px; text-align: left; }
|
||||
.rail .sessrow:hover { background: color-mix(in srgb, var(--text) 4%, transparent); }
|
||||
.rail .sessrow[aria-selected="true"] { background: color-mix(in srgb, var(--text) 13%, transparent); color: var(--text); font-weight: var(--w-reg); box-shadow: none; }
|
||||
@@ -64,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); }
|
||||
@@ -8,11 +8,12 @@ const rule = (selector: string) => {
|
||||
};
|
||||
|
||||
describe("the unread mark in the rail", () => {
|
||||
it("sits at the row end without moving the title", () => {
|
||||
it("uses a green mark in the date slot without overlaying the title or quick actions", () => {
|
||||
const dot = rule(".rail .sessrow > .unread-dot");
|
||||
expect(dot).toMatch(/position:\s*absolute/);
|
||||
expect(dot).toMatch(/inset-inline-end/);
|
||||
expect(dot).not.toMatch(/margin/);
|
||||
expect(dot).toMatch(/margin-inline-start:\s*auto/);
|
||||
expect(dot).toMatch(/flex:\s*none/);
|
||||
expect(dot).toMatch(/background:\s*var\(--ok\)/);
|
||||
expect(dot).not.toMatch(/position:\s*absolute|inset-inline-end/);
|
||||
expect(rule(".rail .sessrow[data-unread] .sesstitle")).not.toMatch(/padding-inline-start|margin/);
|
||||
});
|
||||
|
||||
@@ -22,12 +23,6 @@ describe("the unread mark in the rail", () => {
|
||||
expect(dark).toMatch(/background:\s*color-mix\(in srgb, var\(--text\) 4%, transparent\)/);
|
||||
});
|
||||
|
||||
it("keeps the dot clear of the remote row's delete button", () => {
|
||||
const more = 27 + 4;
|
||||
const end = Number(rule(".rail .sessrow-remote > .unread-dot").match(/inset-inline-end:\s*(\d+)px/)?.[1]);
|
||||
expect(end).toBeGreaterThanOrEqual(more + 4);
|
||||
});
|
||||
|
||||
it("draws the folded count unlike the feedback badge", () => {
|
||||
const pill = rule(".rail .wsacts > .unread-count");
|
||||
expect(pill).toMatch(/border:\s*1px solid/);
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
.worktree-setup-context { display: flex; align-items: center; gap: 10px; min-width: 0; margin-bottom: 24px }
|
||||
.worktree-setup-context > .studio-icon { color: var(--brand); width: 24px; height: 24px; flex-shrink: 0 }
|
||||
.worktree-setup-context strong { min-width: 0; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; font-size: var(--fs-lead); font-weight: var(--w-med) }
|
||||
.worktree-setup-context > span { margin-left: auto; flex-shrink: 0; border-radius: 6px; padding: 3px 8px; background: var(--brand-wash); color: var(--brand); font-size: var(--fs-micro) }
|
||||
.worktree-setup-context > span { margin-left: auto; flex-shrink: 0; border-radius: 6px; padding: 3px 8px; background: var(--overlay); color: var(--muted); font-size: var(--fs-micro) }
|
||||
.worktree-setup h1 { margin: 0; font: var(--w-med) var(--fs-display)/1.3 var(--ui); letter-spacing: -.025em }
|
||||
.worktree-setup-description { margin: 10px 0 22px; color: var(--muted); font-size: var(--fs-ui); line-height: 1.7 }
|
||||
.worktree-setup-phase { display: flex; justify-content: space-between; align-items: center; gap: 18px; padding: 13px 14px; border-radius: 9px; background: var(--brand-wash); color: var(--brand); font-size: var(--fs-ui) }
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
.worktree-trigger .lb { max-width: 230px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap }
|
||||
:root .compose .mode.worktree-trigger[data-on] { color: var(--brand); background: var(--brand-wash) }
|
||||
:root .compose .mode.worktree-trigger[data-on] .lb, :root .compose .mode.worktree-trigger[data-on] .studio-icon { color: var(--brand) }
|
||||
:root .compose .mode.worktree-trigger[data-on] { color: var(--muted); background: var(--overlay) }
|
||||
:root .compose .mode.worktree-trigger[data-on] .lb, :root .compose .mode.worktree-trigger[data-on] .studio-icon { color: var(--muted) }
|
||||
.workspace-palette { position: fixed; z-index: 1000; width: min(400px, calc(100vw / var(--zoom, 1) - 24px)); max-height: calc(100vh / var(--zoom, 1) - 24px); overflow: auto; padding: 16px; border: 1px solid var(--hair); border-radius: 16px; background: var(--float); color: var(--text); box-shadow: var(--shadow-float); font: var(--w-reg) var(--fs-ui)/1.5 var(--ui) }
|
||||
.workspace-palette .studio-icon { width: 18px; height: 18px; flex-shrink: 0 }
|
||||
.workspace-palette-heading { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding-bottom: 12px; margin-bottom: 16px; border-bottom: 1px solid var(--hair); font-size: var(--fs-ui); font-weight: var(--w-med) }
|
||||
@@ -9,7 +9,7 @@
|
||||
.workspace-palette-identity { display: flex; align-items: center; gap: 10px; min-width: 0; margin-bottom: 16px }
|
||||
.workspace-palette-identity > .studio-icon { width: 23px; height: 23px; color: var(--brand) }
|
||||
.workspace-palette-identity > strong { min-width: 0; flex: 1; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; font-weight: var(--w-med) }
|
||||
.workspace-palette-identity > span { flex-shrink: 0; max-width: 46%; padding: 3px 7px; border-radius: 6px; color: var(--brand); background: var(--brand-wash); font-size: var(--fs-micro) }
|
||||
.workspace-palette-identity > span { flex-shrink: 0; max-width: 46%; padding: 3px 7px; border-radius: 6px; color: var(--muted); background: var(--overlay); font-size: var(--fs-micro) }
|
||||
.worktree-modes { display: grid; grid-template-columns: 1fr 1fr; gap: 4px; padding: 3px; margin: 0 0 14px; background: var(--raised); border: 1px solid var(--hair); border-radius: 9px }
|
||||
.worktree-modes button { display: flex; align-items: center; justify-content: center; gap: 7px; border: 1px solid transparent; border-radius: 6px; min-height: 36px; padding: 6px; color: var(--muted); background: none; font: inherit; font-size: var(--fs-meta); cursor: pointer }
|
||||
.worktree-modes button[aria-pressed="true"] { background: var(--float); border-color: var(--hair); color: var(--text) }
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -18,12 +18,15 @@ import { LegacySessionsSection } from "./LegacySessionsSection";
|
||||
import { OpenSessionLimitSection } from "./OpenSessionLimitSection";
|
||||
import { LiveStreamBudgetSection } from "./LiveStreamBudgetSection";
|
||||
import { NewAnswerSection } from "./NewAnswerSection";
|
||||
import { PlanProgressSection } from "./PlanProgressSection";
|
||||
import { ComposerHintSection } from "./ComposerHintSection";
|
||||
import { SessionQuickSection } from "./SessionQuickSection";
|
||||
import { NavEntriesSection } from "./NavEntriesSection";
|
||||
import { NavRailSection } from "./NavRailSection";
|
||||
import { GraphicsSection } from "./GraphicsSection";
|
||||
import { GlobalShortcutSection } from "./GlobalShortcutSection";
|
||||
import { SendShortcut } from "./SendShortcut";
|
||||
import { RunningMessageSetting } from "./RunningMessageSetting";
|
||||
import { KeysSheet } from "./KeysSheet";
|
||||
import { SessionTabsSetting } from "./SessionTabsSetting";
|
||||
import { EscStopSetting } from "./EscStopSetting";
|
||||
@@ -33,6 +36,7 @@ import { ThemeRemove } from "./ThemeRemove";
|
||||
import { WallpaperSceneImport } from "./WallpaperSceneImport";
|
||||
import { WallpaperVolumeSlider } from "./WallpaperVolumeSlider";
|
||||
import { useThemeInventory } from "./useThemeInventory";
|
||||
import { ThemeScheduleFields } from "./ThemeScheduleFields";
|
||||
|
||||
// "" follows the machine; the rest are explicit, the same shape the light/dark
|
||||
// control uses.
|
||||
@@ -47,6 +51,7 @@ export const SCHEMES: [string, string][] = [
|
||||
["auto", "跟随系统"],
|
||||
["light", "浅色"],
|
||||
["dark", "深色"],
|
||||
["scheduled", "定时"],
|
||||
];
|
||||
|
||||
interface Props {
|
||||
@@ -74,13 +79,11 @@ const WEIGHTS: [string, string, string][] = [
|
||||
["heavy", "加粗", "小字号下更扎实,屏幕距离较远或有反光时更易读"],
|
||||
];
|
||||
|
||||
// Two answers, because there are two: a line short enough that the eye finds
|
||||
// the next one without hunting, or a window used to its edge. A step between
|
||||
// them would be a number nobody could tell apart from its neighbour.
|
||||
const MEASURES: [string, string, string][] = [
|
||||
["standard", "适宜阅读", "每行长度控制在一眼能回到行首的范围"],
|
||||
["full", "铺满窗口", "正文跟随窗口宽度,宽屏上不留两侧空白"],
|
||||
["full-all", "连输入框一起铺满", "正文和输入框都跟随窗口宽度;输入框很宽时一行不易回到行首"],
|
||||
["custom", "自定义比例", "按比例调整对话和输入框占用的区域"],
|
||||
];
|
||||
|
||||
const EFFECTS: [EffectsMode, string, string][] = [
|
||||
@@ -159,6 +162,7 @@ export function Appearance({ port, theme, onTheme, contrast, onContrast, weight,
|
||||
// Until the kernel has announced its range there is nothing to draw the
|
||||
// slider from; it is held inert rather than given a range of its own.
|
||||
const zoomRange = look.zoomRange ?? { min: 0, max: 2, step: 1 };
|
||||
const widthRange = look.widthRange ?? { min: 0, max: 2, step: 1 };
|
||||
const setPaper = useCallback(
|
||||
(patch: Partial<NonNullable<Look["wallpaper"]>>) =>
|
||||
look.wallpaper && onLook({ ...look, wallpaper: { ...look.wallpaper, ...patch } }),
|
||||
@@ -233,7 +237,7 @@ export function Appearance({ port, theme, onTheme, contrast, onContrast, weight,
|
||||
<div className="grp-hd">
|
||||
<h3>{t("明暗")}</h3>
|
||||
</div>
|
||||
<p className="hint">{t("跟随系统时会随系统主题切换;手动选择后将保持固定。")}</p>
|
||||
<p className="hint">{t("跟随系统时会随系统主题切换;浅色和深色保持固定;定时按本机时间每天切换。")}</p>
|
||||
<div className="grp-items">
|
||||
<div className="seg" data-text role="group" aria-label={t("明暗")}>
|
||||
{SCHEMES.map(([id, name]) => (
|
||||
@@ -242,6 +246,7 @@ export function Appearance({ port, theme, onTheme, contrast, onContrast, weight,
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{theme === "scheduled" && <ThemeScheduleFields />}
|
||||
</div>
|
||||
<ApplyNote id="mode" />
|
||||
</section>
|
||||
@@ -361,21 +366,35 @@ export function Appearance({ port, theme, onTheme, contrast, onContrast, weight,
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{/* Beside the size because they are one question asked twice: how big
|
||||
the letters are, and how far they run before the eye comes back. */}
|
||||
<div className="prow">
|
||||
<span className="tx">{t("行宽")}</span>
|
||||
<div className="seg" data-text role="group" aria-label={t("正文行宽")}>
|
||||
{MEASURES.map(([id, name, why]) => (
|
||||
<button key={id} data-action="appearance.measure" data-value={id}
|
||||
aria-pressed={(look.width || "standard") === id} title={t(why)} onClick={() => set({ width: id })}>
|
||||
aria-pressed={(look.width || "standard") === id} title={t(why)}
|
||||
disabled={id === "custom" && !look.widthRange} onClick={() => set({ width: id })}>
|
||||
{t(name)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{look.width === "custom" && (
|
||||
<div className="prow">
|
||||
<span className="tx">{t("对话区域宽度")}</span>
|
||||
<input className="slider" type="range"
|
||||
style={at(look.widthPercent ?? 1, widthRange.min, widthRange.max)}
|
||||
min={widthRange.min} max={widthRange.max} step={widthRange.step}
|
||||
disabled={!look.widthRange} value={look.widthPercent ?? 1}
|
||||
aria-label={t("对话区域宽度")} aria-valuetext={pct((look.widthPercent ?? 1) / 100)}
|
||||
data-action="appearance.measure" data-value="custom"
|
||||
onChange={(e) => set({ widthPercent: Number(e.target.value) })} />
|
||||
<span className="now">{pct((look.widthPercent ?? 1) / 100)}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<p className="hint">{t("行宽只管正文:代码、命令与工具输出始终占满可用宽度。")}</p>
|
||||
<p className="hint">{t(look.width === "custom"
|
||||
? "自定义比例同时调整对话和输入框;窄窗口会优先保证可用空间。"
|
||||
: "行宽只管正文:代码、命令与工具输出始终占满可用宽度。")}</p>
|
||||
<ApplyNote id="size" />
|
||||
</section>
|
||||
|
||||
@@ -556,12 +575,15 @@ export function Appearance({ port, theme, onTheme, contrast, onContrast, weight,
|
||||
<NewAnswerSection />
|
||||
<GraphicsSection />
|
||||
<SendShortcut />
|
||||
<ComposerHintSection />
|
||||
<RunningMessageSetting />
|
||||
<EscStopSetting />
|
||||
<GlobalShortcutSection />
|
||||
<KeysSheet />
|
||||
<EscStopSetting />
|
||||
<PageSleepSetting />
|
||||
<SessionTabsSetting />
|
||||
<Folding />
|
||||
<PlanProgressSection />
|
||||
<Notifications port={port} />
|
||||
|
||||
{/* What makes this page read as a console is that all of it arrives at
|
||||
|
||||
@@ -10,6 +10,7 @@ import { ariaChord, chord } from "./keys";
|
||||
import { PhonePop } from "./PhonePop";
|
||||
import { ViewSwitch } from "./ViewSwitch";
|
||||
import { TooltipButton } from "./TooltipButton";
|
||||
import { workspaceDirectoryDescription } from "./workspaceDirectory";
|
||||
import { WindowControls, zoomOnTitleBar } from "./WindowControls";
|
||||
import type { PaneView } from "../state/paneview";
|
||||
|
||||
@@ -27,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;
|
||||
@@ -43,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
|
||||
@@ -55,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);
|
||||
@@ -99,9 +94,9 @@ export function Chrome({ port, status, title, steer, account, host, theme, onThe
|
||||
<span className="crumb-proj" title={root}>
|
||||
{project}
|
||||
</span>
|
||||
<span className="isolab" hidden={!ws || ws.current !== root} title={root}>
|
||||
{ws?.isolated ? t("隔离目录") : t("原目录")}
|
||||
</span>
|
||||
{ws?.isolated && ws.current === root && <span className="isolab" title={`${workspaceDirectoryDescription(true)}\n${root}`}>
|
||||
{t("独立工作树")}
|
||||
</span>}
|
||||
<span className="isolab hostlab" hidden={!host}>
|
||||
{host}
|
||||
</span>
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
// @vitest-environment jsdom
|
||||
import { cleanup, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import "./testkit";
|
||||
import { Composer } from "./Composer";
|
||||
import { ComposerHintSection } from "./ComposerHintSection";
|
||||
import { MockPort } from "../port/mock";
|
||||
import { boot, STORAGE } from "../i18n";
|
||||
import { onComposerHintChange, setShowsComposerHint, showsComposerHint } from "../state/prefs";
|
||||
|
||||
beforeEach(() => { localStorage.clear(); localStorage.setItem(STORAGE, "zh"); boot(); });
|
||||
afterEach(() => { cleanup(); localStorage.clear(); vi.restoreAllMocks(); });
|
||||
|
||||
function draw(queued = false) {
|
||||
const onSubmit = vi.fn(async () => true);
|
||||
const view = render(<Composer port={new MockPort()} status={null} running={queued} queued={queued} onSubmit={onSubmit} onChanged={vi.fn()} onError={vi.fn()} />);
|
||||
return { ...view, onSubmit, box: screen.getByRole<HTMLTextAreaElement>("combobox", { name: "任务输入" }) };
|
||||
}
|
||||
|
||||
describe("composer hint visibility", () => {
|
||||
it("defaults on, accepts only explicit off, and tolerates unavailable storage", () => {
|
||||
expect(showsComposerHint()).toBe(true);
|
||||
for (const value of ["", "on", "false", "garbage"]) {
|
||||
localStorage.setItem("rx-composer-hint", value);
|
||||
expect(showsComposerHint()).toBe(true);
|
||||
}
|
||||
setShowsComposerHint(false);
|
||||
expect(showsComposerHint()).toBe(false);
|
||||
const get = vi.spyOn(Storage.prototype, "getItem").mockImplementation(() => { throw new Error("blocked"); });
|
||||
const set = vi.spyOn(Storage.prototype, "setItem").mockImplementation(() => { throw new Error("blocked"); });
|
||||
expect(showsComposerHint()).toBe(true);
|
||||
expect(() => setShowsComposerHint(false)).not.toThrow();
|
||||
get.mockRestore();
|
||||
set.mockRestore();
|
||||
});
|
||||
|
||||
it("notifies mounted subscribers and removes them on unsubscribe", () => {
|
||||
const heard = vi.fn();
|
||||
const stop = onComposerHintChange(heard);
|
||||
setShowsComposerHint(false);
|
||||
stop();
|
||||
setShowsComposerHint(true);
|
||||
expect(heard).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it.each([false, true])("applies immediately and persists on remount (queued=%s)", async (queued) => {
|
||||
const settings = render(<ComposerHintSection />);
|
||||
const view = draw(queued);
|
||||
expect(view.box.placeholder.length).toBeGreaterThan(0);
|
||||
await userEvent.click(within(settings.container).getByRole("button", { name: "关闭" }));
|
||||
expect(view.box.placeholder).toBe("");
|
||||
expect(view.box.getAttribute("aria-label")).toBe("任务输入");
|
||||
expect(view.box.getAttribute("aria-describedby")).toBeTruthy();
|
||||
expect(localStorage.getItem("rx-composer-hint")).toBe("off");
|
||||
view.unmount();
|
||||
const remounted = draw(queued);
|
||||
expect(remounted.box.placeholder).toBe("");
|
||||
await userEvent.click(within(settings.container).getByRole("button", { name: "显示" }));
|
||||
await waitFor(() => expect(remounted.box.placeholder.length).toBeGreaterThan(0));
|
||||
expect(localStorage.getItem("rx-composer-hint")).toBe("on");
|
||||
});
|
||||
|
||||
it("keeps entered text, focus, IME confirmation and submission when hidden", async () => {
|
||||
localStorage.setItem("rx-composer-hint", "off");
|
||||
const { box, onSubmit } = draw();
|
||||
await userEvent.type(box, "implement the change");
|
||||
expect(box.value).toBe("implement the change");
|
||||
expect(document.activeElement).toBe(box);
|
||||
fireEvent.compositionStart(box);
|
||||
fireEvent.keyDown(box, { key: "Enter", isComposing: true });
|
||||
expect(onSubmit).not.toHaveBeenCalled();
|
||||
fireEvent.compositionEnd(box);
|
||||
vi.spyOn(Date, "now").mockReturnValue(Date.now() + 120);
|
||||
fireEvent.keyDown(box, { key: "Enter" });
|
||||
await waitFor(() => expect(onSubmit).toHaveBeenCalledWith("implement the change"));
|
||||
});
|
||||
});
|
||||
@@ -1,9 +1,9 @@
|
||||
import { useCallback, useEffect, useId, useRef, useState, type ReactNode } from "react";
|
||||
import { useCallback, useEffect, useId, useRef, useState, useSyncExternalStore, type ReactNode } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { t } from "../i18n";
|
||||
import { reason } from "../i18n/kernel";
|
||||
import { keepPasteAsFile, PasteKeep, type PasteChip } from "./pastekeep";
|
||||
import type { AgentPort, ChipCall, ModelEntry, SessionStatus, WorkspaceGit } from "../port/port";
|
||||
import type { AgentPort, ChipCall, ModelEntry, SessionStatus, WorkspaceGit, WorkspaceInfo } from "../port/port";
|
||||
import { Picker } from "./Menu";
|
||||
import { BranchChip } from "./BranchChip";
|
||||
import { WorktreeChip } from "./WorktreeChip";
|
||||
@@ -18,7 +18,7 @@ import { ChipMirror, useSkillChips } from "./ChipMirror";
|
||||
import { useIme } from "./ime";
|
||||
import { usePlaceholderFit } from "./placeholderfit";
|
||||
import { useFitHeight } from "./fitHeight";
|
||||
import { useSendShortcut } from "../state/prefs";
|
||||
import { onComposerHintChange, showsComposerHint, useRunningMessageIntent, useSendShortcut } from "../state/prefs";
|
||||
import { sendAria, sendHint, sendsOnKey } from "./sendkey";
|
||||
import { countLines, pasteIsLong, planTone, planVerb } from "./intake";
|
||||
import { kindOf, nameOf, previewURL } from "./chipfile";
|
||||
@@ -68,10 +68,10 @@ interface Props {
|
||||
sidePanel?: ReactNode;
|
||||
changeCount?: number;
|
||||
countElsewhere?: boolean;
|
||||
// The work tree's Git state as git itself reports it, refreshed on the same
|
||||
// path as changeCount (turn boundaries, writes). null is "not answered yet";
|
||||
// repo:false is a workspace with no repository, and the two render apart.
|
||||
// Git state follows changeCount's refresh path (turn boundaries and writes).
|
||||
// null is unread; repo:false is a workspace without a repository.
|
||||
git?: WorkspaceGit | null;
|
||||
workspace?: WorkspaceInfo | null;
|
||||
// The switch already answers with the workspace's Git state.
|
||||
onTreeChanged?: (git: WorkspaceGit) => void;
|
||||
// Bumped when settings change; a source edited there can change the ladder.
|
||||
@@ -79,7 +79,7 @@ interface Props {
|
||||
draftKey?: string;
|
||||
}
|
||||
|
||||
export function Composer({ port, status, running, queued = false, quote, restore, inject, focus, onSubmit, onChanged, onError, onSettings = () => {}, side = false, onSide, sidePanel, changeCount = 0, countElsewhere = false, git = null, onTreeChanged, pulse = 0, draftKey = "", fresh = false, worktreeTarget, onWorktreePhase }: Props) {
|
||||
export function Composer({ port, status, running, queued = false, quote, restore, inject, focus, onSubmit, onChanged, onError, onSettings = () => {}, side = false, onSide, sidePanel, changeCount = 0, countElsewhere = false, git = null, workspace = null, onTreeChanged, pulse = 0, draftKey = "", fresh = false, worktreeTarget, onWorktreePhase }: Props) {
|
||||
const touch = touchKeyboard();
|
||||
const providerOrder = useProviderOrder();
|
||||
const modelGroups = useModelFilter();
|
||||
@@ -195,6 +195,7 @@ export function Composer({ port, status, running, queued = false, quote, restore
|
||||
}, chips, addSession);
|
||||
const ime = useIme();
|
||||
const sendShortcut = useSendShortcut();
|
||||
const runningIntent = useRunningMessageIntent();
|
||||
|
||||
// A counter, not a boolean: asking twice in a row has to move the cursor twice.
|
||||
useEffect(() => {
|
||||
@@ -217,7 +218,9 @@ export function Composer({ port, status, running, queued = false, quote, restore
|
||||
pending.current = null;
|
||||
}, []);
|
||||
useFitHeight(box, text, placeCaret);
|
||||
const placeholder = usePlaceholderFit(box, queued ? [t("继续输入以排队后续修改"), t("继续输入…"), "…"] : [t("描述任务、问题或要改的内容…"), t("描述任务…"), "…"]);
|
||||
const showHint = useSyncExternalStore(onComposerHintChange, showsComposerHint, showsComposerHint);
|
||||
const fittedPlaceholder = usePlaceholderFit(box, queued ? [t("继续输入以排队后续修改"), t("继续输入…"), "…"] : [t("描述任务、问题或要改的内容…"), t("描述任务…"), "…"]);
|
||||
const placeholder = showHint ? fittedPlaceholder : "";
|
||||
|
||||
// Attachments ride into the turn as path references, exactly as they do from
|
||||
// the CLI — the host saved the bytes, the turn parser resolves the token. A
|
||||
@@ -637,7 +640,7 @@ export function Composer({ port, status, running, queued = false, quote, restore
|
||||
? t("正在添加附件…")
|
||||
: failed
|
||||
? t("有附件添加失败,请重试或移除")
|
||||
: sendHint(sendShortcut, running, touch)}
|
||||
: sendHint(sendShortcut, running, touch, runningIntent)}
|
||||
</span>
|
||||
{showCount && <span className="fcount">{t("{n} 字 · {lines} 行", { n: text.length, lines })}</span>}
|
||||
</div>
|
||||
@@ -691,7 +694,7 @@ export function Composer({ port, status, running, queued = false, quote, restore
|
||||
label={<><StudioIcon name="agent" /><span className="studio-sr-label">{t("计划")}</span><span>{status?.plan ? "Plan" : "Agent"}</span><StudioIcon name="down" /></>}
|
||||
/>
|
||||
</div>
|
||||
{git?.repo && (worktree.sources || status?.workspaceRoot) ? <WorktreeChip port={port} onChanged={onChanged} onSwitched={onTreeChanged} onError={onError} changeCount={countElsewhere ? 0 : changeCount} sources={worktree.sources ?? { available: false, sourceRoot: status?.workspaceRoot ?? "", sources: [] }} canCreate={fresh && !!worktree.sources?.available} selection={worktree.selection} branch={git.branch} busy={submitting || running} onSelect={worktree.setSelection} refresh={worktree.refresh} /> : <BranchChip port={port} git={git} changeCount={changeCount} countElsewhere={countElsewhere} onChanged={onChanged} onSwitched={onTreeChanged} onError={onError} />}
|
||||
{git?.repo && (worktree.sources || status?.workspaceRoot) ? <WorktreeChip port={port} workspace={workspace} onChanged={onChanged} onSwitched={onTreeChanged} onError={onError} changeCount={countElsewhere ? 0 : changeCount} sources={worktree.sources ?? { available: false, sourceRoot: status?.workspaceRoot ?? "", sources: [] }} canCreate={fresh && !!worktree.sources?.available} selection={worktree.selection} branch={git.branch} busy={submitting || running} onSelect={worktree.setSelection} refresh={worktree.refresh} /> : <BranchChip port={port} git={git} changeCount={changeCount} countElsewhere={countElsewhere} onChanged={onChanged} onSwitched={onTreeChanged} onError={onError} />}
|
||||
{/* The toggle keeps its legacy meaning: it follows `plan`, which the
|
||||
kernel turns off the moment a plan is approved. The lifecycle is a
|
||||
separate reading — an approved plan is still running, and saying so
|
||||
@@ -788,7 +791,7 @@ export function Composer({ port, status, running, queued = false, quote, restore
|
||||
<path d="M8 12.8V3.4M4.2 7.2 8 3.4l3.8 3.8" />
|
||||
</svg>
|
||||
</span>
|
||||
<span>{t(submitting ? "正在发送…" : running ? "插话" : "发送")}</span>
|
||||
<span>{t(submitting ? "正在发送…" : running ? runningIntent === "followup" ? "排队发送" : "插话" : "发送")}</span>
|
||||
</button>}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -18,7 +18,7 @@ it.each(["failed", "cancelled"])("restores composer focus after dismissing %s pr
|
||||
const st = { workspaceRoot: "/source", modelRef: "deepseek/deepseek-v4-pro", preset: "balanced", effort: "auto", toolApprovalMode: "ask" } as SessionStatus;
|
||||
render(<Composer port={port} status={st} running={false} fresh git={{ repo: true, branch: "main", name: "project", detached: false, added: 0, removed: 0, untracked: 0 }} onSubmit={onSubmit} onChanged={vi.fn()} onError={vi.fn()}/>);
|
||||
fireEvent.click(await screen.findByRole("button", { name: "配置工作目录" }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "新建 worktree" }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "独立工作树" }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "关闭工作目录配置" }));
|
||||
const box = screen.getByLabelText("任务输入") as HTMLTextAreaElement;
|
||||
box.focus();
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -55,3 +55,18 @@ it("closes on Escape and offers nothing where the kernel's machine cannot open p
|
||||
draw(false);
|
||||
expect(screen.queryByRole("button", { name: "用其他程序打开项目" })).toBeNull();
|
||||
});
|
||||
|
||||
it("says why an installed program is not offered, and also when nothing is", async () => {
|
||||
shell.reveals = true;
|
||||
const blocked = { id: "cursor", name: "Cursor", available: false, why: "inside_project", within: "/home/u" };
|
||||
const openWith = vi.fn().mockResolvedValue(undefined);
|
||||
const openWithTargets = vi.fn().mockResolvedValue([blocked]);
|
||||
render(<OpenWithMenu hub={{ openWith, openWithTargets }} root="/w" onError={vi.fn()} />);
|
||||
await userEvent.click(screen.getByRole("button", { name: "用其他程序打开项目" }));
|
||||
const item = await screen.findByRole("menuitem", { name: /用 Cursor 打开/ });
|
||||
expect(item.hasAttribute("disabled")).toBe(true);
|
||||
expect(item.getAttribute("title")).toContain("/home/u");
|
||||
expect(screen.queryByText("没有找到可用的程序")).toBeNull();
|
||||
await userEvent.click(item);
|
||||
expect(openWith).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { openWithName } from "./openwith_label";
|
||||
import { blockedInProject, blockedNote, openWithName } from "./openwith_label";
|
||||
import type { HubPort, OpenWithTarget } from "../port/hub";
|
||||
import { host } from "../port/host";
|
||||
import { t } from "../i18n";
|
||||
@@ -19,7 +19,7 @@ export function OpenWithMenu({ hub, root, onError }: {
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
let alive = true;
|
||||
void hub.openWithTargets().then((list) => alive && setTargets(list.filter((x) => x.available))).catch(() => {});
|
||||
void hub.openWithTargets().then((list) => alive && setTargets(list.filter((x) => x.available || blockedInProject(x)))).catch(() => {});
|
||||
return () => { alive = false; };
|
||||
}, [hub, open]);
|
||||
if (!shell || !root) return null;
|
||||
@@ -51,12 +51,15 @@ export function OpenWithMenu({ hub, root, onError }: {
|
||||
data-action="workspace.open-with"
|
||||
data-target={root}
|
||||
data-value={target.id}
|
||||
disabled={!target.available}
|
||||
title={target.available ? undefined : blockedNote(target)}
|
||||
onClick={() => {
|
||||
setOpen(false);
|
||||
hub.openWith(root, target.id).catch(onError);
|
||||
}}
|
||||
>
|
||||
<span>{t("用 {name} 打开", { name: openWithName(target) })}</span>
|
||||
{!target.available && <small>{t("位于项目文件夹内")}</small>}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
// @vitest-environment jsdom
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { cleanup, render, screen, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import "./testkit";
|
||||
import { PlanFold } from "./PlanFold";
|
||||
import { PlanProgressSection } from "./PlanProgressSection";
|
||||
import { boot, STORAGE } from "../i18n";
|
||||
import { setShowsPlanProgress } from "../state/prefs";
|
||||
import type { PlanStep } from "../state/session";
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
localStorage.setItem(STORAGE, "zh");
|
||||
boot();
|
||||
setShowsPlanProgress(false);
|
||||
});
|
||||
afterEach(cleanup);
|
||||
|
||||
const plan: PlanStep[] = [
|
||||
{ text: "Read requirements", status: "completed" },
|
||||
{ text: "Implement changes", status: "in_progress" },
|
||||
{ text: "Verify result", status: "pending" },
|
||||
];
|
||||
|
||||
describe("optional progress below the collapsed plan", () => {
|
||||
it("applies the machine preference immediately to every mounted plan, and remembers it on remount", async () => {
|
||||
const settings = render(<PlanProgressSection />);
|
||||
const cards = render(<><PlanFold plan={plan} shown /><PlanFold plan={plan} shown /></>);
|
||||
expect(screen.queryAllByRole("progressbar")).toHaveLength(0);
|
||||
await userEvent.click(within(settings.container).getByRole("button", { name: "显示" }));
|
||||
expect(localStorage.getItem("rx-plan-progress")).toBe("on");
|
||||
expect(screen.getAllByRole("progressbar")).toHaveLength(2);
|
||||
const progress = screen.getAllByRole("progressbar")[0];
|
||||
expect(progress.getAttribute("aria-label")).toBe("计划进度");
|
||||
expect(progress.getAttribute("aria-valuenow")).toBe("1");
|
||||
expect(progress.getAttribute("aria-valuemax")).toBe("3");
|
||||
expect(progress.querySelector("i")?.style.width).toBe("33%");
|
||||
cards.unmount();
|
||||
render(<PlanFold plan={plan} shown />);
|
||||
expect(screen.getByRole("progressbar")).toBeTruthy();
|
||||
await userEvent.click(within(settings.container).getByRole("button", { name: "关闭" }));
|
||||
expect(screen.queryByRole("progressbar")).toBeNull();
|
||||
expect(localStorage.getItem("rx-plan-progress")).toBe("off");
|
||||
});
|
||||
|
||||
it("follows live canonical statuses, including resetting the plan and completing all steps", () => {
|
||||
setShowsPlanProgress(true);
|
||||
const view = render(<PlanFold plan={plan} shown paused />);
|
||||
for (const status of ["pending", "in_progress", "completed"] as const) {
|
||||
view.rerender(<PlanFold plan={plan.map((s) => ({ ...s, status }))} shown paused />);
|
||||
const progress = screen.getByRole("progressbar");
|
||||
expect(progress.getAttribute("aria-valuenow")).toBe(status === "completed" ? "3" : "0");
|
||||
expect(progress.querySelector("i")?.style.width).toBe(status === "completed" ? "100%" : "0%");
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps expanded progress and all collapse and dismiss interactions unchanged", async () => {
|
||||
setShowsPlanProgress(true);
|
||||
render(<><PlanFold plan={plan} shown /><button>Outside</button></>);
|
||||
const bar = screen.getByRole("button", { expanded: false });
|
||||
await userEvent.click(bar);
|
||||
expect(screen.queryByRole("progressbar")).toBeNull();
|
||||
expect(document.querySelector(".studio-todo-body .prog2 i")?.getAttribute("style")).toContain("33%");
|
||||
await userEvent.click(screen.getByText("Verify result"));
|
||||
expect(bar.getAttribute("aria-expanded")).toBe("true");
|
||||
await userEvent.click(screen.getByRole("button", { name: "收起计划" }));
|
||||
expect(screen.getByRole("progressbar")).toBeTruthy();
|
||||
expect(document.activeElement).toBe(bar);
|
||||
await userEvent.click(bar);
|
||||
await userEvent.keyboard("{Escape}");
|
||||
expect(screen.getByRole("progressbar")).toBeTruthy();
|
||||
expect(document.activeElement).toBe(bar);
|
||||
await userEvent.click(bar);
|
||||
await userEvent.click(screen.getByRole("button", { name: "Outside" }));
|
||||
expect(bar.getAttribute("aria-expanded")).toBe("false");
|
||||
expect(screen.getByRole("progressbar")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("does not fabricate progress for a goal-only, empty or hidden card", () => {
|
||||
setShowsPlanProgress(true);
|
||||
const view = render(<PlanFold plan={[]} goal="Deliver a working feature" shown />);
|
||||
expect(screen.queryByRole("progressbar")).toBeNull();
|
||||
expect(screen.getByRole("button", { expanded: false }).textContent).toContain("Deliver a working feature");
|
||||
view.rerender(<PlanFold plan={[]} shown />);
|
||||
expect(view.container.innerHTML).toBe("");
|
||||
view.rerender(<PlanFold plan={plan} shown={false} />);
|
||||
expect(view.container.innerHTML).toBe("");
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useRef, useState } from "react";
|
||||
import { useRef, useState, useSyncExternalStore } from "react";
|
||||
import { t } from "../i18n";
|
||||
import { currentStep, stepDone, stepLabel, type PlanStep } from "../state/session";
|
||||
import { onPlanProgressChange, showsPlanProgress } from "../state/prefs";
|
||||
import { Plan } from "./Plan";
|
||||
import type { GoalRuntime } from "../port/session";
|
||||
import { goalStatusText, goalStopText, goalUsageText } from "./goalusage";
|
||||
@@ -12,13 +13,15 @@ import { useDismiss } from "./dismiss";
|
||||
* visit to another view. */
|
||||
export function PlanFold({ plan, goal, goalStatus, goalRuntime, shown, paused }: { plan: PlanStep[]; goal?: string; goalStatus?: string; goalRuntime?: GoalRuntime; shown: boolean; paused?: boolean }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const progress = useSyncExternalStore(onPlanProgressChange, showsPlanProgress, showsPlanProgress);
|
||||
const bar = useRef<HTMLButtonElement>(null);
|
||||
const root = useRef<HTMLDivElement>(null);
|
||||
const put = () => { setOpen(false); bar.current?.focus(); };
|
||||
useDismiss(open, root, put);
|
||||
if ((plan.length === 0 && !goal) || !shown) return null;
|
||||
const done = plan.filter(stepDone).length;
|
||||
const running = plan.filter((s) => !stepDone(s) && plan.indexOf(s) === currentStep(plan)).length;
|
||||
const waiting = plan.length - plan.filter(stepDone).length - running;
|
||||
const waiting = plan.length - done - running;
|
||||
return (
|
||||
<div className="studio-todo" data-open={open ? "" : undefined} ref={root}>
|
||||
{/* One line by default. The composer already carries the run line,
|
||||
@@ -35,9 +38,15 @@ export function PlanFold({ plan, goal, goalStatus, goalRuntime, shown, paused }:
|
||||
<StudioIcon name="list" />
|
||||
<b>{t(plan.length ? "计划" : "目标")}</b>
|
||||
<span>{plan.length ? stepLabel(plan[currentStep(plan)] ?? plan[0]) : goal}</span>
|
||||
{plan.length > 0 && <small>{plan.filter(stepDone).length}/{plan.length}</small>}
|
||||
{plan.length > 0 && <small>{done}/{plan.length}</small>}
|
||||
<StudioIcon name="down" className="studio-todo-fold" />
|
||||
</button>
|
||||
{!open && progress && plan.length > 0 && (
|
||||
<div className="prog2 studio-todo-progress" role="progressbar" aria-label={t("计划进度")}
|
||||
aria-valuemin={0} aria-valuemax={plan.length} aria-valuenow={done}>
|
||||
<span className="track"><i style={{ width: `${Math.round((done / plan.length) * 100)}%` }} /></span>
|
||||
</div>
|
||||
)}
|
||||
{open && (
|
||||
<div className="studio-todo-body">
|
||||
<div className="studio-todo-head">
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import { useSyncExternalStore } from "react";
|
||||
import { t } from "../i18n";
|
||||
import { onPlanProgressChange, setShowsPlanProgress, showsPlanProgress } from "../state/prefs";
|
||||
import { Group } from "./Group";
|
||||
|
||||
export function PlanProgressSection() {
|
||||
const on = useSyncExternalStore(onPlanProgressChange, showsPlanProgress, showsPlanProgress);
|
||||
return (
|
||||
<Group id="plan-progress" title={t("收起时显示计划进度")} hint={t("在收起的计划卡片下方显示进度条,随已完成步骤更新。默认关闭,仅保存在本机。")}>
|
||||
<div className="seg" data-text role="group" aria-label={t("收起时显示计划进度")}>
|
||||
<button data-action="appearance.plan-progress" data-value="off" aria-pressed={!on} onClick={() => setShowsPlanProgress(false)}>{t("关闭")}</button>
|
||||
<button data-action="appearance.plan-progress" data-value="on" aria-pressed={on} onClick={() => setShowsPlanProgress(true)}>{t("显示")}</button>
|
||||
</div>
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
@@ -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" />
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
@@ -8,7 +8,6 @@ import { pinToViewport } from "./place";
|
||||
import { SessionAge } from "./SessionAge";
|
||||
import { SessionArchiveAction, SessionPinAction } from "./SessionRowActions";
|
||||
import { StudioIcon } from "./StudioIcon";
|
||||
import { UnreadDot } from "./UnreadMark";
|
||||
import { workspaceMenuKeys } from "./WorkspaceOrder";
|
||||
import { asksDelete } from "./keys";
|
||||
import type { SessionMutation } from "./useSessionMutation";
|
||||
@@ -61,8 +60,7 @@ export function RemoteSessionRow({ host, target, session, held, active, busy, pe
|
||||
<i className="pip" />
|
||||
<SessionPinAction target={target} title={title} pinned={!!session.pinned} enabled={quick} disabled={!!pending || busy} onPin={() => write("pin")} />
|
||||
<span className="sesstitle" title={title}><span>{title}</span>{session.isolated && <em className="sessarch" title={session.workspaceRoot}>{t("隔离")}</em>}{session.archived && <em className="sessarch">{t("已归档")}</em>}</span>
|
||||
{session.unread && <UnreadDot />}
|
||||
<SessionAge at={session.lastActiveAt} />
|
||||
<SessionAge at={session.lastActiveAt} unread={session.unread} />
|
||||
{session.legacy && <span className="sesslegacy" title={t("由 Reasonix 1.x 创建:Studio 只读打开,继续对话会另存为新会话")}
|
||||
aria-label={t("由 Reasonix 1.x 创建:Studio 只读打开,继续对话会另存为新会话")}>{t("旧")}</span>}
|
||||
{quick && <SessionArchiveAction target={target} title={title} archived={!!session.archived} disabled={!!pending || busy || live} onArchive={() => write("archive")} />}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
// @vitest-environment jsdom
|
||||
import { afterEach, expect, it } from "vitest";
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import "./testkit";
|
||||
import { SessionAge } from "./SessionAge";
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
it.each([undefined, 0, NaN, Infinity, 1_700_000_000_000])("renders exactly one unread mark instead of the date for %s", (at) => {
|
||||
const { container, rerender } = render(<SessionAge at={at} unread />);
|
||||
expect(screen.getAllByRole("img", { name: "未读" })).toHaveLength(1);
|
||||
expect(container.querySelector("time")).toBeNull();
|
||||
rerender(<SessionAge at={at} unread={false} />);
|
||||
expect(screen.queryByRole("img")).toBeNull();
|
||||
expect(container.querySelectorAll("time")).toHaveLength(at && Number.isFinite(at) ? 1 : 0);
|
||||
});
|
||||
|
||||
it("restores the original date when the canonical unread field clears", () => {
|
||||
const at = 1_700_000_000_000;
|
||||
const { container, rerender } = render(<SessionAge at={at} unread />);
|
||||
rerender(<SessionAge at={at} />);
|
||||
expect(container.querySelector("time")?.dateTime).toBe(new Date(at).toISOString());
|
||||
expect(screen.queryByRole("img")).toBeNull();
|
||||
});
|
||||
@@ -1,6 +1,8 @@
|
||||
import { since } from "./wallet";
|
||||
import { UnreadDot } from "./UnreadMark";
|
||||
|
||||
export function SessionAge({ at }: { at?: number }) {
|
||||
export function SessionAge({ at, unread }: { at?: number; unread?: boolean }) {
|
||||
if (unread) return <UnreadDot />;
|
||||
if (!at) return null;
|
||||
const date = new Date(at);
|
||||
if (!Number.isFinite(date.valueOf())) return null;
|
||||
|
||||
@@ -75,7 +75,6 @@ import { Storage } from "./Storage";
|
||||
import { Appearance, SCHEMES } from "./Appearance";
|
||||
import { ScopeBar } from "./CapabilityScope";
|
||||
import { savedNotApplied, reason } from "../i18n/kernel";
|
||||
import { SettingsHeading } from "./SettingsHeading";
|
||||
import type { SettingsProps as Props } from "./SettingsProps";
|
||||
import { APPROVALS, approvalName, approvalNote } from "./approvals";
|
||||
|
||||
@@ -425,7 +424,6 @@ export function Settings({ hub, onError, port, networkPort, localWorkspacePort,
|
||||
and it is the one thing here whose mount really is the cause. */}
|
||||
<div className="prefs-col" key={at}>
|
||||
<SettingsPageStyle section={at} page={pageNow} />
|
||||
<SettingsHeading section={at} value={nav[at]} danger={danger(at)} />
|
||||
{/* Every refused write on every page lands in one state, so it is
|
||||
said in one place. Copied onto the pages somebody remembered, it
|
||||
was missing from the third that writes it: 创建隔离副本 was
|
||||
|
||||
@@ -1,30 +0,0 @@
|
||||
import { t } from "../i18n";
|
||||
import { ICON, NAV, SETTINGS, type Section } from "./prefsnav";
|
||||
|
||||
interface Props {
|
||||
section: Section;
|
||||
value?: string;
|
||||
danger?: boolean;
|
||||
}
|
||||
|
||||
export function SettingsHeading({ section, value, danger }: Props) {
|
||||
const group = NAV.find(([, items]) => items.some(([id]) => id === section));
|
||||
const name = group?.[1].find(([id]) => id === section)?.[1] ?? section;
|
||||
const count = SETTINGS.filter((entry) => entry.section === section && !entry.hidden).length;
|
||||
|
||||
return (
|
||||
<header className="prefs-intro">
|
||||
<span className="prefs-intro-mark" aria-hidden="true">
|
||||
<svg viewBox="0 0 16 16">{ICON[section]}</svg>
|
||||
</span>
|
||||
<span className="prefs-intro-copy">
|
||||
<span className="prefs-intro-overline">{t(group?.[0] ?? "")}</span>
|
||||
<h2>{t(name)}</h2>
|
||||
</span>
|
||||
<span className="prefs-intro-meta" data-danger={danger ? "" : undefined}>
|
||||
{value && <strong title={value}>{value}</strong>}
|
||||
<span>{t("设置项:{n}", { n: count })}</span>
|
||||
</span>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
@@ -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,7 +1,7 @@
|
||||
import { plural, t } from "../i18n";
|
||||
|
||||
export function UnreadDot() {
|
||||
return <i className="unread-dot" role="img" aria-label={t("未读")} />;
|
||||
return <i className="unread-dot" role="img" aria-label={t("未读")} title={t("未读")} />;
|
||||
}
|
||||
|
||||
export function UnreadCount({ n }: { n: number }) {
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -3,7 +3,7 @@ import type { HubPort, OpenWithTarget, TreeWorkspace } from "../port/hub";
|
||||
import { host } from "../port/host";
|
||||
import { t } from "../i18n";
|
||||
import { StudioIcon } from "./StudioIcon";
|
||||
import { openWithName } from "./openwith_label";
|
||||
import { blockedInProject, blockedNote, openWithName } from "./openwith_label";
|
||||
|
||||
export function WorkspaceReveal({ ws, hub, dismiss, onError }: {
|
||||
ws: TreeWorkspace;
|
||||
@@ -18,7 +18,7 @@ export function WorkspaceReveal({ ws, hub, dismiss, onError }: {
|
||||
useEffect(() => {
|
||||
if (!shell) return;
|
||||
let alive = true;
|
||||
void hub.openWithTargets().then((list) => alive && setTargets(list.filter((x) => x.available && x.id !== "file-manager"))).catch(() => {});
|
||||
void hub.openWithTargets().then((list) => alive && setTargets(list.filter((x) => (x.available || blockedInProject(x)) && x.id !== "file-manager"))).catch(() => {});
|
||||
return () => { alive = false; };
|
||||
}, [hub, shell]);
|
||||
if (!shell) return null;
|
||||
@@ -44,13 +44,15 @@ export function WorkspaceReveal({ ws, hub, dismiss, onError }: {
|
||||
data-action="workspace.open-with"
|
||||
data-target={ws.root}
|
||||
data-value={target.id}
|
||||
disabled={ws.missing}
|
||||
disabled={ws.missing || !target.available}
|
||||
title={target.available ? undefined : blockedNote(target)}
|
||||
onClick={() => {
|
||||
dismiss();
|
||||
hub.openWith(ws.root, target.id).catch(onError);
|
||||
}}
|
||||
>
|
||||
<StudioIcon name="external" /><span>{t("用 {name} 打开", { name: openWithName(target) })}</span>
|
||||
{!target.available && <small>{t("位于项目文件夹内")}</small>}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -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";
|
||||
@@ -28,7 +28,7 @@ import { useSessionMove } from "./useSessionMove";
|
||||
import { useSessionMoveTo } from "./useSessionMoveTo";
|
||||
import { pinnedFirst } from "./pinorder";
|
||||
import { Confirm, removeHint, sessionDeleteHint } from "./WorkspaceConfirm";
|
||||
import { UnreadCount, UnreadDot } from "./UnreadMark";
|
||||
import { UnreadCount } from "./UnreadMark";
|
||||
import { PeerThreadMark } from "./peerthreads";
|
||||
import { NO_PAGING, atCap, sessionCount, answers, unloaded, type TreePaging, type TreeView } from "./treewindow";
|
||||
import { SessionBatch, useSelectedRows } from "./SessionBatch";
|
||||
@@ -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 {
|
||||
@@ -360,14 +362,14 @@ function WorkspacesView({ hub, tree, treeRead, answered = null, runtimes, active
|
||||
<>
|
||||
{moveTo.dialog}
|
||||
<SessionBatch hub={hub} selection={selection} selected={selected} displayed={displayed} onSelect={setSelection}
|
||||
onExit={() => treeKeys.ref.current?.previousElementSibling?.querySelector<HTMLElement>('[data-action="machine.menu"]')?.focus()}
|
||||
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} onRecent={onRecent}
|
||||
onToggleAll={onToggleAll} allFolded={needle ? false : allFolded} canFold={canFold} searching={!!needle}
|
||||
canSelect={displayed.length > 0} selecting={selection !== null}
|
||||
onSelect={() => setSelection(new Set())} />
|
||||
<div className="scroll">
|
||||
<ProjectsHeader adder={adder}
|
||||
onToggleAll={onToggleAll} allFolded={needle ? false : allFolded} canFold={canFold} searching={!!needle}
|
||||
canSelect={displayed.length > 0} selecting={selection !== null}
|
||||
onSelect={() => setSelection(new Set())} />
|
||||
<div role="tree" aria-label={t("项目与会话")} aria-multiselectable={selection !== null} inert={busy === "batch"} data-action-keydown="tree.navigate" ref={treeKeys.ref} onKeyDown={treeKeys.onKeyDown}>
|
||||
{shownTree.map((ws) => {
|
||||
// A fold is a resting-state preference; while a query is on it would hide
|
||||
@@ -616,8 +618,7 @@ function WorkspacesView({ hub, tree, treeRead, answered = null, runtimes, active
|
||||
</span>
|
||||
)}
|
||||
<PeerThreadMark path={session.path} />
|
||||
{session.unread && <UnreadDot />}
|
||||
<SessionAge at={session.lastActiveAt} />
|
||||
<SessionAge at={session.lastActiveAt} unread={session.unread} />
|
||||
{kept.length > 0 && (
|
||||
<button
|
||||
className="sesscopies"
|
||||
@@ -775,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));
|
||||
|
||||
@@ -5,6 +5,7 @@ import type { WorkspaceInfo, WorktreeSelection, WorktreeSources } from "../port/
|
||||
import type { AgentPort, WorkspaceGit } from "../port/port";
|
||||
import { useWorkspaceBranches } from "./useWorkspaceBranches";
|
||||
import { StudioIcon } from "./StudioIcon";
|
||||
import { workspaceDirectoryDescription } from "./workspaceDirectory";
|
||||
import { WorkspacePath, WorkspaceBranchName, compactWorkspaceValue, workspaceName } from "./WorkspacePath";
|
||||
import { listenAction } from "./listen";
|
||||
import { beside, pinToViewport, useFollow, zoom } from "./place";
|
||||
@@ -16,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;
|
||||
@@ -25,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);
|
||||
@@ -43,16 +44,14 @@ export function WorktreeChip({ sources, canCreate, selection, branch, busy, chan
|
||||
const isolated = workspace?.current === sources.sourceRoot && !!workspace.isolated;
|
||||
const chosen = sources.sources.find((source) => source.ref === selection?.ref);
|
||||
const label = chosen?.name ?? selection?.ref ?? branch;
|
||||
const creationDescription = t("发送消息时创建独立工作树,基于所选分支的已提交版本,不包含未提交修改;端口、数据库等运行资源仍可能共享。");
|
||||
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;
|
||||
@@ -96,13 +95,16 @@ export function WorktreeChip({ sources, canCreate, selection, branch, busy, chan
|
||||
const failed = selection ? sourceError : reading === "failed";
|
||||
const loading = !selection && (reading === "idle" || reading === "loading") && locals.length === 0;
|
||||
return <div className="studio-branch-pop">
|
||||
<button ref={trigger} type="button" className="mode plain studio-branch worktree-trigger" data-on={selection || isolated ? "" : undefined} data-pending={moving !== null ? "" : undefined} data-action="workspace.configure" aria-label={t("配置工作目录")} title={selection ? label : branch} aria-haspopup="dialog" aria-expanded={open} disabled={busy || moving !== null} onClick={show}>
|
||||
<StudioIcon name="branch"/><span className="lb">{moving ? t("正在切换到 {branch}…", { branch: moving }) : selection || isolated ? t("独立 worktree · {branch}", { branch: compactWorkspaceValue(label) }) : t("当前目录 · {branch}", { branch: compactWorkspaceValue(branch) })}</span>{changeCount > 0 && <small>{t("{n} 个变更", { n: changeCount })}</small>}<StudioIcon name="down"/>
|
||||
<button ref={trigger} type="button" className="mode plain studio-branch worktree-trigger" data-on={selection || isolated ? "" : undefined} data-pending={moving !== null ? "" : undefined} data-action="workspace.configure" aria-label={t("配置工作目录")} title={`${directoryDescription}\n${selection ? label : branch}\n${sources.sourceRoot}`} aria-haspopup="dialog" aria-expanded={open} disabled={busy || moving !== null} onClick={show}>
|
||||
<StudioIcon name="branch"/><span className="lb">{moving ? t("正在切换到 {branch}…", { branch: moving }) : selection || isolated ? t("独立工作树 · {branch}", { branch: compactWorkspaceValue(label) }) : compactWorkspaceValue(branch)}</span>{changeCount > 0 && <small>{t("{n} 个变更", { n: changeCount })}</small>}<StudioIcon name="down"/>
|
||||
</button>
|
||||
{open && createPortal(<div ref={panel} className="workspace-palette" role="dialog" aria-labelledby={title}>
|
||||
<div className="workspace-palette-heading"><span id={title}>{t("配置工作目录")}</span><button data-action="workspace.configure" className="workspace-palette-close" aria-label={t("关闭工作目录配置")} onClick={close}><StudioIcon name="close"/></button></div>
|
||||
<div className="workspace-palette-identity"><StudioIcon name="folder"/><strong title={sources.sourceRoot}>{workspaceName(sources.sourceRoot)}</strong><span>{t(selection ? "新建 worktree" : isolated ? "独立 worktree" : "当前目录")}</span></div>
|
||||
{canCreate && <div className="worktree-modes"><button data-action="workspace.worktree-toggle" aria-pressed={!selection} disabled={busy || moving !== null} onClick={() => { onSelect(null); setQuery(""); }}><StudioIcon name="folder"/>{t("使用当前目录")}</button><button data-action="workspace.worktree-toggle" aria-pressed={!!selection} disabled={busy || moving !== null || !sources.available} onClick={selectWorktree}><StudioIcon name="branch"/>{t("新建 worktree")}</button></div>}
|
||||
<div className="workspace-palette-identity"><StudioIcon name="folder"/><strong title={sources.sourceRoot}>{workspaceName(sources.sourceRoot)}</strong>{(selection || isolated) && <span title={directoryDescription}>{t("独立工作树")}</span>}</div>
|
||||
{canCreate && <div className="worktree-modes">
|
||||
<button data-action="workspace.worktree-toggle" title={workspaceDirectoryDescription(false)} aria-pressed={!selection} disabled={busy || moving !== null} onClick={() => { onSelect(null); setQuery(""); }}><StudioIcon name="folder"/>{t("当前目录")}{!selection && <StudioIcon name="check"/>}</button>
|
||||
<button data-action="workspace.worktree-toggle" title={creationDescription} aria-pressed={!!selection} disabled={busy || moving !== null || !sources.available} onClick={selectWorktree}><StudioIcon name="branch"/>{t("独立工作树")}{selection && <StudioIcon name="check"/>}</button>
|
||||
</div>}
|
||||
{selection && <div className="workspace-palette-base"><span>{t("起始分支")}</span><strong title={label}>{compactWorkspaceValue(label)}</strong><small>{t("任务分支自动创建")}</small></div>}
|
||||
<div className="workspace-palette-search"><StudioIcon name="search"/><input data-action-change="workspace.source-search" data-action-keydown="workspace.source-navigate" ref={search} type="search" aria-label={t("搜索分支")} placeholder={t(selection ? "搜索起始分支…" : "搜索本地分支…")} value={query} onChange={(event) => setQuery(event.target.value)} onKeyDown={(event) => {
|
||||
if (event.key === "ArrowDown") { event.preventDefault(); panel.current?.querySelector<HTMLElement>('[role="option"]:not(:disabled)')?.focus(); }
|
||||
|
||||
@@ -53,7 +53,7 @@ export function WorktreeSetupPage({ stage, task, files, busy, onRetry, onCancel,
|
||||
const icon = ready || retained ? "check" : failed || uncertain ? "warning" : cancelled ? "close" : "clock";
|
||||
const phase = error || t(phases[stage.phase] || "正在等待状态确认");
|
||||
return <section className="worktree-setup" data-phase={stage.phase} aria-label={t("工作目录准备")}>
|
||||
<header className="worktree-setup-header"><span className="worktree-setup-context"><StudioIcon name="folder"/><strong>{workspaceName(stage.sourceRoot)}</strong><span>{t("独立 worktree")}</span></span><h1 ref={heading} tabIndex={-1} aria-live="polite">{t(headline)}</h1><p className="worktree-setup-description">{t(description)}</p></header>
|
||||
<header className="worktree-setup-header"><span className="worktree-setup-context"><StudioIcon name="folder"/><strong>{workspaceName(stage.sourceRoot)}</strong><span>{t("独立工作树")}</span></span><h1 ref={heading} tabIndex={-1} aria-live="polite">{t(headline)}</h1><p className="worktree-setup-description">{t(description)}</p></header>
|
||||
<div className="worktree-setup-phase"><span>{active ? <span className="worktree-spinner"/> : <StudioIcon name={icon}/>}<span>{phase}</span></span>{active && <span className="worktree-setup-elapsed">{t("已等待 {n} 秒", { n: elapsed })}</span>}</div>
|
||||
{slow && <p className="worktree-setup-slow" role="status">{t("仍在等待创建完成。你可以继续等待、查看详情,或取消创建。")}</p>}
|
||||
{stage.phase === "cleanup_failed" && <p className="worktree-setup-slow" role="note">{t("请先保存任务与附件,在终端核对并处理保留的目录和任务分支。确认处理完成后,在会话菜单中关闭此会话,再从源目录新建会话重试。")}</p>}
|
||||
|
||||
@@ -92,9 +92,3 @@ describe("what the appearance page says a person is doing", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("the line width choices", () => {
|
||||
it("offer the composer-too width through the same action as the others", () => {
|
||||
expect(SOURCE).toContain('["full-all", "连输入框一起铺满"');
|
||||
expect(idsOf("appearance.measure").length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
Loaded 100 of 336 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user