fix(persona): preserve first-party guidance and user scope

Problem: project configuration could replace the user persona, and complete
mode could suppress built-in guidance. The UI misstated rebuild semantics.

Root cause: persona was absent from the user-scope hold, replacement was a
supported runtime option, and settings metadata described only new sessions.

Fix: hold and clone the user's persona across project decoding, retire complete
throughout config/runtime/API/UI, and describe next-build activation. Integrate
current studio without dropping its settings or transport contracts.

Verification: gofmt, vet, lint, make check, root and Desktop Go suites, boot
scope/prompt regressions, frontend typecheck/build and 5,369 tests pass.
This commit is contained in:
SivanCola committed 2026-10-18 09:22:31 +08:00
commit e7d50cf0e9
575 files changed
+23059 -2052

No files matched your search

+6
View File
@@ -205,6 +205,12 @@ jobs:
WINDOWS_SANDBOX_WAIT_MS: "20000" WINDOWS_SANDBOX_WAIT_MS: "20000"
run: go test -count=1 -p 4 -timeout=18m ./internal/runtime/agent/... ./internal/platform/appidentity/... ./internal/assembly/boot/... ./internal/state/checkpoint/... ./internal/frontend/cli/... ./internal/session/control/... ./internal/base/filelock/... ./internal/base/fileutil/... ./internal/ext/hook/... ./internal/state/instruction/... ./internal/ext/mcplaunch/... ./internal/platform/notify/... ./internal/base/proc/... ./internal/platform/remote/... ./internal/platform/repair/... ./internal/safety/sandbox/... ./internal/base/sysproxy/... ./internal/state/workspacelease/... ./cmd/... run: go test -count=1 -p 4 -timeout=18m ./internal/runtime/agent/... ./internal/platform/appidentity/... ./internal/assembly/boot/... ./internal/state/checkpoint/... ./internal/frontend/cli/... ./internal/session/control/... ./internal/base/filelock/... ./internal/base/fileutil/... ./internal/ext/hook/... ./internal/state/instruction/... ./internal/ext/mcplaunch/... ./internal/platform/notify/... ./internal/base/proc/... ./internal/platform/remote/... ./internal/platform/repair/... ./internal/safety/sandbox/... ./internal/base/sysproxy/... ./internal/state/workspacelease/... ./cmd/...
# Worktree rollback is absent from the broad Windows PR smoke list.
- name: test (Windows worktree rollback)
if: env.RUN_STEPS == 'true' && runner.os == 'Windows' && github.event_name == 'pull_request'
timeout-minutes: 3
run: go test -count=1 -timeout=2m -run '^TestDiscardUnpublished.*OnWindows$|^TestCreateFromDetachedHEADAndOccupiedBranch$' ./internal/platform/worktree
# The general Windows PR smoke list intentionally omits internal/contract/tool. # The general Windows PR smoke list intentionally omits internal/contract/tool.
# Keep the session-temp portability contract covered without widening the # Keep the session-temp portability contract covered without widening the
# leg to every tool test: two real PowerShell launches must share the # leg to every tool test: two real PowerShell launches must share the
+73
View File
@@ -163,6 +163,13 @@ jobs:
node perf/settings-rows.mjs node perf/settings-rows.mjs
node perf/narrow-steps.mjs node perf/narrow-steps.mjs
node perf/job-names.mjs node perf/job-names.mjs
node perf/mcp-actions.mjs
node perf/web-access.mjs
node perf/peers-settings.mjs
node perf/peer-threads.mjs
node perf/text-size.mjs
node perf/fs-scale.mjs
node perf/composer-feel.mjs
node perf/side-panel.mjs node perf/side-panel.mjs
node perf/card-shell.mjs node perf/card-shell.mjs
node perf/line-cards.mjs node perf/line-cards.mjs
@@ -177,6 +184,72 @@ jobs:
node perf/narrow-rail.mjs node perf/narrow-rail.mjs
node perf/selfcheck.mjs branch-tooltip node perf/selfcheck.mjs branch-tooltip
# What a hosted page does over the relay, end to end: the production bundle,
# the real Studio host and kernel, a real Chromium; only the accounts API and
# the relay are stand-ins (tools/relayfake). Event-transport changes are
# judged here, because a unit test of one side cannot see the other.
relay-harness:
name: relay harness
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
fetch-depth: 0
- name: Transport changed?
id: scope
env:
BASE_REF: ${{ github.base_ref }}
run: |
paths="desktop/frontend-next internal/frontend/serve internal/platform/remotecloud internal/platform/account tools/relayfake cmd/reasonix-studio-host"
if [ "$GITHUB_EVENT_NAME" = "pull_request" ] &&
git diff --quiet "origin/$BASE_REF...HEAD" -- $paths; then
echo "run=false" >> "$GITHUB_OUTPUT"
else
echo "run=true" >> "$GITHUB_OUTPUT"
fi
- if: steps.scope.outputs.run == 'true'
uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7
with:
go-version-file: go.mod
cache: true
- if: steps.scope.outputs.run == 'true'
uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0
with:
version: 10
run_install: false
- if: steps.scope.outputs.run == 'true'
uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7
with:
node-version: "24"
cache: pnpm
cache-dependency-path: desktop/frontend-next/pnpm-lock.yaml
- if: steps.scope.outputs.run == 'true'
name: Build the host, the stand-ins and the page
run: |
go build -o desktop/frontend-next/relay/bin/studio-host ./cmd/reasonix-studio-host
go build -o desktop/frontend-next/relay/bin/relayfake ./tools/relayfake
cd desktop/frontend-next
pnpm install --frozen-lockfile
pnpm exec vite build --outDir dist-relay --emptyOutDir
pnpm exec playwright install --with-deps chromium
- if: steps.scope.outputs.run == 'true'
name: Scenarios (hosted page over the relay; local page on a flapping network)
working-directory: desktop/frontend-next
env:
RELAY_HOST_BIN: ${{ github.workspace }}/desktop/frontend-next/relay/bin/studio-host
RELAY_FAKE_BIN: ${{ github.workspace }}/desktop/frontend-next/relay/bin/relayfake
RELAY_DIST: ${{ github.workspace }}/desktop/frontend-next/dist-relay
run: |
node relay/live.mjs
node relay/storm.mjs
# A pull request with no platform-relevant change (scripts/ci-platform-scope.sh) # A pull request with no platform-relevant change (scripts/ci-platform-scope.sh)
# moves the Windows and macOS legs onto an ubuntu runner with every step # moves the Windows and macOS legs onto an ubuntu runner with every step
# skipped, so the check names still report without a scarce runner. A push to # skipped, so the check names still report without a scarce runner. A push to
+1
View File
@@ -95,3 +95,4 @@ scripts/official-theme-art/__pycache__/
# running, which on Windows is every rebuild while Studio is open. # running, which on Windows is every rebuild while Studio is open.
*.exe *.exe
*.exe~ *.exe~
/relayfake
@@ -0,0 +1,35 @@
# Compaction prefix probe
## Method
1. Measured on 2026-10-17 against the official `https://api.deepseek.com` OpenAI adapter, using the model returned by `/models`: `deepseek-flash`. Thinking was disabled. Inputs were synthetic; no workspace history or credentials are recorded here.
2. Each of three text and three rich-history trials sent a main request to warm the prefix, then called the actual agent summary path, then an independent summary control. Rich history included a synthetic closed `read_file` call, result and reasoning. Warmup outputs were discarded, never executed.
3. A summary had a 1,024-token output cap; warmups had 64. Success means a heading, preservation of the fixture's `ParseDuration` and zero-value constraint, no emitted tool calls, and `finish_reason=stop`. This checks the fixture's contract, not arbitrary-summary factual completeness.
4. First-attempt reuse success counts successful reused summaries / six reused summaries. Fallback rate counts independent requests initiated by the reuse coordinator / six reuse trials; the six explicit independent controls are excluded. Both were 6/6 success and 0/6 fallbacks. Independent controls also succeeded 6/6.
5. The raw result file records provider-reported usage and full stream latency, measured from adapter `Stream` until channel close. Token rows below sum only the three indicated summary requests; they exclude warmup, model listing and any earlier exploratory requests. Cache residency was uncontrolled; prior probes had warmed these synthetic prefixes.
## Results
| History | Shape | N | Prompt | Hit | Miss | Output | Median ms | Estimated USD |
| --- | --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: |
| Text | Reused | 3 | 3,714 | 3,072 | 642 | 636 | 1,713 | 0.000487116 |
| Text | Independent | 3 | 1,218 | 768 | 450 | 644 | 1,610 | 0.000456204 |
| Rich | Reused | 3 | 3,780 | 3,072 | 708 | 742 | 1,774 | 0.000560616 |
| Rich | Independent | 3 | 1,341 | 768 | 573 | 921 | 1,815 | 0.000640854 |
1. Estimates use the [official pricing](https://api-docs.deepseek.com/quick_start/pricing/) checked on the measurement date: off-peak Flash USD per million tokens, hit 0.003, miss 0.15, output 0.6. October 17 was a Saturday. Formula: `(hit*0.003 + miss*0.15 + output*0.6)/1_000_000`. These are token estimates, not account deductions.
2. Reuse hit fractions were 82.7% (text) and 81.3% (rich), versus 63.1% and 57.3% for controls. This small fixture does **not** establish cost savings: retaining the system/catalog increased both total input and input cost. Text total cost increased 6.8%; rich total decreased 12.5% because output was shorter, not because input was cheaper.
3. The six warmups consumed 7,032 prompt and 231 output tokens, estimated USD 0.000346680. All 18 recorded generation calls together cost an estimated USD 0.002491470. No failed-digest retry occurred in these live trials; the deterministic fallback tests establish its behavior and accounting.
4. A rejected nonempty reused answer earns one independent-shape fallback. A separate truncation retry may enlarge the output cap, for at most three logical model calls total. Every actual attempt emits and records its own spend; returned usage is their sum. Empty output, stream errors and cancellation do not earn shape retries.
5. These sequential, six-pair trials establish interoperability for one model, mode and short fixture. They do not estimate production failure probability, thinking-mode behavior, other models, cold-cache cost, or large-history savings.
## Reproduction
1. Provide a test key through an existing secret environment configuration. Do not put credentials in source, command arguments or captured logs. Select a model currently returned by `/models`.
2. Run the opt-in probe; it skips without `DEEPSEEK_API_KEY`. The dedicated build tag avoids enabling unrelated live tests.
```sh
REASONIX_LIVE_MODEL=deepseek-flash go test -tags live_compaction ./internal/runtime/agent -run '^TestLiveCompactionPrefix$' -v -count=1
```
3. This probe exercises actual summary construction and the HTTP adapter. The hermetic `TestEffectCompactionSharesThePreviouslySentPrefix` separately exercises manual, automatic pressure and rich history through `boot.Build`, comparing actual JSON request-prefix and schema bytes. Its sparse-history case also proves an interleaved retained user turn survives the fold.
@@ -0,0 +1,512 @@
[
{
"model": "deepseek-flash",
"rich": false,
"trial": 1,
"results": [
{
"kind": "warm",
"milliseconds": 828,
"digest": false,
"grounded": false,
"tool_calls": 1,
"usage": {
"PromptTokens": 1127,
"CompletionTokens": 38,
"TotalTokens": 1165,
"CacheHitTokens": 896,
"CacheMissTokens": 231,
"CacheWriteTokens": 0,
"CacheWriteBilledTokens": 0,
"ReasoningTokens": 0,
"FinishReason": "tool_calls",
"Estimated": false,
"RequestCount": 1,
"ServerToolRequests": 0,
"ContextPromptTokens": 0,
"ContextCompletionTokens": 0,
"ContextReasoningTokens": 0,
"ContextCacheHitTokens": 0,
"ContextCacheMissTokens": 0
}
},
{
"kind": "reuse",
"milliseconds": 2006,
"digest": true,
"grounded": true,
"tool_calls": 0,
"usage": {
"PromptTokens": 1238,
"CompletionTokens": 234,
"TotalTokens": 1472,
"CacheHitTokens": 1024,
"CacheMissTokens": 214,
"CacheWriteTokens": 0,
"CacheWriteBilledTokens": 0,
"ReasoningTokens": 0,
"FinishReason": "stop",
"Estimated": false,
"RequestCount": 1,
"ServerToolRequests": 0,
"ContextPromptTokens": 0,
"ContextCompletionTokens": 0,
"ContextReasoningTokens": 0,
"ContextCacheHitTokens": 0,
"ContextCacheMissTokens": 0
}
},
{
"kind": "independent",
"milliseconds": 1817,
"digest": true,
"grounded": true,
"tool_calls": 0,
"usage": {
"PromptTokens": 406,
"CompletionTokens": 234,
"TotalTokens": 640,
"CacheHitTokens": 256,
"CacheMissTokens": 150,
"CacheWriteTokens": 0,
"CacheWriteBilledTokens": 0,
"ReasoningTokens": 0,
"FinishReason": "stop",
"Estimated": false,
"RequestCount": 1,
"ServerToolRequests": 0,
"ContextPromptTokens": 0,
"ContextCompletionTokens": 0,
"ContextReasoningTokens": 0,
"ContextCacheHitTokens": 0,
"ContextCacheMissTokens": 0
}
}
]
},
{
"model": "deepseek-flash",
"rich": false,
"trial": 2,
"results": [
{
"kind": "warm",
"milliseconds": 820,
"digest": false,
"grounded": false,
"tool_calls": 1,
"usage": {
"PromptTokens": 1127,
"CompletionTokens": 38,
"TotalTokens": 1165,
"CacheHitTokens": 896,
"CacheMissTokens": 231,
"CacheWriteTokens": 0,
"CacheWriteBilledTokens": 0,
"ReasoningTokens": 0,
"FinishReason": "tool_calls",
"Estimated": false,
"RequestCount": 1,
"ServerToolRequests": 0,
"ContextPromptTokens": 0,
"ContextCompletionTokens": 0,
"ContextReasoningTokens": 0,
"ContextCacheHitTokens": 0,
"ContextCacheMissTokens": 0
}
},
{
"kind": "reuse",
"milliseconds": 1633,
"digest": true,
"grounded": true,
"tool_calls": 0,
"usage": {
"PromptTokens": 1238,
"CompletionTokens": 187,
"TotalTokens": 1425,
"CacheHitTokens": 1024,
"CacheMissTokens": 214,
"CacheWriteTokens": 0,
"CacheWriteBilledTokens": 0,
"ReasoningTokens": 0,
"FinishReason": "stop",
"Estimated": false,
"RequestCount": 1,
"ServerToolRequests": 0,
"ContextPromptTokens": 0,
"ContextCompletionTokens": 0,
"ContextReasoningTokens": 0,
"ContextCacheHitTokens": 0,
"ContextCacheMissTokens": 0
}
},
{
"kind": "independent",
"milliseconds": 1555,
"digest": true,
"grounded": true,
"tool_calls": 0,
"usage": {
"PromptTokens": 406,
"CompletionTokens": 202,
"TotalTokens": 608,
"CacheHitTokens": 256,
"CacheMissTokens": 150,
"CacheWriteTokens": 0,
"CacheWriteBilledTokens": 0,
"ReasoningTokens": 0,
"FinishReason": "stop",
"Estimated": false,
"RequestCount": 1,
"ServerToolRequests": 0,
"ContextPromptTokens": 0,
"ContextCompletionTokens": 0,
"ContextReasoningTokens": 0,
"ContextCacheHitTokens": 0,
"ContextCacheMissTokens": 0
}
}
]
},
{
"model": "deepseek-flash",
"rich": false,
"trial": 3,
"results": [
{
"kind": "warm",
"milliseconds": 905,
"digest": false,
"grounded": false,
"tool_calls": 1,
"usage": {
"PromptTokens": 1127,
"CompletionTokens": 38,
"TotalTokens": 1165,
"CacheHitTokens": 896,
"CacheMissTokens": 231,
"CacheWriteTokens": 0,
"CacheWriteBilledTokens": 0,
"ReasoningTokens": 0,
"FinishReason": "tool_calls",
"Estimated": false,
"RequestCount": 1,
"ServerToolRequests": 0,
"ContextPromptTokens": 0,
"ContextCompletionTokens": 0,
"ContextReasoningTokens": 0,
"ContextCacheHitTokens": 0,
"ContextCacheMissTokens": 0
}
},
{
"kind": "reuse",
"milliseconds": 1713,
"digest": true,
"grounded": true,
"tool_calls": 0,
"usage": {
"PromptTokens": 1238,
"CompletionTokens": 215,
"TotalTokens": 1453,
"CacheHitTokens": 1024,
"CacheMissTokens": 214,
"CacheWriteTokens": 0,
"CacheWriteBilledTokens": 0,
"ReasoningTokens": 0,
"FinishReason": "stop",
"Estimated": false,
"RequestCount": 1,
"ServerToolRequests": 0,
"ContextPromptTokens": 0,
"ContextCompletionTokens": 0,
"ContextReasoningTokens": 0,
"ContextCacheHitTokens": 0,
"ContextCacheMissTokens": 0
}
},
{
"kind": "independent",
"milliseconds": 1610,
"digest": true,
"grounded": true,
"tool_calls": 0,
"usage": {
"PromptTokens": 406,
"CompletionTokens": 208,
"TotalTokens": 614,
"CacheHitTokens": 256,
"CacheMissTokens": 150,
"CacheWriteTokens": 0,
"CacheWriteBilledTokens": 0,
"ReasoningTokens": 0,
"FinishReason": "stop",
"Estimated": false,
"RequestCount": 1,
"ServerToolRequests": 0,
"ContextPromptTokens": 0,
"ContextCompletionTokens": 0,
"ContextReasoningTokens": 0,
"ContextCacheHitTokens": 0,
"ContextCacheMissTokens": 0
}
}
]
},
{
"model": "deepseek-flash",
"rich": true,
"trial": 1,
"results": [
{
"kind": "warm",
"milliseconds": 561,
"digest": false,
"grounded": false,
"tool_calls": 1,
"usage": {
"PromptTokens": 1217,
"CompletionTokens": 39,
"TotalTokens": 1256,
"CacheHitTokens": 1024,
"CacheMissTokens": 193,
"CacheWriteTokens": 0,
"CacheWriteBilledTokens": 0,
"ReasoningTokens": 0,
"FinishReason": "tool_calls",
"Estimated": false,
"RequestCount": 1,
"ServerToolRequests": 0,
"ContextPromptTokens": 0,
"ContextCompletionTokens": 0,
"ContextReasoningTokens": 0,
"ContextCacheHitTokens": 0,
"ContextCacheMissTokens": 0
}
},
{
"kind": "reuse",
"milliseconds": 1774,
"digest": true,
"grounded": true,
"tool_calls": 0,
"usage": {
"PromptTokens": 1260,
"CompletionTokens": 256,
"TotalTokens": 1516,
"CacheHitTokens": 1024,
"CacheMissTokens": 236,
"CacheWriteTokens": 0,
"CacheWriteBilledTokens": 0,
"ReasoningTokens": 0,
"FinishReason": "stop",
"Estimated": false,
"RequestCount": 1,
"ServerToolRequests": 0,
"ContextPromptTokens": 0,
"ContextCompletionTokens": 0,
"ContextReasoningTokens": 0,
"ContextCacheHitTokens": 0,
"ContextCacheMissTokens": 0
}
},
{
"kind": "independent",
"milliseconds": 2271,
"digest": true,
"grounded": true,
"tool_calls": 0,
"usage": {
"PromptTokens": 447,
"CompletionTokens": 381,
"TotalTokens": 828,
"CacheHitTokens": 256,
"CacheMissTokens": 191,
"CacheWriteTokens": 0,
"CacheWriteBilledTokens": 0,
"ReasoningTokens": 0,
"FinishReason": "stop",
"Estimated": false,
"RequestCount": 1,
"ServerToolRequests": 0,
"ContextPromptTokens": 0,
"ContextCompletionTokens": 0,
"ContextReasoningTokens": 0,
"ContextCacheHitTokens": 0,
"ContextCacheMissTokens": 0
}
}
]
},
{
"model": "deepseek-flash",
"rich": true,
"trial": 2,
"results": [
{
"kind": "warm",
"milliseconds": 610,
"digest": false,
"grounded": false,
"tool_calls": 1,
"usage": {
"PromptTokens": 1217,
"CompletionTokens": 39,
"TotalTokens": 1256,
"CacheHitTokens": 1024,
"CacheMissTokens": 193,
"CacheWriteTokens": 0,
"CacheWriteBilledTokens": 0,
"ReasoningTokens": 0,
"FinishReason": "tool_calls",
"Estimated": false,
"RequestCount": 1,
"ServerToolRequests": 0,
"ContextPromptTokens": 0,
"ContextCompletionTokens": 0,
"ContextReasoningTokens": 0,
"ContextCacheHitTokens": 0,
"ContextCacheMissTokens": 0
}
},
{
"kind": "reuse",
"milliseconds": 2263,
"digest": true,
"grounded": true,
"tool_calls": 0,
"usage": {
"PromptTokens": 1260,
"CompletionTokens": 276,
"TotalTokens": 1536,
"CacheHitTokens": 1024,
"CacheMissTokens": 236,
"CacheWriteTokens": 0,
"CacheWriteBilledTokens": 0,
"ReasoningTokens": 0,
"FinishReason": "stop",
"Estimated": false,
"RequestCount": 1,
"ServerToolRequests": 0,
"ContextPromptTokens": 0,
"ContextCompletionTokens": 0,
"ContextReasoningTokens": 0,
"ContextCacheHitTokens": 0,
"ContextCacheMissTokens": 0
}
},
{
"kind": "independent",
"milliseconds": 1783,
"digest": true,
"grounded": true,
"tool_calls": 0,
"usage": {
"PromptTokens": 447,
"CompletionTokens": 242,
"TotalTokens": 689,
"CacheHitTokens": 256,
"CacheMissTokens": 191,
"CacheWriteTokens": 0,
"CacheWriteBilledTokens": 0,
"ReasoningTokens": 0,
"FinishReason": "stop",
"Estimated": false,
"RequestCount": 1,
"ServerToolRequests": 0,
"ContextPromptTokens": 0,
"ContextCompletionTokens": 0,
"ContextReasoningTokens": 0,
"ContextCacheHitTokens": 0,
"ContextCacheMissTokens": 0
}
}
]
},
{
"model": "deepseek-flash",
"rich": true,
"trial": 3,
"results": [
{
"kind": "warm",
"milliseconds": 834,
"digest": false,
"grounded": false,
"tool_calls": 1,
"usage": {
"PromptTokens": 1217,
"CompletionTokens": 39,
"TotalTokens": 1256,
"CacheHitTokens": 1024,
"CacheMissTokens": 193,
"CacheWriteTokens": 0,
"CacheWriteBilledTokens": 0,
"ReasoningTokens": 0,
"FinishReason": "tool_calls",
"Estimated": false,
"RequestCount": 1,
"ServerToolRequests": 0,
"ContextPromptTokens": 0,
"ContextCompletionTokens": 0,
"ContextReasoningTokens": 0,
"ContextCacheHitTokens": 0,
"ContextCacheMissTokens": 0
}
},
{
"kind": "reuse",
"milliseconds": 1575,
"digest": true,
"grounded": true,
"tool_calls": 0,
"usage": {
"PromptTokens": 1260,
"CompletionTokens": 210,
"TotalTokens": 1470,
"CacheHitTokens": 1024,
"CacheMissTokens": 236,
"CacheWriteTokens": 0,
"CacheWriteBilledTokens": 0,
"ReasoningTokens": 0,
"FinishReason": "stop",
"Estimated": false,
"RequestCount": 1,
"ServerToolRequests": 0,
"ContextPromptTokens": 0,
"ContextCompletionTokens": 0,
"ContextReasoningTokens": 0,
"ContextCacheHitTokens": 0,
"ContextCacheMissTokens": 0
}
},
{
"kind": "independent",
"milliseconds": 1815,
"digest": true,
"grounded": true,
"tool_calls": 0,
"usage": {
"PromptTokens": 447,
"CompletionTokens": 298,
"TotalTokens": 745,
"CacheHitTokens": 256,
"CacheMissTokens": 191,
"CacheWriteTokens": 0,
"CacheWriteBilledTokens": 0,
"ReasoningTokens": 0,
"FinishReason": "stop",
"Estimated": false,
"RequestCount": 1,
"ServerToolRequests": 0,
"ContextPromptTokens": 0,
"ContextCompletionTokens": 0,
"ContextReasoningTokens": 0,
"ContextCacheHitTokens": 0,
"ContextCacheMissTokens": 0
}
}
]
}
]
+149 -19
View File
@@ -45,26 +45,72 @@ func killedRun(t *testing.T) result {
return r return r
} }
// A run whose final metrics never landed has no harness-side accounting. The // writeSilentAgentStub writes a stand-in that leaves neither a final record nor
// report already has the state for that (Unaccounted) and swebench mode sets it; // a snapshot: what it spent is simply gone, so nothing can bound it.
// the suite path must not fold such a run in as an accounted zero, or every func writeSilentAgentStub(t *testing.T, dir string) string {
// published total silently omits what it spent. t.Helper()
func TestSuiteRunWithoutFinalMetricsIsNotCountedAsAccounted(t *testing.T) { requireShellStub(t)
path := filepath.Join(dir, "silent-agent.sh")
if err := os.WriteFile(path, []byte("#!/bin/sh\nexit 0\n"), 0o755); err != nil {
t.Fatalf("write stub: %v", err)
}
return path
}
// A run recovered from its snapshot is still accounted: its numbers are real and
// stop at the last write, which is exactly what Partial says. Marking it
// Unaccounted would drop them from every total and hide the lower bound.
func TestSuiteSidecarRecoveredRunStaysAccountedAsALowerBound(t *testing.T) {
r := killedRun(t) r := killedRun(t)
if r.Unaccounted || !r.Partial {
t.Fatalf("a run recovered from its snapshot was reported as unmeasured:\n"+
" partial=%v unaccounted=%v, want partial=true unaccounted=false", r.Partial, r.Unaccounted)
}
stats := gatherSuiteStats([]result{r}) stats := gatherSuiteStats([]result{r})
if stats.unaccounted != 1 { if stats.unaccounted != 0 || stats.accounted != 1 || stats.partial != 1 {
t.Fatalf("a run that wrote no final metrics counted as accounted:\n"+ t.Fatalf("a recovered run changed category:\n"+
" unaccounted=%d accounted=%d tokens=%d cost=%.4f\n"+ " unaccounted=%d accounted=%d partial=%d", stats.unaccounted, stats.accounted, stats.partial)
" (prompt_tokens=%d completion_tokens=%d, from the result itself)", }
stats.unaccounted, stats.accounted, stats.pTok+stats.cTok, stats.cost, if stats.pTok != 1200 || stats.cTok != 80 || stats.cost != 0.0123 {
r.PromptTokens, r.CompletionTokens) t.Fatalf("the recovered numbers never reached the aggregates:\n"+
" tokens=%d/%d cost=%.4f, want 1200/80 at 0.0123", stats.pTok, stats.cTok, stats.cost)
}
if out := renderBody([]result{r}); !strings.Contains(out, "contributed partial accounting") {
t.Fatalf("the lower-bound hint is unreachable:\n%s", out)
}
}
// A leg that leaves neither record has no surviving evidence at all, so the run
// is unmeasured and must stay out of the totals rather than count as zero.
func TestSuiteRunWithNoRecordAtAllIsUnaccounted(t *testing.T) {
work := testenv.TempDir(t)
tk := task{ID: "silent-task", Prompt: "do the thing"}
r := result{task: tk}
cfg := suiteConfig{bin: writeSilentAgentStub(t, work)}
if err := runSegments(context.Background(), cfg, tk, work, "", nil, &r); err != nil {
t.Fatalf("the stub exits 0; a silent leg must not fail the run: %v", err)
}
if !r.Unaccounted || r.Partial {
t.Fatalf("a run that left no record at all was not marked unmeasured:\n"+
" partial=%v unaccounted=%v, want partial=false unaccounted=true", r.Partial, r.Unaccounted)
}
stats := gatherSuiteStats([]result{r})
if stats.unaccounted != 1 || stats.accounted != 0 {
t.Fatalf("a record-less run was counted as accounted:\n"+
" unaccounted=%d accounted=%d", stats.unaccounted, stats.accounted)
}
if stats.pTok != 0 || stats.cTok != 0 || stats.cost != 0 {
t.Fatalf("a record-less run contributed numbers: tokens=%d/%d cost=%.4f",
stats.pTok, stats.cTok, stats.cost)
}
if out := renderBody([]result{r}); !strings.Contains(out, "Accounting incomplete for 1 of 1 instances") {
t.Fatalf("the record-less run was not disclosed as unaccounted:\n%s", out)
} }
} }
// The killed child did leave its numbers: the metrics sink snapshots in flight // The killed child did leave its numbers: the metrics sink snapshots in flight
// into <metrics>.partial. Swebench mode reads that sidecar; the suite path // into <metrics>.partial. Swebench mode reads that sidecar; the suite path must
// deletes the directory without looking, so the evidence is destroyed rather // do the same rather than let the directory removal destroy the evidence.
// than being missing.
func TestSuiteRunRecoversTheInFlightSnapshotItsChildLeft(t *testing.T) { func TestSuiteRunRecoversTheInFlightSnapshotItsChildLeft(t *testing.T) {
r := killedRun(t) r := killedRun(t)
if r.PromptTokens != 1200 || r.CompletionTokens != 80 { if r.PromptTokens != 1200 || r.CompletionTokens != 80 {
@@ -75,14 +121,98 @@ func TestSuiteRunRecoversTheInFlightSnapshotItsChildLeft(t *testing.T) {
} }
} }
// The meter-vs-harness divergence line divides one side by the other. A run // The meter-vs-harness divergence line divides one side by the other. Whether a
// whose harness-side number never landed has no side, so folding its meter // run has a harness side is the suite's Unaccounted state, not the token values:
// tokens against a silent zero invents a divergence and blames the harness for // a run with no record at all has no side, and pricing it against the meter
// it. // invents a divergence and blames the harness for it.
func TestMeterDivergenceDoesNotPriceAMetricslessRun(t *testing.T) { func TestMeterDivergenceDoesNotPriceAMetricslessRun(t *testing.T) {
r := result{task: task{ID: "killed"}, Meter: &meterUsage{PromptTokens: 1200, CompletionTokens: 80}} r := result{task: task{ID: "killed"}, Unaccounted: true,
Meter: &meterUsage{PromptTokens: 1200, CompletionTokens: 80}}
got := renderMeterAccounting([]result{r}) got := renderMeterAccounting([]result{r})
if strings.Contains(got, "self-report divergence") { if strings.Contains(got, "self-report divergence") {
t.Fatalf("a run with no harness-side number was priced as a self-report divergence:\n%s", got) t.Fatalf("a run with no harness-side number was priced as a self-report divergence:\n%s", got)
} }
} }
// A persisted record that says zero tokens is a measurement of zero, not an
// absence: the harness side exists, so it belongs on both sides of the ratio
// even when the number it reports disagrees with the meter.
func TestMeterAccountingKeepsAPersistedZeroOnBothSides(t *testing.T) {
r := result{task: task{ID: "zero"}, Meter: &meterUsage{PromptTokens: 1200, CompletionTokens: 80}}
got := renderMeterAccounting([]result{r})
if !strings.Contains(got, "-100.0% (harness 0 vs meter 1,280 over 1 runs)") {
t.Fatalf("a real zero-token measurement was dropped as if it were missing:\n%s", got)
}
}
func TestCorrectBoundaryMediansExcludeRunsWithNoRecordedSplit(t *testing.T) {
cp := []checkpoint{{Seq: 1, ElapsedMs: 1_000}, {Seq: 2, ElapsedMs: 2_000, Pass: true}}
measured := result{
task: task{ID: "measured"}, Passed: true, WallMs: 200_000, Checkpoints: cp,
FirstCorrectMs: 50_000, PostSolveWasteMs: 150_000,
MutationsBeforeCorrect: 1, RoundsBeforeCorrect: 3, RoundsAfterCorrect: 4,
CallsAfterCorrect: 5, VerifyAfterCorrect: 2,
Trajectory: &trajectorySummary{ModelRounds: 8},
}
// the same run twice, but -trajectories was omitted: no digest, so no split
// was ever taken and the before/after tallies stay at their zero value.
unmeasured := result{
task: task{ID: "unmeasured"}, Passed: true, WallMs: 200_000, Checkpoints: cp,
FirstCorrectMs: 50_000, PostSolveWasteMs: 150_000,
MutationsBeforeCorrect: 1,
}
unmeasured2 := unmeasured
unmeasured2.task = task{ID: "unmeasured-2"}
// The audit read the rendered line as plain text; the renderer emits
// markdown emphasis around each label, so compare with it stripped.
const want = "**rounds before** 3 · **rounds after** 4"
got := renderSolveProfiles([]result{measured, unmeasured, unmeasured2})
if !strings.Contains(got, want) {
t.Fatalf("medians folded in a run whose trajectory was never split:\n%s\n"+
"want %q (the measured run only)", got, want)
}
}
// Control (a): when every run recorded its split, the exclusion must not touch
// a single number — the medians stay the measured ones.
func TestCorrectBoundaryMediansAllMeasuredUnchanged(t *testing.T) {
cp := []checkpoint{{Seq: 1, ElapsedMs: 1_000}, {Seq: 2, ElapsedMs: 2_000, Pass: true}}
mk := func(id string, before, after, verify, mutations int) result {
return result{
task: task{ID: id}, Passed: true, WallMs: 200_000, Checkpoints: cp,
FirstCorrectMs: 50_000, PostSolveWasteMs: 150_000,
RoundsBeforeCorrect: before, RoundsAfterCorrect: after,
VerifyAfterCorrect: verify, MutationsBeforeCorrect: mutations,
Trajectory: &trajectorySummary{ModelRounds: 8},
}
}
got := renderSolveProfiles([]result{mk("a", 2, 6, 1, 0), mk("b", 3, 4, 2, 1), mk("c", 4, 5, 3, 2)})
const want = "**mutations before** 1 · **rounds before** 3 · **rounds after** 5 · **verifications after** 2"
if !strings.Contains(got, want) {
t.Fatalf("all-measured medians changed:\n%s\nwant %q", got, want)
}
}
// Control (b): with -trajectories off no split was ever taken for any run, so
// there is nothing to average. Printing zeros would publish them as
// observations; the report must say the boundary is unmeasured instead.
func TestCorrectBoundaryWithNoRecordedSplitSaysNothingToAverage(t *testing.T) {
cp := []checkpoint{{Seq: 1, ElapsedMs: 1_000}, {Seq: 2, ElapsedMs: 2_000, Pass: true}}
run := func(id string, mutations int) result {
return result{
task: task{ID: id}, Passed: true, WallMs: 200_000, Checkpoints: cp,
FirstCorrectMs: 50_000, PostSolveWasteMs: 150_000,
MutationsBeforeCorrect: mutations,
}
}
got := renderSolveProfiles([]result{run("a", 1), run("b", 3)})
if strings.Contains(got, "rounds before") || strings.Contains(got, "verifications after") {
t.Fatalf("a boundary that was never split printed as a measurement:\n%s", got)
}
if !strings.Contains(got, "**mutations before** 3") {
t.Fatalf("the checkpoint-derived mutation count is measured without a trajectory and must still be printed:\n%s", got)
}
if !strings.Contains(got, "nothing to average") {
t.Fatalf("the report must say the boundary was never measured:\n%s", got)
}
}
+19 -7
View File
@@ -195,7 +195,7 @@ func renderSolveProfiles(results []result) string {
} }
line := "**Solve profile**: " line := "**Solve profile**: "
parts := []string{} parts := []string{}
regressed, withCorrect := 0, 0 regressed, withCorrect, split := 0, 0, 0
var roundsBefore, roundsAfter, verifyAfter, mutationsBefore []int64 var roundsBefore, roundsAfter, verifyAfter, mutationsBefore []int64
for _, r := range results { for _, r := range results {
if len(r.Checkpoints) == 0 { if len(r.Checkpoints) == 0 {
@@ -203,10 +203,16 @@ func renderSolveProfiles(results []result) string {
} }
if r.FirstCorrectMs > 0 { if r.FirstCorrectMs > 0 {
withCorrect++ withCorrect++
roundsBefore = append(roundsBefore, int64(r.RoundsBeforeCorrect)) // Fold in only runs whose trajectory was read and split (Trajectory
roundsAfter = append(roundsAfter, int64(r.RoundsAfterCorrect)) // is set exactly when -trajectories ran): a run without one has no
verifyAfter = append(verifyAfter, int64(r.VerifyAfterCorrect)) // measurement, and its zeros are absence, not a zero.
mutationsBefore = append(mutationsBefore, int64(r.MutationsBeforeCorrect)) mutationsBefore = append(mutationsBefore, int64(r.MutationsBeforeCorrect))
if r.Trajectory != nil {
split++
roundsBefore = append(roundsBefore, int64(r.RoundsBeforeCorrect))
roundsAfter = append(roundsAfter, int64(r.RoundsAfterCorrect))
verifyAfter = append(verifyAfter, int64(r.VerifyAfterCorrect))
}
} }
if r.RegressedAfterCorrect { if r.RegressedAfterCorrect {
regressed++ regressed++
@@ -247,9 +253,15 @@ func renderSolveProfiles(results []result) string {
ttfum = append(ttfum, r.FirstUsefulMs) ttfum = append(ttfum, r.FirstUsefulMs)
} }
} }
line += fmt.Sprintf("\n\n**Correct boundary** (medians): **mutations before** %d · **rounds before** %d · **rounds after** %d · **verifications after** %d · **regression-after-correct** %s", if split == 0 {
median(mutationsBefore), median(roundsBefore), median(roundsAfter), median(verifyAfter), // The split counts are absent; the checkpoint-derived ones are not.
pct(regressed, withCorrect)) line += fmt.Sprintf("\n\n**Correct boundary**: **mutations before** %d · **regression-after-correct** %s — nothing to average: the runs reached a correct state, but none recorded a trajectory, so the boundary was never split",
median(mutationsBefore), pct(regressed, withCorrect))
} else {
line += fmt.Sprintf("\n\n**Correct boundary** (medians): **mutations before** %d · **rounds before** %d · **rounds after** %d · **verifications after** %d · **regression-after-correct** %s",
median(mutationsBefore), median(roundsBefore), median(roundsAfter), median(verifyAfter),
pct(regressed, withCorrect))
}
if len(ttfum) > 0 { if len(ttfum) > 0 {
line += fmt.Sprintf(" · **TTFUM median** %s", dur(median(ttfum))) line += fmt.Sprintf(" · **TTFUM median** %s", dur(median(ttfum)))
} }
+4 -4
View File
@@ -116,10 +116,10 @@ func renderMeterAccounting(results []result) string {
miss += r.Meter.CacheMissTokens miss += r.Meter.CacheMissTokens
unmeasured += r.Meter.WithoutUsage unmeasured += r.Meter.WithoutUsage
injected += r.Meter.Injected injected += r.Meter.Injected
// A run whose metrics file never landed has no harness number, so it // Whether the harness has a side to compare is the suite's state, not a
// stays out of both sides of the comparison. A metered run whose // token count: Unaccounted has no record and Partial stops at its last
// harness reported no tokens at all is the same absence, not a ratio. // snapshot, so neither side is whole. A persisted zero is a measurement.
if r.Unaccounted || r.PromptTokens+r.CompletionTokens == 0 { if r.Unaccounted || r.Partial {
continue continue
} }
selfReported++ selfReported++
+6 -7
View File
@@ -51,15 +51,14 @@ func runSegments(ctx context.Context, cfg suiteConfig, t task, work, trajDir str
m, partial, err := legMetrics(metricsPath) m, partial, err := legMetrics(metricsPath)
if err != nil { if err != nil {
// The leg left no record at all: its real spend is unknown, so the // The leg left no record at all: nothing survives to say what it
// run is reported as unmeasured rather than priced at zero. // spent, so the run is unmeasured rather than priced at zero.
r.Unaccounted = true r.Unaccounted = true
} else { } else {
// A leg that finished wrote the final record; one that was killed // A snapshot is real evidence, so the run stays accounted and only
// only left the sidecar snapshot, so the account stops at the last // Partial says its numbers stop at the last write. It is sticky: a
// write and the run's total is still incomplete. // complete later leg cannot restore the tail an earlier one lost.
r.Unaccounted = r.Unaccounted || partial r.Partial = r.Partial || partial || !m.Complete
r.Partial = partial || !m.Complete
keepMetrics = keepMetrics || partial keepMetrics = keepMetrics || partial
foldSegmentMetrics(r, m, seg.index) foldSegmentMetrics(r, m, seg.index)
} }
+97 -6
View File
@@ -50,6 +50,36 @@ func recordedMetricsPath(t *testing.T, work string) string {
return got return got
} }
// twoLegStub writes a stand-in whose legs differ: the first leaves only the
// in-flight snapshot, the second lands its final record. Both exit 0, which is
// what lets the run reach the second leg after the first one stopped short.
func twoLegStub(t *testing.T, name string) string {
t.Helper()
requireShellStub(t)
script := `#!/bin/sh
metrics=""
prev=""
for arg in "$@"; do
if [ "$prev" = "--metrics" ]; then metrics="$arg"; fi
prev="$arg"
done
case "$metrics" in
*-1.json)
printf '%s' '{"prompt_tokens":1200,"completion_tokens":80,"steps":9,"cost":0.0123,"currency":"USD","complete":false}' > "$metrics.partial"
;;
*)
printf '%s' '{"prompt_tokens":300,"completion_tokens":20,"steps":2,"cost":0.001,"currency":"USD","complete":true}' > "$metrics"
;;
esac
exit 0
`
path := filepath.Join(t.TempDir(), name)
if err := os.WriteFile(path, []byte(script), 0o755); err != nil {
t.Fatalf("write stub: %v", err)
}
return path
}
// A run that lands its final metrics record is untouched by the recovery // A run that lands its final metrics record is untouched by the recovery
// path: it stays accounted, keeps its real numbers and is still not partial. // path: it stays accounted, keeps its real numbers and is still not partial.
func TestSuiteHealthyRunKeepsItsFinalAccount(t *testing.T) { func TestSuiteHealthyRunKeepsItsFinalAccount(t *testing.T) {
@@ -63,9 +93,9 @@ func TestSuiteHealthyRunKeepsItsFinalAccount(t *testing.T) {
t.Fatalf("a healthy leg must not fail the run: %v", err) t.Fatalf("a healthy leg must not fail the run: %v", err)
} }
stats := gatherSuiteStats([]result{r}) stats := gatherSuiteStats([]result{r})
if stats.unaccounted != 0 || stats.accounted != 1 { if stats.unaccounted != 0 || stats.accounted != 1 || stats.partial != 0 {
t.Fatalf("a healthy run changed category: unaccounted=%d accounted=%d", t.Fatalf("a healthy run changed category: unaccounted=%d accounted=%d partial=%d",
stats.unaccounted, stats.accounted) stats.unaccounted, stats.accounted, stats.partial)
} }
if stats.pTok != 900 || stats.cTok != 60 || stats.cost != 0.02 { if stats.pTok != 900 || stats.cTok != 60 || stats.cost != 0.02 {
t.Fatalf("a healthy run's numbers changed: tokens=%d/%d cost=%.4f", t.Fatalf("a healthy run's numbers changed: tokens=%d/%d cost=%.4f",
@@ -104,11 +134,52 @@ func TestSuiteRunKeepsTheSnapshotItRecovered(t *testing.T) {
t.Fatalf("the snapshot was kept but not recovered: tokens=%d/%d", t.Fatalf("the snapshot was kept but not recovered: tokens=%d/%d",
r.PromptTokens, r.CompletionTokens) r.PromptTokens, r.CompletionTokens)
} }
// Recovering the numbers must not also mark the run unmeasured: the snapshot
// is evidence, so the run stays accounted and Partial marks it a lower bound.
if r.Unaccounted || !r.Partial {
t.Fatalf("a recovered snapshot was not reported as accounted but partial: partial=%v unaccounted=%v",
r.Partial, r.Unaccounted)
}
} }
// TestMeterAccountingSkipsAnUnaccountedRunWithTokens pins the other term of the // A lower bound belongs to the run, not to its last leg: a complete second leg
// guard: a run the suite already marked unmeasured stays out of the ratio even // cannot restore the tail a killed first leg stopped short of, so once any leg
// when the harness number is present, so a partial account is not priced. // ends at a snapshot the whole run stays partial — and still counts.
func TestSuitePartialFirstLegMakesTheWholeRunALowerBound(t *testing.T) {
work := testenv.TempDir(t)
tk := task{ID: "two-leg-partial", Prompt: "do the thing", MaxSteps: 10}
cfg := suiteConfig{bin: twoLegStub(t, "stub-two-leg.sh"), segments: 2}
r := result{task: tk}
if err := runSegments(context.Background(), cfg, tk, work, "", nil, &r); err != nil {
t.Fatalf("both legs exit 0; the run must not fail: %v", err)
}
if r.Segments != 2 {
t.Fatalf("the run did not take both legs: segments=%d", r.Segments)
}
// The second leg's completeness says nothing about the first leg's tail.
if !r.Partial || r.Unaccounted {
t.Fatalf("a complete last leg erased the first leg's lower bound:\n"+
" partial=%v unaccounted=%v, want partial=true unaccounted=false", r.Partial, r.Unaccounted)
}
// Partial bounds the account; it does not exclude it. Both legs are counted.
if r.PromptTokens != 1500 || r.CompletionTokens != 100 {
t.Fatalf("the legs' spend was not folded together: tokens=%d/%d, want 1500/100",
r.PromptTokens, r.CompletionTokens)
}
stats := gatherSuiteStats([]result{r})
if stats.accounted != 1 || stats.unaccounted != 0 || stats.partial != 1 || stats.pTok != 1500 {
t.Fatalf("a two-leg partial run changed category:\n"+
" accounted=%d unaccounted=%d partial=%d pTok=%d, want 1/0/1/1500",
stats.accounted, stats.unaccounted, stats.partial, stats.pTok)
}
if out := renderBody([]result{r}); !strings.Contains(out, "contributed partial accounting") {
t.Fatalf("the run was counted but not disclosed as a lower bound:\n%s", out)
}
}
// TestMeterAccountingSkipsAnUnaccountedRunWithTokens pins the guard: a run the
// suite marked unmeasured stays out of the ratio even when a harness number is
// present, so a run with no record is not priced.
func TestMeterAccountingSkipsAnUnaccountedRunWithTokens(t *testing.T) { func TestMeterAccountingSkipsAnUnaccountedRunWithTokens(t *testing.T) {
r := result{ r := result{
task: task{ID: "killed"}, task: task{ID: "killed"},
@@ -121,3 +192,23 @@ func TestMeterAccountingSkipsAnUnaccountedRunWithTokens(t *testing.T) {
t.Fatalf("an unaccounted run with a harness number was priced as a self-report divergence:\n%s", got) t.Fatalf("an unaccounted run with a harness number was priced as a self-report divergence:\n%s", got)
} }
} }
// The same guard has to cover a lower bound: a Partial run's harness side stops
// at its last snapshot while the meter saw the whole leg, so pricing it reads as
// a divergence that is only the missing tail.
func TestMeterAccountingSkipsAPartialRunWithTokens(t *testing.T) {
r := result{
task: task{ID: "lower-bound"},
runMetrics: runMetrics{PromptTokens: 100, CompletionTokens: 10},
Partial: true,
Meter: &meterUsage{PromptTokens: 1200, CompletionTokens: 80},
}
got := renderMeterAccounting([]result{r})
if strings.Contains(got, "self-report divergence") {
t.Fatalf("a partial run's missing tail was priced as a self-report divergence:\n%s", got)
}
// Its meter tokens are still a real measurement, so the totals keep them.
if !strings.Contains(got, "tokens 1,280") {
t.Fatalf("skipping the ratio also dropped the run's metered tokens:\n%s", got)
}
}
+12
View File
@@ -84,3 +84,15 @@ did not specify a dark layout. Phone captures confirm no horizontal overflow.
Windows native installer acceptance and hosted CI are separate from these Windows native installer acceptance and hosted CI are separate from these
local results. The production build retains its existing large-chunk warning. local results. The production build retains its existing large-chunk warning.
## Workspace preparation / 工作目录准备
The workspace palette and preparation states reuse existing typography and
tokens. Real Git checks cover branches, creation, retry and cancellation; visual
QA passed. Captures remain in
[PR #13598](https://github.com/esengine/DeepSeek-Reasonix/pull/13598).
工作目录面板与准备状态复用现有字号、图标和表面样式;真实 Git 验证了分支搜索、
占用状态、路径展开、创建、成功、失败重试与取消。无残留的 P0/P1/P2 视觉问题,
截图仅保留于 PR。清理失败时保留外部修改,并明确提示保存任务、核对目录后关闭
会话,从源目录新建会话重试;安装包与上游 CI 验收单独记录。
+5
View File
@@ -15,6 +15,11 @@
"build:dist": "electron-builder", "build:dist": "electron-builder",
"smoke": "electron test/smoke.js", "smoke": "electron test/smoke.js",
"browser-live": "electron test/browser_live.js", "browser-live": "electron test/browser_live.js",
"image-live": "electron test/image_live.js",
"image-pane-live": "electron test/image_pane_live.js",
"sidepanel-live": "electron test/sidepanel_live.js",
"focus-live": "electron test/focus_live.js",
"ime-live": "electron test/ime_live.js",
"freeze-live": "electron test/freeze_live.js", "freeze-live": "electron test/freeze_live.js",
"windowstate-live": "electron test/windowstate_live.js", "windowstate-live": "electron test/windowstate_live.js",
"computer-live": "electron test/computer_live.js" "computer-live": "electron test/computer_live.js"
+60 -9
View File
@@ -3,12 +3,19 @@ const crypto = require("node:crypto");
const { WebContentsView, session } = require("electron"); const { WebContentsView, session } = require("electron");
const { installPaneReload } = require("./reload"); const { installPaneReload } = require("./reload");
const { guestNavigationAllowed, typed } = require("./browserguard"); const { guestNavigationAllowed, typed } = require("./browserguard");
const { KeyboardKeeper } = require("./keyboard");
// The size a page lays out at while nobody is looking at it: the kernel drives // The size a page lays out at while nobody is looking at it: the kernel drives
// pages whether or not the panel is open, and a page sized to nothing reads as // pages whether or not the panel is open, and a page sized to nothing reads as
// a phone layout or not at all. // a phone layout or not at all.
const AGENT_VIEWPORT = { width: 1280, height: 900 }; const AGENT_VIEWPORT = { width: 1280, height: 900 };
// How long a page may be held out of the window while its navigation runs. A
// server that accepts the connection and never answers would otherwise keep the
// panel blank, with nothing turning, for as long as it likes; past this the page
// is shown again, loaded or not.
const LEAVE_LIMIT_MS = 4000;
// CSS cannot clip a native view, so the page's corners are rounded here; the // CSS cannot clip a native view, so the page's corners are rounded here; the
// value is .bview's border-radius in studio.css, whose rect the view fills. // value is .bview's border-radius in studio.css, whose rect the view fills.
const PAGE_RADIUS = 8; const PAGE_RADIUS = 8;
@@ -30,6 +37,7 @@ class BrowserViews {
// for this run only: a restart, or a different certificate, asks again. // for this run only: a restart, or a different certificate, asks again.
this.trusted = new Set(); this.trusted = new Set();
this.shown = ""; this.shown = "";
this.keyboard = new KeyboardKeeper({ win });
// Moves with every show and hide, so a freeze that finishes after one does // Moves with every show and hide, so a freeze that finishes after one does
// not put away a page shown since. // not put away a page shown since.
this.placed = 0; this.placed = 0;
@@ -106,26 +114,33 @@ class BrowserViews {
} }
onEvent(method, params); onEvent(method, params);
}); });
const entry = { view, onDownload, typed: 0 }; const entry = { view, onDownload, typed: 0, synthUntil: 0, attached: false };
this.keyboard.watch(contents, () => Date.now() < entry.synthUntil);
this.watchLoads(targetId, entry); this.watchLoads(targetId, entry);
let closed = false; let closed = false;
const close = () => { const close = () => {
if (closed) return; if (closed) return;
closed = true; closed = true;
this.entries.delete(targetId); this.entries.delete(targetId);
if (!this.win.isDestroyed()) this.win.contentView.removeChildView(view); clearTimeout(entry.returning);
if (entry.attached && !this.win.isDestroyed()) this.win.contentView.removeChildView(view);
entry.attached = false;
if (!contents.isDestroyed()) contents.close(); if (!contents.isDestroyed()) contents.close();
onClosed(); onClosed();
}; };
contents.once("destroyed", close); contents.once("destroyed", close);
contents.on("render-process-gone", close); contents.on("render-process-gone", close);
this.entries.set(targetId, entry); this.entries.set(targetId, entry);
this.win.contentView.addChildView(view); // The page loads before the window holds it: a page that finishes loading in a
this.putAway(view); // view already attached is given the keyboard by the platform, and the
// person's composition in the app page ends with it.
void contents.loadURL(url).catch(() => {}); void contents.loadURL(url).catch(() => {});
return { return {
targetId, targetId,
send: (method, params) => contents.debugger.sendCommand(method, params), send: (method, params) => {
if (method.startsWith("Input.")) entry.synthUntil = Date.now() + 250;
return contents.debugger.sendCommand(method, params);
},
close, close,
mainFrame: () => mainFrame, mainFrame: () => mainFrame,
}; };
@@ -155,19 +170,52 @@ class BrowserViews {
// A page that was parked comes back as a view that is attached but whose // A page that was parked comes back as a view that is attached but whose
// widget never repaints until the window's geometry changes; attaching it // widget never repaints until the window's geometry changes; attaching it
// again is what wakes it, the same as after a minimize. // again is what wakes it, the same as after a minimize.
if (!entry.at) this.win.contentView.removeChildView(entry.view); if (entry.attached && !entry.at) this.win.contentView.removeChildView(entry.view);
this.win.contentView.addChildView(entry.view); this.win.contentView.addChildView(entry.view);
entry.attached = true;
entry.view.setBounds(at); entry.view.setBounds(at);
entry.at = at; entry.at = at;
this.keepKeyboard();
}
// Attaching a view can leave nobody holding the keyboard, and the platform
// settles that a moment later, so the page is handed it back twice.
keepKeyboard() {
this.keyboard.reclaim();
setTimeout(() => this.keyboard.reclaim(), 150);
} }
reattach() { reattach() {
for (const [id, entry] of this.entries) { for (const [id, entry] of this.entries) {
if (this.win.isDestroyed() || entry.view.webContents.isDestroyed()) continue; if (this.win.isDestroyed() || entry.view.webContents.isDestroyed() || !entry.attached) continue;
this.win.contentView.removeChildView(entry.view); this.win.contentView.removeChildView(entry.view);
this.win.contentView.addChildView(entry.view); this.win.contentView.addChildView(entry.view);
if (id !== this.shown) this.putAway(entry.view, entry); if (id !== this.shown) this.putAway(entry.view, entry);
} }
this.keepKeyboard();
}
// A navigation that starts in an attached view takes the keyboard from the app
// page when it finishes, and a composition in progress there is committed as
// the raw letters. While the app page holds the keyboard the view is held out of
// the window for the length of the navigation, and put back when it ends; a
// person who is in the page themselves keeps it where it is.
leave(entry) {
if (!entry.attached || this.win.isDestroyed() || this.keyboard.owner !== "app") return;
this.win.contentView.removeChildView(entry.view);
entry.attached = false;
clearTimeout(entry.returning);
entry.returning = setTimeout(() => this.settle(entry), LEAVE_LIMIT_MS);
}
settle(entry) {
clearTimeout(entry.returning);
entry.returning = undefined;
if (entry.attached || this.win.isDestroyed() || entry.view.webContents.isDestroyed()) return;
this.win.contentView.addChildView(entry.view);
entry.attached = true;
if (entry.at) entry.view.setBounds(entry.at);
else this.putAway(entry.view, entry);
} }
// freeze answers with a picture of the page on screen, taken before it is put // freeze answers with a picture of the page on screen, taken before it is put
@@ -255,8 +303,11 @@ class BrowserViews {
watchLoads(targetId, entry) { watchLoads(targetId, entry) {
const contents = entry.view.webContents; const contents = entry.view.webContents;
contents.on("did-start-navigation", (details) => { contents.on("did-start-navigation", (details) => {
if (details.isMainFrame && !details.isSameDocument) this.report(targetId, null); if (!details.isMainFrame || details.isSameDocument) return;
this.report(targetId, null);
this.leave(entry);
}); });
contents.on("did-stop-loading", () => this.settle(entry));
contents.on("did-fail-load", (_event, code, description, url, isMainFrame) => { contents.on("did-fail-load", (_event, code, description, url, isMainFrame) => {
// -3 is ERR_ABORTED: a navigation replaced by another, or stopped. A // -3 is ERR_ABORTED: a navigation replaced by another, or stopped. A
// typed address reports through loadTyped, which may still retry it. // typed address reports through loadTyped, which may still retry it.
@@ -374,4 +425,4 @@ function validRect(rect) {
return rect && [rect.x, rect.y, rect.width, rect.height].every(Number.isFinite) && rect.width > 0 && rect.height > 0; return rect && [rect.x, rect.y, rect.width, rect.height].every(Number.isFinite) && rect.width > 0 && rect.height > 0;
} }
module.exports = { BrowserViews, AGENT_VIEWPORT }; module.exports = { BrowserViews, AGENT_VIEWPORT, LEAVE_LIMIT_MS };
+63
View File
@@ -0,0 +1,63 @@
"use strict";
// Who owns the keyboard while the agent drives a page. A person typing in the
// app page keeps typing there: a page the agent opens, navigates or puts on
// screen is given the keyboard by the platform, and nothing the agent does is a
// reason for it to have it. The one reason that counts is the person's own: a
// click or a key that reached the page from the window, which is theirs to
// have chosen. The agent's own input arrives over the debugging protocol and
// is recognised as such, because it reaches the page the same way a click does.
const PERSON_MS = 400;
class KeyboardKeeper {
constructor({ win, now = Date.now }) {
this.win = win;
this.now = now;
this.owner = win.webContents.isFocused() ? "app" : "none";
win.webContents.on("focus", () => {
this.owner = "app";
});
}
// watch follows one page. synthetic() says whether input reaching it right now
// is the agent's own.
watch(contents, synthetic = () => false) {
let personAt = -Infinity;
contents.on("before-input-event", () => {
personAt = this.now();
});
contents.on("input-event", (_event, input) => {
if (input && input.type === "mouseDown" && !synthetic()) personAt = this.now();
});
contents.on("focus", () => {
if (this.now() - personAt < PERSON_MS) {
this.owner = "view";
return;
}
if (this.owner !== "app" || !this.live()) {
this.owner = "view";
return;
}
// The platform is still handing the page the keyboard while this event is
// delivered, and a focus asked for from inside it is lost; one a turn later
// is not, as long as nothing of the person's has arrived by then.
setTimeout(() => {
if (this.now() - personAt < PERSON_MS || this.owner !== "app" || !this.live()) return;
if (contents.isFocused()) this.win.webContents.focus();
}, 0);
});
}
// reclaim gives the keyboard back to the app page when attaching or moving a
// view left nobody holding it, while the window itself is the one in front.
reclaim() {
if (this.owner === "app" && this.live() && !this.win.webContents.isFocused()) this.win.webContents.focus();
}
live() {
return !this.win.isDestroyed() && this.win.isFocused();
}
}
module.exports = { KeyboardKeeper, PERSON_MS };
+141
View File
@@ -0,0 +1,141 @@
"use strict";
// Who owns the keyboard while the agent drives its browser. A person typing in
// the app page must still be typing there after the agent opens a page, shows
// it, clicks in it, types into it or asks for it to come to the front: the
// agent's commands arrive over the debugging protocol, and nothing it sends may
// move the focus out of the page the person is in. Run with `pnpm focus-live`;
// it needs no network and no kernel.
const { app, BrowserWindow } = require("electron");
const { BrowserProtocol } = require("../src/browserprotocol");
const { BrowserViews } = require("../src/browserviews");
const { checker, listen, wait } = require("./livekit");
const APP = `<!doctype html><title>app</title><textarea id="compose" autofocus></textarea>`;
const PAGE = `<!doctype html><title>page</title><input id="name"><button id="go" onclick="document.title='clicked'">Go</button>`;
const { check, failures } = checker();
async function main() {
await app.whenReady();
const page = await listen((_req, res) => {
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
res.end(PAGE);
});
const win = new BrowserWindow({ width: 1000, height: 700, show: true, webPreferences: { contextIsolation: true } });
await win.loadURL("data:text/html;charset=utf-8," + encodeURIComponent(APP));
win.show();
app.focus({ steal: true });
win.focus();
win.webContents.focus();
await wait(800);
if (!win.webContents.isFocused()) {
process.stdout.write(" SKIP this desktop would not give the test window the keyboard, so there is nothing to keep\n");
app.exit(0);
return;
}
const views = new BrowserViews({ win, kernelOrigin: "http://127.0.0.1:1" });
const frames = [];
const waiting = new Map();
const protocol = new BrowserProtocol({
createView: (spec) => views.create(spec),
post: (frame) => {
frames.push(frame);
const id = frame.message && frame.message.id;
if (id !== undefined && waiting.has(id)) waiting.get(id)(frame.message);
},
});
let n = 0;
const send = (message, sessionId) => new Promise((resolve) => {
const id = ++n;
waiting.set(id, resolve);
protocol.receive({ conn: "c", message: JSON.stringify({ id, ...message, ...(sessionId ? { sessionId } : {}) }) });
setTimeout(() => resolve({ timedOut: true }), 4000);
});
const where = async () => ({
app: await win.webContents.executeJavaScript("document.activeElement && document.activeElement.id"),
appFocused: win.webContents.isFocused(),
viewFocused: [...views.entries.values()].some((e) => e.view.webContents.isFocused()),
});
const keepsTheKeyboard = async (what) => {
const w = await where();
check(`${what}: the composer keeps the keyboard`, w.app === "compose" && w.appFocused && !w.viewFocused, w);
};
protocol.receive({ conn: "c", open: "focus-live" });
await keepsTheKeyboard("before the agent does anything");
const created = await send({ method: "Target.createTarget", params: { url: page.url } });
const targetId = created.result.targetId;
const attached = await send({ method: "Target.attachToTarget", params: { targetId } });
const session = attached.result.sessionId;
await wait(1200);
await keepsTheKeyboard("opening a page");
views.show(targetId, { x: 300, y: 100, width: 600, height: 400 });
await wait(500);
await keepsTheKeyboard("showing it over the page");
await send({ method: "Page.bringToFront" }, session);
await wait(300);
await keepsTheKeyboard("asking for the page to come to the front");
await send({ method: "Input.dispatchMouseEvent", params: { type: "mousePressed", x: 40, y: 20, button: "left", clickCount: 1 } }, session);
await send({ method: "Input.dispatchMouseEvent", params: { type: "mouseReleased", x: 40, y: 20, button: "left", clickCount: 1 } }, session);
await wait(300);
await keepsTheKeyboard("a click inside the agent's page");
await send({ method: "Input.insertText", params: { text: "hello" } }, session);
await send({ method: "Input.dispatchKeyEvent", params: { type: "keyDown", key: "a", text: "a" } }, session);
await send({ method: "Input.dispatchKeyEvent", params: { type: "keyUp", key: "a" } }, session);
await wait(300);
await keepsTheKeyboard("typing into the agent's page");
await send({ method: "Runtime.evaluate", params: { expression: "document.querySelector('#name').focus()" } }, session);
await wait(300);
await keepsTheKeyboard("the page focusing one of its own fields");
await send({ method: "Page.navigate", params: { url: page.url + "/next" } }, session);
await wait(1200);
await keepsTheKeyboard("navigating the agent's page");
views.hide();
await wait(300);
await keepsTheKeyboard("putting the page away");
// The other half: a person who clicks into the agent's page is typing there,
// and the agent navigating it again does not take that away from them.
views.show(targetId, { x: 300, y: 100, width: 600, height: 400 });
await wait(500);
const theirs = [...views.entries.values()][0].view.webContents;
// A click that reaches the page from the window, and the focus the platform
// gives the page because of it; sendInputEvent alone does not move focus.
theirs.sendInputEvent({ type: "mouseDown", x: 40, y: 20, button: "left", clickCount: 1 });
theirs.focus();
theirs.sendInputEvent({ type: "mouseUp", x: 40, y: 20, button: "left", clickCount: 1 });
await wait(500);
const chosen = await where();
check("a click of the person's own into the agent's page gives it the keyboard", chosen.viewFocused && !chosen.appFocused, chosen);
await send({ method: "Page.navigate", params: { url: page.url + "/again" } }, session);
await wait(1200);
const stays = await where();
check("and the agent navigating it again does not pull the keyboard back", stays.viewFocused && !stays.appFocused, stays);
win.webContents.focus();
await wait(300);
win.minimize();
await wait(400);
win.restore();
await wait(600);
await keepsTheKeyboard("the window being minimized and restored");
page.server.close();
app.exit(failures.length ? 1 : 0);
}
main().catch((err) => {
process.stdout.write(` FAIL focus-live crashed — ${err && err.stack}\n`);
app.exit(1);
});
+114
View File
@@ -0,0 +1,114 @@
"use strict";
// A local image the agent opens in its browser has to be on screen, not a blank
// panel under the image's own title. The page is opened the way the kernel opens
// one (Target.createTarget on a file: address), shown over the rectangle the
// panel reserves, and what the view actually drew is read back and judged.
// Run with `pnpm image-live`; it needs no network and no kernel.
const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
const { app, BrowserWindow, nativeImage } = require("electron");
const { BrowserViews } = require("../src/browserviews");
const { BrowserProtocol } = require("../src/browserprotocol");
const { checker, listen, wait } = require("./livekit");
const { check, failures } = checker();
function picture(width, height, file) {
const bitmap = Buffer.alloc(width * height * 4);
for (let y = 0; y < height; y++) {
for (let x = 0; x < width; x++) {
const at = (y * width + x) * 4;
bitmap[at] = (x * 255) / width;
bitmap[at + 1] = (y * 255) / height;
bitmap[at + 2] = 128;
bitmap[at + 3] = 255;
}
}
fs.writeFileSync(file, nativeImage.createFromBitmap(bitmap, { width, height }).toPNG());
}
// How much of what was drawn is neither the panel's own white nor transparent.
async function inked(view) {
const shot = await view.webContents.capturePage();
const { width, height } = shot.getSize();
if (!width || !height) return { width, height, share: 0 };
const bitmap = shot.toBitmap();
let ink = 0;
for (let i = 0; i < bitmap.length; i += 4) {
const blank = bitmap[i + 3] === 0 || (bitmap[i] > 245 && bitmap[i + 1] > 245 && bitmap[i + 2] > 245);
if (!blank) ink++;
}
return { width, height, share: ink / (bitmap.length / 4) };
}
async function main() {
await app.whenReady();
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rx-image-live-"));
const small = path.join(dir, "small.png");
const large = path.join(dir, "large.png");
const html = path.join(dir, "page.html");
picture(320, 180, small);
picture(3200, 1800, large);
fs.writeFileSync(html, `<!doctype html><title>page</title><body style="margin:0;background:#2a6"><h1>hello</h1>`);
const web = await listen((req, res) => {
res.writeHead(200, { "Content-Type": "image/png" });
res.end(fs.readFileSync(large));
});
const win = new BrowserWindow({ width: 1280, height: 800, show: true });
await win.loadURL("data:text/html;charset=utf-8," + encodeURIComponent("<body style='margin:0'>app"));
await wait(500);
const views = new BrowserViews({ win, kernelOrigin: "http://127.0.0.1:1" });
const waiting = new Map();
const protocol = new BrowserProtocol({
createView: (spec) => views.create(spec),
post: (frame) => {
const id = frame.message && frame.message.id;
if (id !== undefined && waiting.has(id)) waiting.get(id)(frame.message);
},
});
let n = 0;
const send = (message) => new Promise((resolve) => {
const id = ++n;
waiting.set(id, resolve);
protocol.receive({ conn: "c", message: JSON.stringify({ id, ...message }) });
setTimeout(() => resolve({ timedOut: true }), 5000);
});
protocol.receive({ conn: "c", open: "image-live" });
const cases = [
["a small local image", "file://" + (process.platform === "win32" ? "/" : "") + small.replace(/\\/g, "/")],
["a 3200x1800 local image", "file://" + (process.platform === "win32" ? "/" : "") + large.replace(/\\/g, "/")],
["a local html page", "file://" + (process.platform === "win32" ? "/" : "") + html.replace(/\\/g, "/")],
["a 3200x1800 image over http", web.url + "/big.png"],
];
for (const [name, url] of cases) {
const created = await send({ method: "Target.createTarget", params: { url } });
const targetId = created.result && created.result.targetId;
if (!targetId) {
check(`${name}: the agent got a page`, false, created);
continue;
}
await wait(1500);
const view = [...views.entries.values()].find((e) => e.view)?.view;
const entry = views.entries.get(targetId);
views.show(targetId, { x: 300, y: 100, width: 800, height: 500 });
await wait(1200);
const where = entry.view.getBounds();
const drawn = await inked(entry.view);
check(`${name}: it is placed over the reserved rectangle`, where.x === 300 && where.y === 100 && where.width === 800 && where.height === 500, where);
check(`${name}: what the view drew is not blank`, drawn.share > 0.2, drawn);
await send({ method: "Target.closeTarget", params: { targetId } });
await wait(300);
void view;
}
web.server.close();
app.exit(failures.length ? 1 : 0);
}
main().catch((err) => {
process.stdout.write(` FAIL image-live crashed — ${err && err.stack}\n`);
app.exit(1);
});
+78
View File
@@ -0,0 +1,78 @@
"use strict";
// The agent opens a local image in the built-in browser, through the real kernel
// and the real window. The tab has to show the picture: a view over the
// rectangle the panel reserved whose pixels are the image, small or 3200x1800.
// Run with `pnpm image-pane-live`; it needs no network.
const os = require("node:os");
const path = require("node:path");
const fs = require("node:fs");
const { app, nativeImage } = require("electron");
const { checker, scriptedModel, listen, seedHome, settledWindow, until, wait } = require("./livekit");
const { check, failures } = checker();
const script = { url: "" };
function picture(width, height, file) {
const bitmap = Buffer.alloc(width * height * 4);
for (let y = 0; y < height; y++) for (let x = 0; x < width; x++) {
const at = (y * width + x) * 4;
bitmap[at] = (x * 255) / width; bitmap[at + 1] = (y * 255) / height; bitmap[at + 2] = 128; bitmap[at + 3] = 255;
}
fs.writeFileSync(file, nativeImage.createFromBitmap(bitmap, { width, height }).toPNG());
}
async function main() {
// The kernel only lets the agent open a local file inside the workspace.
const workspace = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), "rx-image-ws-")));
const small = path.join(workspace, "small.png");
const large = path.join(workspace, "ode_solution.png");
picture(320, 180, small);
picture(3200, 1800, large);
const fileURL = (p) => "file://" + (process.platform === "win32" ? "/" : "") + p.replace(/\\/g, "/");
let calls = 0;
const model = await listen(scriptedModel((tools) => {
if (calls >= 1) return null;
calls++;
return { name: "browser_open", arguments: { url: script.url } };
}).handler);
const home = fs.mkdtempSync(path.join(os.tmpdir(), "rx-image-home-"));
process.env.REASONIX_HOME = home;
seedHome(home, model.url, workspace);
const { current } = require("../src/main.js");
const win = await settledWindow();
const { client } = current();
const runtimes = await until("a pane", async () => {
const list = await client.json("GET", "/runtimes");
return Array.isArray(list) && list.length ? list : null;
});
const base = runtimes[0].base;
const guests = () => win.contentView.children.filter((v) => v.webContents && v.webContents !== win.webContents);
for (const [name, file] of [["a small local image", small], ["a 3200x1800 local image", large]]) {
script.url = fileURL(file);
calls = 0;
const before = guests().length;
await client.request("POST", `${base}/submit`, { input: `show ${name}` });
const guest = await until(`${name}: a page view`, async () => guests()[guests().length - 1], 60000);
await until(`${name}: the page beside the conversation`, async () => (guest.getBounds().x >= 0 ? true : null), 30000).catch(() => null);
await wait(1500);
const b = guest.getBounds();
const shot = await guest.webContents.capturePage();
const size = shot.getSize();
const bitmap = size.width ? shot.toBitmap() : Buffer.alloc(0);
let ink = 0;
for (let i = 0; i < bitmap.length; i += 4) if (!(bitmap[i + 3] === 0 || (bitmap[i] > 245 && bitmap[i + 1] > 245 && bitmap[i + 2] > 245))) ink++;
const share = bitmap.length ? ink / (bitmap.length / 4) : 0;
check(`${name}: the view is on screen, beside the conversation`, b.x >= 0 && b.width > 100 && b.height > 100, b);
check(`${name}: the title is the image's own`, guest.webContents.getTitle().includes(path.basename(file)), guest.webContents.getTitle());
check(`${name}: what the view drew is the picture, not a blank`, share > 0.2, { share, size, bounds: b, scale: require("electron").screen.getPrimaryDisplay().scaleFactor });
void before;
}
app.exit(failures.length ? 1 : 0);
}
app.whenReady().then(main).catch((err) => {
process.stdout.write(` FAIL image-pane-live crashed — ${err && err.stack}\n`);
app.exit(1);
});
+125
View File
@@ -0,0 +1,125 @@
"use strict";
// A person in the middle of an IME composition — the pinyin letters still
// underlined, the candidate list open — must not lose it when the agent's browser
// loads, navigates, reloads or is shown. Losing it is not a lost focus that comes
// back: the composition is committed as the raw letters and the word they were
// spelling is gone. Run with `pnpm ime-live`; it needs no network and no kernel.
const { app, BrowserWindow } = require("electron");
const { BrowserViews, LEAVE_LIMIT_MS } = require("../src/browserviews");
const { BrowserProtocol } = require("../src/browserprotocol");
const { checker, listen, wait } = require("./livekit");
const APP = `<!doctype html><title>app</title><textarea id="compose" autofocus></textarea>
<script>window.__ime = { start: 0, update: 0, end: 0, blur: 0 };
const t = document.getElementById("compose");
t.addEventListener("compositionstart", () => __ime.start++);
t.addEventListener("compositionupdate", () => __ime.update++);
t.addEventListener("compositionend", () => __ime.end++);
t.addEventListener("blur", () => __ime.blur++);</script>`;
const PAGE = `<!doctype html><title>page</title><input id="name"><p>hello</p>`;
const { check, failures } = checker();
async function main() {
await app.whenReady();
const page = await listen((req, res) => {
// A server that takes the connection and never answers.
if (req.url === "/hang") return;
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
res.end(PAGE);
});
const win = new BrowserWindow({ width: 1000, height: 700, show: true });
await win.loadURL("data:text/html;charset=utf-8," + encodeURIComponent(APP));
win.show();
app.focus({ steal: true });
win.focus();
win.webContents.focus();
await wait(800);
if (!win.webContents.isFocused()) {
process.stdout.write(" SKIP this desktop would not give the test window the keyboard, so there is nothing to keep\n");
app.exit(0);
return;
}
const dbg = win.webContents.debugger;
dbg.attach("1.3");
const views = new BrowserViews({ win, kernelOrigin: "http://127.0.0.1:1" });
const waiting = new Map();
const protocol = new BrowserProtocol({
createView: (spec) => views.create(spec),
post: (frame) => {
const id = frame.message && frame.message.id;
if (id !== undefined && waiting.has(id)) waiting.get(id)(frame.message);
},
});
let n = 0;
const send = (message, sessionId) => new Promise((resolve) => {
const id = ++n;
waiting.set(id, resolve);
protocol.receive({ conn: "c", message: JSON.stringify({ id, ...message, ...(sessionId ? { sessionId } : {}) }) });
setTimeout(() => resolve({ timedOut: true }), 4000);
});
const ime = () => win.webContents.executeJavaScript("JSON.stringify({ ...window.__ime, value: document.getElementById('compose').value })").then(JSON.parse);
const compose = async (text) => {
await win.webContents.executeJavaScript("window.__ime.start = window.__ime.update = window.__ime.end = window.__ime.blur = 0; document.getElementById('compose').value = ''; document.getElementById('compose').focus()");
await dbg.sendCommand("Input.imeSetComposition", { text, selectionStart: text.length, selectionEnd: text.length });
await wait(150);
};
const keepsComposing = async (what) => {
const s = await ime();
check(`${what}: the composition is still open`, s.start >= 1 && s.end === 0, s);
};
protocol.receive({ conn: "c", open: "ime-live" });
await compose("nihao");
await keepsComposing("before the agent does anything");
const created = await send({ method: "Target.createTarget", params: { url: page.url } });
const targetId = created.result.targetId;
const session = (await send({ method: "Target.attachToTarget", params: { targetId } })).result.sessionId;
await wait(1500);
await keepsComposing("opening a page");
await compose("zhongwen");
views.show(targetId, { x: 300, y: 100, width: 600, height: 400 });
await wait(600);
await keepsComposing("showing it");
await compose("ceshi");
await send({ method: "Page.navigate", params: { url: page.url + "/next" } }, session);
await wait(1500);
await keepsComposing("navigating it");
await compose("shuaxin");
await send({ method: "Page.reload" }, session);
await wait(1500);
await keepsComposing("reloading it");
await compose("dianji");
await send({ method: "Input.dispatchMouseEvent", params: { type: "mousePressed", x: 40, y: 20, button: "left", clickCount: 1 } }, session);
await send({ method: "Input.dispatchMouseEvent", params: { type: "mouseReleased", x: 40, y: 20, button: "left", clickCount: 1 } }, session);
await send({ method: "Input.insertText", params: { text: "x" } }, session);
await wait(500);
await keepsComposing("the agent clicking and typing in it");
await compose("guaqi");
// Not awaited: the protocol answers a navigation only when the page does.
void send({ method: "Page.navigate", params: { url: page.url + "/hang" } }, session);
await wait(500);
const out = !win.contentView.children.includes([...views.entries.values()][0].view);
check("a navigation that has just started holds the page out of the window", out);
await wait(LEAVE_LIMIT_MS + 1000);
const back = win.contentView.children.includes([...views.entries.values()][0].view);
check("one that never answers does not keep it out for good", back);
await keepsComposing("and the composition is still open while it hangs");
page.server.closeAllConnections?.();
page.server.close();
app.exit(failures.length ? 1 : 0);
}
main().catch((err) => {
process.stdout.write(` FAIL ime-live crashed — ${err && err.stack}\n`);
app.exit(1);
});
+87
View File
@@ -0,0 +1,87 @@
"use strict";
// The side question has to be seen over the built-in browser. The browser is a
// native view drawn above the whole page, so the panel can only be seen if the
// page stops covering the place the panel is. Real kernel, real window: the agent
// opens a page, the person widens it, opens the side question, and what is judged
// is whether the view still covers the panel. Run with `pnpm sidepanel-live`.
const os = require("node:os");
const path = require("node:path");
const fs = require("node:fs");
const { app } = require("electron");
const { checker, scriptedModel, listen, seedHome, settledWindow, until, wait } = require("./livekit");
const { check, failures } = checker();
async function main() {
const page = await listen((_req, res) => {
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
res.end("<!doctype html><title>Greeter</title><body style='margin:0;background:#2a6'><h1>hello</h1>");
});
let calls = 0;
const model = await listen(scriptedModel(() => (calls++ >= 1 ? null : { name: "browser_open", arguments: { url: page.url + "/" } })).handler);
const home = fs.mkdtempSync(path.join(os.tmpdir(), "rx-side-home-"));
const workspace = fs.mkdtempSync(path.join(os.tmpdir(), "rx-side-ws-"));
process.env.REASONIX_HOME = home;
seedHome(home, model.url, workspace);
const { current } = require("../src/main.js");
const win = await settledWindow();
const { client } = current();
const runtimes = await until("a pane", async () => {
const list = await client.json("GET", "/runtimes");
return Array.isArray(list) && list.length ? list : null;
});
const base = runtimes[0].base;
const js = (src) => win.webContents.executeJavaScript(src);
const guests = () => win.contentView.children.filter((v) => v.webContents && v.webContents !== win.webContents);
await client.request("POST", `${base}/submit`, { input: "open the page" });
const guest = await until("a page view", async () => guests()[0], 60000);
await until("the page beside the conversation", async () => (guest.getBounds().x >= 0 ? true : null), 30000);
await until("the wide control", () => js(`!!document.querySelector('[data-action="browser.wide"]')`));
const panelRect = () => js(`(() => { const e = document.querySelector('.sidepanel'); if (!e) return null; const b = e.getBoundingClientRect(); return { x: b.left, y: b.top, width: b.width, height: b.height }; })()`);
const covers = (view, rect) => view.x < rect.x + rect.width && view.x + view.width > rect.x && view.y < rect.y + rect.height && view.y + view.height > rect.y;
for (const [width, height, wide] of [[1440, 900, false], [1440, 900, true], [1100, 760, false], [1100, 760, true], [900, 700, true], [700, 700, true], [1440, 900, "panel-first"], [1100, 760, "panel-first"]]) {
win.setContentSize(width, height);
await wait(900);
const label = `${width}px ${wide === "panel-first" ? "side question open, then the browser widened" : wide ? "widened" : "beside the conversation"}`;
if (wide === true && (await js(`document.querySelector('.pane')?.getAttribute('data-browser-size')`)) !== "wide") {
await js(`document.querySelector('[data-action="browser.wide"]')?.click()`);
await wait(1200);
}
if (wide === false && (await js(`document.querySelector('.pane')?.getAttribute('data-browser-size')`)) === "wide") {
await js(`document.querySelector('[data-action="browser.wide"]')?.click()`);
await wait(1200);
}
if (wide === "panel-first") {
// Back beside the conversation first, then the question, then the widening.
if ((await js(`document.querySelector('.pane')?.getAttribute('data-browser-size')`)) === "wide") {
await js(`document.querySelector('[data-action="browser.wide"]')?.click()`);
await wait(1000);
}
await js(`document.querySelector('[data-action="side.toggle"]').click()`);
await until("the side panel", async () => (await panelRect()) || null, 10000);
await wait(800);
await js(`document.querySelector('[data-action="browser.wide"]')?.click()`);
await wait(1500);
} else {
await js(`document.querySelector('[data-action="side.toggle"]').click()`);
await until("the side panel", async () => (await panelRect()) || null, 10000);
}
await wait(1500);
const panel = await panelRect();
const view = guest.getBounds();
const parked = view.x < 0;
process.stdout.write(` [bounds] ${label}: view=${JSON.stringify(view)} panel=${JSON.stringify(panel)} parked=${parked}\n`);
check(`${label}: the side panel is drawn`, !!panel && panel.width > 100, panel);
check(`${label}: the page does not cover the panel`, parked || !covers(view, panel), { view, panel });
await js(`document.querySelector('[data-action="side.toggle"]').click()`);
await wait(800);
}
app.exit(failures.length ? 1 : 0);
}
app.whenReady().then(main).catch((err) => {
process.stdout.write(` FAIL sidepanel-live crashed — ${err && err.stack}\n`);
app.exit(1);
});
+232 -2
View File
@@ -1,4 +1,4 @@
import { test } from "node:test"; import { test, mock } from "node:test";
import assert from "node:assert/strict"; import assert from "node:assert/strict";
import { createRequire } from "node:module"; import { createRequire } from "node:module";
import path from "node:path"; import path from "node:path";
@@ -2260,12 +2260,14 @@ test("a parked page is attached again when it is shown, and a page already on sc
const win = { const win = {
on() {}, on() {},
isDestroyed: () => false, isDestroyed: () => false,
isFocused: () => false,
webContents: Object.assign(new EventEmitter(), { isFocused: () => false }),
getContentBounds: () => ({ width: 1200, height: 800 }), getContentBounds: () => ({ width: 1200, height: 800 }),
contentView: { addChildView: () => calls.push("add"), removeChildView: () => calls.push("remove") }, contentView: { addChildView: () => calls.push("add"), removeChildView: () => calls.push("remove") },
}; };
const views = new BrowserViews({ win, kernelOrigin: "http://127.0.0.1:1" }); const views = new BrowserViews({ win, kernelOrigin: "http://127.0.0.1:1" });
const view = { setBounds: (b) => calls.push("bounds " + b.x), webContents: { isDestroyed: () => false } }; const view = { setBounds: (b) => calls.push("bounds " + b.x), webContents: { isDestroyed: () => false } };
views.entries.set("t1", { view, at: null }); views.entries.set("t1", { view, at: null, attached: true });
const rect = { x: 10, y: 20, width: 600, height: 400 }; const rect = { x: 10, y: 20, width: 600, height: 400 };
views.show("t1", rect); views.show("t1", rect);
@@ -2451,3 +2453,231 @@ test("a folder panel that closes before anyone could read it is reported as neve
assert.equal(folderAnswer(closed, 20, "win32"), ""); assert.equal(folderAnswer(closed, 20, "win32"), "");
assert.equal(folderAnswer({ canceled: false, filePaths: ["/home/a/p"] }, 20, "linux"), "/home/a/p"); assert.equal(folderAnswer({ canceled: false, filePaths: ["/home/a/p"] }, 20, "linux"), "/home/a/p");
}); });
// Who keeps the keyboard while the agent drives a page.
const { KeyboardKeeper, PERSON_MS } = require("../src/keyboard.js");
function keyboardWindow({ appFocused = true, windowFocused = true } = {}) {
const contents = Object.assign(new EventEmitter(), { focused: appFocused, focus() { this.focused = true; this.emit("focus"); }, isFocused() { return this.focused; } });
const win = { webContents: contents, isDestroyed: () => false, isFocused: () => windowFocused };
return { win, contents };
}
function keyboardPage() {
return new EventEmitter();
}
const turn = () => new Promise((resolve) => setTimeout(resolve, 5));
test("a page the agent opens does not take the keyboard from the app page", async () => {
const { win, contents } = keyboardWindow();
const keeper = new KeyboardKeeper({ win });
const page = keyboardPage();
page.isFocused = () => true;
keeper.watch(page);
contents.focused = false;
page.emit("focus");
assert.equal(contents.focused, false, "not from inside the platform's own focus event");
await turn();
assert.equal(contents.focused, true);
assert.equal(keeper.owner, "app");
});
test("a click or a key from the person into the page is theirs to have chosen", () => {
for (const arrive of [(page) => page.emit("before-input-event", {}, { type: "keyDown" }), (page) => page.emit("input-event", {}, { type: "mouseDown" })]) {
const { win, contents } = keyboardWindow();
const keeper = new KeyboardKeeper({ win });
const page = keyboardPage();
keeper.watch(page);
arrive(page);
contents.focused = false;
page.emit("focus");
assert.equal(contents.focused, false);
assert.equal(keeper.owner, "view");
}
});
test("a click the agent itself sent over the protocol is not the person's", async () => {
const { win, contents } = keyboardWindow();
const keeper = new KeyboardKeeper({ win });
const page = keyboardPage();
page.isFocused = () => true;
keeper.watch(page, () => true);
page.emit("input-event", {}, { type: "mouseDown" });
contents.focused = false;
page.emit("focus");
await turn();
assert.equal(contents.focused, true);
});
test("the person's choice stands until they go back to the app page", async () => {
const { win, contents } = keyboardWindow();
const keeper = new KeyboardKeeper({ win });
const page = keyboardPage();
page.isFocused = () => true;
keeper.watch(page);
page.emit("input-event", {}, { type: "mouseDown" });
page.emit("focus");
assert.equal(keeper.owner, "view");
contents.focused = false;
page.emit("focus");
await turn();
assert.equal(contents.focused, false, "the agent navigating again does not pull it back");
contents.focus();
assert.equal(keeper.owner, "app");
});
test("nothing is taken back when the app page did not have the keyboard, or the window is not in front", async () => {
let { win, contents } = keyboardWindow({ appFocused: false });
let keeper = new KeyboardKeeper({ win });
let page = keyboardPage();
page.isFocused = () => true;
keeper.watch(page);
page.emit("focus");
await turn();
assert.equal(contents.focused, false);
({ win, contents } = keyboardWindow({ windowFocused: false }));
keeper = new KeyboardKeeper({ win });
page = keyboardPage();
page.isFocused = () => true;
keeper.watch(page);
contents.focused = false;
page.emit("focus");
await turn();
assert.equal(contents.focused, false);
({ win, contents } = keyboardWindow());
keeper = new KeyboardKeeper({ win });
page = keyboardPage();
page.isFocused = () => true;
keeper.watch(page);
contents.focused = false;
page.emit("focus");
page.emit("before-input-event", {}, { type: "keyDown" });
await turn();
assert.equal(contents.focused, false, "a key from the person in the meantime is theirs");
});
test("reclaim hands the keyboard back only when nobody holds it and the app page owned it", () => {
const { win, contents } = keyboardWindow();
const keeper = new KeyboardKeeper({ win });
contents.focused = false;
keeper.reclaim();
assert.equal(contents.focused, true);
keeper.owner = "view";
contents.focused = false;
keeper.reclaim();
assert.equal(contents.focused, false);
});
test("a person's click is only a click for a moment", () => {
assert.ok(PERSON_MS > 0 && PERSON_MS < 2000);
});
function loadedViews() {
const Module = require("node:module");
const load = Module._load;
Module._load = function (request, ...rest) {
if (request === "electron") return { WebContentsView: class {}, session: { fromPartition: () => ({}) } };
return load.call(this, request, ...rest);
};
let BrowserViews;
let LEAVE_LIMIT_MS;
try {
({ BrowserViews, LEAVE_LIMIT_MS } = require("../src/browserviews.js"));
} finally {
Module._load = load;
}
const calls = [];
const win = {
on() {},
isDestroyed: () => false,
isFocused: () => true,
webContents: Object.assign(new EventEmitter(), { isFocused: () => true }),
getContentBounds: () => ({ width: 1200, height: 800 }),
contentView: { addChildView: () => calls.push("add"), removeChildView: () => calls.push("remove") },
};
const views = new BrowserViews({ win, kernelOrigin: "http://127.0.0.1:1" });
const view = { setBounds: (b) => calls.push("bounds " + b.x), webContents: { isDestroyed: () => false } };
return { views, view, calls, LEAVE_LIMIT_MS };
}
test("a page navigating while the app page holds the keyboard is held out of the window, then put back where it was", () => {
const { views, view, calls } = loadedViews();
const entry = { view, at: { x: 10, y: 20, width: 600, height: 400 }, attached: true };
views.leave(entry);
assert.deepEqual(calls, ["remove"]);
assert.equal(entry.attached, false);
views.settle(entry);
assert.deepEqual(calls, ["remove", "add", "bounds 10"]);
assert.equal(entry.attached, true);
views.settle(entry);
assert.equal(calls.length, 3, "settling a page that is already attached does nothing");
});
test("a parked page comes back parked", () => {
const { views, view, calls } = loadedViews();
const entry = { view, at: null, attached: true };
views.leave(entry);
calls.length = 0;
views.settle(entry);
assert.equal(calls[0], "add");
assert.match(calls[1], /^bounds -?\d+$/);
assert.equal(entry.at, null);
});
test("a page is left where it is when the person, not the app page, holds the keyboard, or when it is not attached", () => {
const { views, view, calls } = loadedViews();
const entry = { view, at: null, attached: true };
views.keyboard.owner = "view";
views.leave(entry);
assert.deepEqual(calls, []);
assert.equal(entry.attached, true);
views.keyboard.owner = "app";
const loading = { view, at: null, attached: false };
views.leave(loading);
assert.deepEqual(calls, []);
});
test("a page still loading is not put back by a window event", () => {
const { views, view, calls } = loadedViews();
views.entries.set("t1", { view, at: null, attached: false });
views.reattach();
assert.deepEqual(calls, []);
});
test("a navigation that never finishes does not keep the page out of the window for good", () => {
mock.timers.enable({ apis: ["setTimeout"] });
try {
const { views, view, calls, LEAVE_LIMIT_MS } = loadedViews();
const entry = { view, at: { x: 10, y: 20, width: 600, height: 400 }, attached: true };
views.leave(entry);
assert.deepEqual(calls, ["remove"]);
mock.timers.tick(LEAVE_LIMIT_MS - 1);
assert.equal(entry.attached, false, "still out just before the limit");
mock.timers.tick(1);
assert.deepEqual(calls, ["remove", "add", "bounds 10"]);
assert.equal(entry.attached, true);
mock.timers.tick(LEAVE_LIMIT_MS * 3);
assert.equal(calls.length, 3, "and it is put back once");
} finally {
mock.timers.reset();
}
});
test("a navigation that does finish cancels the limit, and a second one gets a fresh one", () => {
mock.timers.enable({ apis: ["setTimeout"] });
try {
const { views, view, calls, LEAVE_LIMIT_MS } = loadedViews();
const entry = { view, at: null, attached: true };
views.leave(entry);
mock.timers.tick(LEAVE_LIMIT_MS - 100);
views.settle(entry);
const back = calls.length;
mock.timers.tick(LEAVE_LIMIT_MS * 2);
assert.equal(calls.length, back, "the limit did not fire after the page came back");
views.leave(entry);
mock.timers.tick(LEAVE_LIMIT_MS);
assert.equal(entry.attached, true, "the second navigation was bounded by its own limit");
} finally {
mock.timers.reset();
}
});
+2
View File
@@ -4,3 +4,5 @@ dist-perf*
*.png *.png
!public/icons/*.png !public/icons/*.png
tsconfig.tsbuildinfo tsconfig.tsbuildinfo
dist-relay*
relay/bin
+26 -17
View File
@@ -1,5 +1,5 @@
// Opening the branch menu while the pointer is still over the branch chip must not leave the hover card showing: // Workspace selection must stay reachable before and after a task, with no
// the two surfaces would stack their shadows. Visibility under :hover is a styling question, so it is asked of a real browser. // legacy hover card stacked over the open palette.
import { chromium } from "playwright"; import { chromium } from "playwright";
const BASE = process.env.PERF_URL ?? "http://localhost:4399/perf.html?pref=zh&turns=2"; const BASE = process.env.PERF_URL ?? "http://localhost:4399/perf.html?pref=zh&turns=2";
@@ -9,22 +9,31 @@ const check = (name, ok, detail = "") => {
if (!ok) fails.push(name); if (!ok) fails.push(name);
}; };
const browser = await chromium.launch(); const browser = await chromium.launch({ executablePath: process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE });
try { try {
const page = await (await browser.newContext({ locale: "zh-CN", viewport: { width: 1280, height: 800 } })).newPage(); for (const turns of [0, 2]) {
await page.goto(BASE, { waitUntil: "networkidle" }); const page = await browser.newPage({ locale: "zh-CN", viewport: { width: 1280, height: 800 }, reducedMotion: "reduce" });
await page.waitForSelector(".app", { timeout: 20000 }); const url = new URL(BASE);
const chip = page.locator('.studio-branch-pop .studio-branch').first(); url.searchParams.set("turns", String(turns));
await chip.waitFor(); await page.goto(url.href, { waitUntil: "networkidle" });
const card = page.locator(".studio-branch-card").first(); const chip = page.getByRole("button", { name: "配置工作目录", exact: true });
const shown = () => card.evaluate((el) => getComputedStyle(el).visibility === "visible" && Number(getComputedStyle(el).opacity) > 0.5); await chip.hover();
await chip.hover(); await chip.click();
await page.waitForTimeout(400); const palette = page.getByRole("dialog", { name: "配置工作目录", exact: true });
check("the hover card shows over a closed chip", await shown()); await palette.waitFor();
await chip.click(); check(`${turns} turns: the workspace palette opened`, await chip.getAttribute("aria-expanded") === "true");
await page.waitForTimeout(500); const stacked = await page.locator(".studio-branch-card").evaluateAll((cards) => cards.some((card) => getComputedStyle(card).visibility === "visible" && Number(getComputedStyle(card).opacity) > 0.5));
check("the branch menu opened", (await chip.getAttribute("aria-expanded")) === "true"); check(`${turns} turns: no legacy hover card overlaps the palette`, !stacked);
check("the hover card is gone while the menu is open", !(await shown())); await palette.getByRole("option", { name: "main", exact: true }).waitFor();
check(`${turns} turns: the current branch is selected`, await palette.getByRole("option", { name: "main", exact: true }).getAttribute("aria-selected") === "true");
const search = palette.getByRole("searchbox", { name: "搜索分支", exact: true });
await search.fill("studio");
check(`${turns} turns: search filters the branch choices`, await palette.getByRole("option").count() === 1 && await palette.getByRole("option", { name: "studio", exact: true }).isVisible());
await search.press("Escape");
await palette.waitFor({ state: "detached" });
check(`${turns} turns: closing returns focus to the workspace entry`, await chip.evaluate((element) => element === document.activeElement));
await page.close();
}
} finally { } finally {
await browser.close(); await browser.close();
} }
+3 -3
View File
@@ -1,4 +1,4 @@
// The product mark in the sidebar's top-left is one row: the wordmark and a bordered uppercase badge, vertically centred on // The product mark in the sidebar's top-left is one row: the wordmark and an outlined uppercase badge, vertically centred on
// each other. Where the badge was hidden before it stays hidden. // each other. Where the badge was hidden before it stays hidden.
import { chromium } from "playwright"; import { chromium } from "playwright";
@@ -30,13 +30,13 @@ for (const scheme of ["light", "dark"]) {
top: r.top, top: r.top,
dy: Math.abs(logo.top + logo.height / 2 - (r.top + r.height / 2)), dy: Math.abs(logo.top + logo.height / 2 - (r.top + r.height / 2)),
after: r.left >= logo.right - 1, after: r.left >= logo.right - 1,
fill: cs.backgroundColor, ink: cs.color, radius: cs.borderTopLeftRadius, upper: cs.textTransform, text: sub.textContent, fill: cs.backgroundColor, ink: cs.color, border: cs.borderTopWidth, radius: cs.borderTopLeftRadius, upper: cs.textTransform, text: sub.textContent,
}; };
}); });
const tag = `${scheme} ${width}px`; const tag = `${scheme} ${width}px`;
if (width >= 760) { if (width >= 760) {
check(`${tag}: the badge is on the wordmark's row, vertically centred on it`, m.shown && m.dy <= 1.5 && m.after, JSON.stringify(m)); check(`${tag}: the badge is on the wordmark's row, vertically centred on it`, m.shown && m.dy <= 1.5 && m.after, JSON.stringify(m));
check(`${tag}: it is a filled uppercase badge in both themes and the text is untouched`, m.fill !== "rgba(0, 0, 0, 0)" && m.fill !== m.ink && m.radius !== "0px" && m.upper === "uppercase" && m.text === "studio", JSON.stringify(m)); check(`${tag}: it is an outlined uppercase badge in both themes and the text is untouched`, m.fill === "rgba(0, 0, 0, 0)" && m.border !== "0px" && m.radius !== "0px" && m.upper === "uppercase" && m.text === "studio", JSON.stringify(m));
if (!seen) seen = { left: m.left, top: m.top }; if (!seen) seen = { left: m.left, top: m.top };
check(`${tag}: it sits in the same place whichever theme is on`, Math.abs(m.left - seen.left) <= 0.5 && Math.abs(m.top - seen.top) <= 0.5, `${m.left},${m.top} vs ${seen.left},${seen.top}`); check(`${tag}: it sits in the same place whichever theme is on`, Math.abs(m.left - seen.left) <= 0.5 && Math.abs(m.top - seen.top) <= 0.5, `${m.left},${m.top} vs ${seen.left},${seen.top}`);
} else { } else {
@@ -0,0 +1,79 @@
// The composer keeps the 1.x feel: a ring of light sweeps its border once when
// it is hovered or focused, nothing sweeps while a turn runs or when motion is
// reduced, and the run state is announced rather than printed inside the box.
import { chromium } from "playwright";
const BASE = process.env.PERF_URL ?? "http://localhost:4399/perf.html?pref=zh&turns=2";
const fails = [];
const check = (name, ok, detail = "") => {
console.log(`${ok ? " ok" : "FAIL"} ${name}${detail ? " — " + detail : ""}`);
if (!ok) fails.push(name);
};
const shotDir = process.env.SHOTS;
const sweep = (page) => page.evaluate(() => getComputedStyle(document.querySelector(".compose"), "::before").animationName);
const browser = await chromium.launch();
for (const scheme of ["light", "dark"]) {
for (const running of [false, true]) {
const label = `${scheme} ${running ? "running" : "idle"}`;
const page = await browser.newPage({ viewport: { width: 1280, height: 800 }, locale: "zh-CN", colorScheme: scheme });
page.on("pageerror", (e) => fails.push("页面异常: " + e.message));
await page.goto(BASE + (running ? "&running=1" : ""), { waitUntil: "networkidle" });
await page.waitForSelector(".compose");
if (running) {
await page.waitForFunction(() => typeof window.__feed === "function");
await page.waitForTimeout(400);
await page.evaluate(() => window.__feed({ kind: "turn_started" }));
}
await page.waitForSelector(running ? ".studio-runstate[data-running]" : ".studio-runstate[data-idle]");
await page.mouse.move(5, 5);
await page.waitForTimeout(150);
const state = await page.evaluate(() => {
const status = document.querySelector(".compose .studio-runstate");
const word = status?.querySelector(".studio-runlabel > span.sr-only");
const r = word?.getBoundingClientRect();
const clock = status?.querySelector(".studio-runclock");
return {
role: status?.getAttribute("role"),
live: status?.getAttribute("aria-live"),
said: word?.textContent ?? "",
wordBox: r ? [Math.round(r.width), Math.round(r.height)] : null,
clockShown: !!clock && clock.getBoundingClientRect().width > 0,
mark: !!status?.querySelector(".rmark"),
};
});
check(`${label}: the state is announced`, state.role === "status" && state.live === "polite" && state.said.length > 0, state.said);
check(`${label}: the word is not printed in the box`, state.wordBox && state.wordBox[0] <= 2 && state.wordBox[1] <= 2, JSON.stringify(state.wordBox));
check(`${label}: the mark stays`, state.mark);
if (running) check(`${label}: the clock stays`, state.clockShown);
check(`${label}: nothing sweeps at rest`, (await sweep(page)) === "none");
await page.hover(".compose textarea");
await page.waitForTimeout(300);
const hovered = await sweep(page);
check(`${label}: ${running ? "no sweep on hover while running" : "a sweep on hover"}`, running ? hovered === "none" : hovered === "compose-sweep", hovered);
if (shotDir && !running) await page.locator(".compose").screenshot({ path: `${shotDir}/${scheme}-hover.png` });
await page.mouse.move(5, 5);
await page.waitForTimeout(100);
await page.focus(".compose textarea");
await page.waitForTimeout(300);
const focused = await sweep(page);
check(`${label}: ${running ? "no sweep on focus while running" : "a sweep on focus"}`, running ? focused === "none" : focused === "compose-sweep", focused);
if (shotDir) await page.locator(".compose").screenshot({ path: `${shotDir}/${scheme}-${running ? "running" : "idle"}.png` });
await page.close();
}
const calm = await browser.newPage({ viewport: { width: 1280, height: 800 }, locale: "zh-CN", colorScheme: scheme, reducedMotion: "reduce" });
await calm.goto(BASE, { waitUntil: "networkidle" });
await calm.waitForSelector(".compose");
await calm.hover(".compose textarea");
await calm.focus(".compose textarea");
await calm.waitForTimeout(300);
check(`${scheme}: no sweep when motion is reduced`, (await sweep(calm)) === "none");
await calm.close();
}
await browser.close();
if (fails.length) {
console.log(`\n${fails.length} failed`);
process.exit(1);
}
+5 -3
View File
@@ -1,4 +1,4 @@
// The session title sits right beside the project name in the title bar, at every width, and the // The session title sits right beside the project and checkout in the title bar, at every width, and the
// project name still gives way (ellipsis) before the title is pushed off. Layout is a real-browser question. // project name still gives way (ellipsis) before the title is pushed off. Layout is a real-browser question.
import { chromium } from "playwright"; import { chromium } from "playwright";
@@ -22,9 +22,11 @@ try {
const range = document.createRange(); const range = document.createRange();
range.selectNodeContents(projEl); range.selectNodeContents(projEl);
const text = range.getBoundingClientRect(); const text = range.getBoundingClientRect();
return { gap: Math.round(sep.left - text.right), titleRight: Math.round(title.right) }; const tags = [...document.querySelectorAll(".chrome .crumb .isolab")].filter((el) => !el.hidden);
const edge = tags.at(-1)?.getBoundingClientRect().right ?? text.right;
return { gap: Math.round(sep.left - edge), titleRight: Math.round(title.right) };
}); });
check(`${width}px: the title follows the project name`, at.gap >= 0 && at.gap <= 12, `gap ${at.gap}px`); check(`${width}px: the title follows the project and checkout`, at.gap >= 0 && at.gap <= 12, `gap ${at.gap}px`);
check(`${width}px: the title stays on screen`, at.titleRight <= width, `right edge ${at.titleRight}px`); check(`${width}px: the title stays on screen`, at.titleRight <= width, `right edge ${at.titleRight}px`);
const long = await page.evaluate(() => { const long = await page.evaluate(() => {
document.querySelector(".chrome .crumb-proj").textContent = "a-very-long-project-folder-name-".repeat(8); document.querySelector(".chrome .crumb-proj").textContent = "a-very-long-project-folder-name-".repeat(8);
+90
View File
@@ -0,0 +1,90 @@
// Interface text has one size dial, --fs-scale, and the transcript has its own,
// --read. Every piece of text must answer to one of them: text that moves with
// neither is a size written into a stylesheet as a bare pixel count, which the
// text-size setting could never reach.
import { chromium } from "playwright";
const BASE = process.env.PERF_URL ?? "http://localhost:4399/perf.html";
const fails = [];
const check = (name, ok, detail = "") => {
console.log(`${ok ? " ok" : "FAIL"} ${name}${detail ? " — " + detail : ""}`);
if (!ok) fails.push(name);
};
const SCENES = [
{ name: "conversation", q: "?pref=zh&ws=2&sess=6&turns=3" },
{ name: "files panel", q: "?pref=zh&ws=2&sess=6&turns=2", act: async (p) => { await p.locator('.navbtn[aria-label="文件"]').first().click(); await p.waitForSelector(".workbench-filelist button"); } },
{ name: "side panel", q: "?pref=zh&turns=2", act: async (p) => { await p.locator('[data-action="side.toggle"]').click(); await p.waitForSelector(".sidepanel"); } },
{ name: "narrow", q: "?pref=zh&ws=2&sess=6&turns=2", w: 390 },
{ name: "settings", q: "?pref=zh&turns=2", tabs: true },
];
const sizes = (page) => page.evaluate(() => {
window.__texts ??= (() => {
const out = [];
const walker = document.createTreeWalker(document.body, NodeFilter.SHOW_ELEMENT);
let n;
while ((n = walker.nextNode())) {
if (![...n.childNodes].some((c) => c.nodeType === 3 && c.textContent.trim())) continue;
const r = n.getBoundingClientRect();
if (r.width < 2 || r.height < 2 || !n.checkVisibility({ checkVisibilityCSS: true, contentVisibilityAuto: true })) continue;
out.push(n);
}
return out;
})();
return window.__texts.map((el) => parseFloat(getComputedStyle(el).fontSize));
});
const browser = await chromium.launch();
for (const sc of SCENES) {
const page = await browser.newPage({ viewport: { width: sc.w ?? 1400, height: 900 }, locale: "zh-CN", reducedMotion: "reduce" });
page.on("pageerror", (e) => fails.push("页面异常: " + e.message));
await page.goto(BASE + sc.q, { waitUntil: "networkidle" });
await page.waitForSelector(".app");
await page.evaluate(() => document.fonts.ready);
const views = [];
if (sc.tabs) {
await page.evaluate(() => document.querySelector('[data-action="chrome.settings"]')?.click());
await page.waitForSelector(".prefs-nav button");
const n = await page.evaluate(() => document.querySelectorAll(".prefs-nav button").length);
for (let i = 0; i < n; i++) views.push(async () => { await page.evaluate((k) => document.querySelectorAll(".prefs-nav button")[k].click(), i); });
} else views.push(async () => { await sc.act?.(page); });
let total = 0;
const holes = new Map();
for (const show of views) {
await show();
await page.waitForTimeout(500);
await page.evaluate(() => { window.__texts = undefined; document.documentElement.style.setProperty("--fs-scale", "1"); });
const base = await sizes(page);
await page.evaluate(() => document.documentElement.style.setProperty("--fs-scale", "1.25"));
await page.waitForTimeout(80);
const scaled = await sizes(page);
await page.evaluate(() => {
const root = document.documentElement.style;
window.__read = root.getPropertyValue("--read");
root.setProperty("--fs-scale", "1");
root.setProperty("--read", `${parseFloat(getComputedStyle(document.documentElement).getPropertyValue("--read")) * 1.25}px`);
});
await page.waitForTimeout(80);
const reading = await sizes(page);
await page.evaluate(() => {
const root = document.documentElement.style;
if (window.__read) root.setProperty("--read", window.__read);
else root.removeProperty("--read");
});
const names = await page.evaluate(() => window.__texts.map((el) => (el.className && typeof el.className === "string" ? el.className.trim().split(/\s+/).slice(0, 2).join(".") : el.tagName.toLowerCase()) + " " + el.textContent.trim().slice(0, 10)));
base.forEach((b, i) => {
if (b <= 0) return;
total++;
const ok = Math.abs(scaled[i] / b - 1.25) < 0.03 || Math.abs(reading[i] / b - 1.25) < 0.03;
if (!ok) holes.set(`${names[i]} (${b}px)`, (holes.get(`${names[i]} (${b}px)`) ?? 0) + 1);
});
}
check(`${sc.name}: every piece of text answers to the text size or the reading size`, holes.size === 0, `${total} text nodes${holes.size ? "; stuck: " + [...holes.keys()].slice(0, 6).join(", ") : ""}`);
await page.close();
}
await browser.close();
if (fails.length) {
console.log(`\n${fails.length} failed`);
process.exit(1);
}
+83 -8
View File
@@ -30,6 +30,7 @@ import "../src/styles/gitgraph.css";
import "../src/styles/tips.css"; import "../src/styles/tips.css";
import "../src/styles/waitnote.css"; import "../src/styles/waitnote.css";
import "../src/styles/bring.css"; import "../src/styles/bring.css";
import "../src/styles/peerthreads.css";
import "../src/styles/line.css"; import "../src/styles/line.css";
import "../src/styles/notice.css"; import "../src/styles/notice.css";
import "../src/styles/peers.css"; import "../src/styles/peers.css";
@@ -49,7 +50,7 @@ import { App } from "../src/ui/App";
import { MockHub } from "../src/port/mock_hub"; import { MockHub } from "../src/port/mock_hub";
import { MockPort } from "../src/port/mock"; import { MockPort } from "../src/port/mock";
import { fromHistory } from "../src/state/session"; import { fromHistory } from "../src/state/session";
import type { AgentPort, Appearance, HistoryMessage } from "../src/port/port"; import type { AgentPort, Appearance, HistoryMessage, McpEntry } from "../src/port/port";
import type { UpdateProgress } from "../src/port/version"; import type { UpdateProgress } from "../src/port/version";
import type { ThemePack } from "../src/port/look"; import type { ThemePack } from "../src/port/look";
import type { RuntimeView, TreeWorkspace } from "../src/port/hub"; import type { RuntimeView, TreeWorkspace } from "../src/port/hub";
@@ -94,18 +95,55 @@ function benchPack(): ThemePack | null {
} }
class BenchPort extends MockPort { class BenchPort extends MockPort {
// ?mcpactions=1 adds a server whose tools are withheld and one that is followed.
constructor() {
super();
if (!query.has("mcpactions")) return;
(this as unknown as { servers: McpEntry[] }).servers.push(
{ name: "notes", state: "ready", enabled: true, transport: "stdio", tools: 2, description: "Notes the agent can search and add to.",
held: [{ name: "mcp__notes__search", class: "changed" }, { name: "mcp__notes__add", class: "added" }], heldDigest: "digest-1" },
{ name: "wiki", state: "ready", enabled: true, transport: "http", tools: 4, followsChanges: true, description: "The team wiki." },
);
}
// ?checkfail=<name> makes a plain test of that source answer 401. // ?checkfail=<name> makes a plain test of that source answer 401.
protected async answerCheck(name: string) { protected async answerCheck(name: string) {
if (query.get("checkfail") !== name) return super.answerCheck(name); if (query.get("checkfail") !== name) return super.answerCheck(name);
return { ok: false, code: "provider.probe.unauthorized", httpStatus: 401, detail: "Invalid Authentication" }; return { ok: false, code: "provider.probe.unauthorized", httpStatus: 401, detail: "Invalid Authentication" };
} }
async browserTabs() { // ?badprice=1: DeepSeek's flash price is a card the vendor's table does not have,
if (!query.has("agentpage")) return super.browserTabs(); // under a pinned display currency.
return [{ id: "t1", target: "view-a", url: "https://example.com/docs/install", title: "Install guide", active: true }]; async providers() {
const all = await super.providers();
if (!query.has("badprice")) return all;
return all.map((p) => p.name === "deepseek"
? { ...p, officialPricing: true, modelPrices: { "deepseek-flash": { input: 0.3, output: 1.2, cacheHit: 0, currency: "USD", official: false } } }
: p);
} }
async workspaceFile(path: string) { async displayCurrency() {
const got = await super.displayCurrency();
return query.has("badprice") ? { ...got, mode: "CNY" as const, unconverted: [{ provider: "deepseek", model: "deepseek-flash", currency: "USD" }] } : got;
}
async browserTabs() {
if (!query.has("agentpage")) return super.browserTabs();
const url = query.get("agentpage") === "file" ? "file:///Users/me/work/site/index.html" : "https://example.com/docs/install";
return [{ id: "t1", target: "view-a", url, title: "Install guide", active: true }];
}
// ?grant=1 grants the workspace a folder, /library, holding one document.
async workspaceRoots() {
const roots = await super.workspaceRoots();
return query.has("grant") ? { ...roots, extra: ["/library"] } : roots;
}
async workspaceFile(path: string, root?: string) {
if (root) {
const content = "# Release plan\n\nThe plan lives in the library folder you granted this project.\n\n- freeze on Friday\n- tag after the checks are green\n- announce the next morning\n";
return { path, content, revision: "r1", root, readOnly: true };
}
if (!query.has("doc") || !path.endsWith(".md")) return super.workspaceFile(path); if (!query.has("doc") || !path.endsWith(".md")) return super.workspaceFile(path);
const content = "# Install guide\n\nDownload the package for your system, then run the installer. It places the program in your user folder and does not need administrator rights.\n\nAfter the first start, open Settings and add a model service before you begin a conversation."; const content = "# Install guide\n\nDownload the package for your system, then run the installer. It places the program in your user folder and does not need administrator rights.\n\nAfter the first start, open Settings and add a model service before you begin a conversation.";
return { path, content, revision: "r1" }; return { path, content, revision: "r1" };
@@ -151,6 +189,15 @@ class BenchPort extends MockPort {
} }
async peers() { async peers() {
if (query.has("peerthreads")) {
const states = ["waiting", "working", "replied", "declined"] as const;
return {
enabled: true,
peers: [],
held: [],
threads: states.map((state, i) => ({ key: `s-${i}`, title: ["文档整理", "发布脚本", "拾音", "旧迁移"][i], project: "rx-archive", sessionPath: `/sessions/w0-s${i}.jsonl`, state, since: "2026-10-16T10:00:00Z", messageId: `m-${i}` })),
};
}
if (!query.has("peers")) return super.peers(); if (!query.has("peers")) return super.peers();
return { return {
enabled: true, enabled: true,
@@ -159,6 +206,25 @@ class BenchPort extends MockPort {
}; };
} }
async webAccessSettings() {
if (!query.has("webaccess")) return super.webAccessSettings();
return {
search_backends: ["searxng", "deepseek_api"], searxng_url: "http://127.0.0.1:8080", search_paid_allowed: [],
fetch_backends: ["local", "deepseek_api"], fetch_paid_allowed: [],
known_search_backends: ["searxng", "deepseek_api"], known_fetch_backends: ["local", "deepseek_api"], paid_backends: ["deepseek_api"],
deepseek_api_ready: query.get("webaccess") !== "noready", path: "/Users/you/.reasonix/config.toml",
};
}
async peersSettings() {
if (!query.has("peerset")) return super.peersSettings();
return {
enabled: true, dispatch: true, applies: "next_turn" as const, pending: query.has("pending"), available: true,
path: "/Users/you/.reasonix/config.toml",
release_projects: [{ path: "/Users/you/work/site", name: "site" }, { path: "/Users/you/work/api-gateway", name: "api-gateway" }],
};
}
async foldWorkSettings() { async foldWorkSettings() {
return { ...(await super.foldWorkSettings()), fold: query.has("fold") }; return { ...(await super.foldWorkSettings()), fold: query.has("fold") };
} }
@@ -204,13 +270,16 @@ class BenchPort extends MockPort {
id: `seed-${i}`, id: `seed-${i}`,
// Lines the person queued: the panel leaves out the host's own queued // Lines the person queued: the panel leaves out the host's own queued
// continuations, so a seed of those would draw nothing. // continuations, so a seed of those would draw nothing.
intent: "followup" as const, intent: (QUEUE_IMAGES && i === 0 ? "steer" : "followup") as "steer" | "followup",
state: "queued" as const, state: "queued" as const,
...(QUEUE_IMAGES && i === 0 ? { images: true } : {}),
// 三种长度,各有各的对照:第一条没被截断(不该弹),第二条长到撞天花板 // 三种长度,各有各的对照:第一条没被截断(不该弹),第二条长到撞天花板
// (浮层要自己滚),其余是寻常的被截断行。内核把 preview 截到 120 runes, // (浮层要自己滚),其余是寻常的被截断行。内核把 preview 截到 120 runes,
// 所以第二条在队列里到不了 —— 但 .ovf 是通用的,天花板得有人证明它在。 // 所以第二条在队列里到不了 —— 但 .ovf 是通用的,天花板得有人证明它在。
preview: preview:
i === 0 QUEUE_IMAGES && i === 0
? "@.reasonix/attachments/clipboard-20261017-141249.png reasonix 的你改好了?我这边看到的还是旧界面"
: i === 0
? "bash-1 — failed" ? "bash-1 — failed"
: i === 1 : i === 1
? `bash-2 — failed: ${"error[E0308]: mismatched types in the trait implementation; ".repeat(14)}` ? `bash-2 — failed: ${"error[E0308]: mismatched types in the trait implementation; ".repeat(14)}`
@@ -234,7 +303,11 @@ class BenchPort extends MockPort {
private readonly jobsSince = Date.now() - 739_000; private readonly jobsSince = Date.now() - 739_000;
async status() { async status() {
const st = { ...(await super.status()), ...(query.has("running") ? { running: true } : {}), ...(query.has("goal") ? { goal: query.get("goal") ?? "" } : {}) }; const usage = query.get("goalusage");
const goalRuntime = usage
? { 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 } : {}) };
if (STATUS_MS > 0) await new Promise((r) => setTimeout(r, STATUS_MS)); if (STATUS_MS > 0) await new Promise((r) => setTimeout(r, STATUS_MS));
if (!JOBS) return st; if (!JOBS) return st;
const jobs = Array.from({ length: JOBS }, (_, i) => ({ const jobs = Array.from({ length: JOBS }, (_, i) => ({
@@ -312,6 +385,8 @@ const SWITCH_MS = Number(query.get("switchms") ?? 0);
// ?queue= is how many lines are already waiting when the window opens. // ?queue= is how many lines are already waiting when the window opens.
const QUEUE = Number(query.get("queue") ?? 0); const QUEUE = Number(query.get("queue") ?? 0);
const QUEUE_BODY = query.has("queuebody"); const QUEUE_BODY = query.has("queuebody");
// ?queueimages makes the first seeded line a steer that carries a picture.
const QUEUE_IMAGES = query.has("queueimages");
const JOBS = Number(query.get("jobs") ?? 0); const JOBS = Number(query.get("jobs") ?? 0);
// ?treems= holds every tree read back until that long after load, so the // ?treems= holds every tree read back until that long after load, so the
// window can be seen before the kernel has named a single folder. // window can be seen before the kernel has named a single folder.
@@ -0,0 +1,60 @@
// A server that answered 401 offers to sign in beside the retry; a server whose
// tools were withheld offers to accept and follow beside the plain approval; a
// followed server says so and offers to stop. None of it may spill at a phone's
// width or crowd the row's other controls.
import { chromium } from "playwright";
const BASE = process.env.PERF_URL ?? "http://localhost:4399/perf.html";
const fails = [];
const check = (name, ok, detail = "") => {
console.log(`${ok ? " ok" : "FAIL"} ${name}${detail ? " — " + detail : ""}`);
if (!ok) fails.push(name);
};
const shotDir = process.env.SHOTS;
const browser = await chromium.launch();
for (const scheme of ["light", "dark"]) {
for (const width of [1280, 420]) {
const tag = `${scheme} ${width}px`;
const page = await browser.newPage({ viewport: { width, height: 1100 }, locale: "zh-CN", colorScheme: scheme, reducedMotion: "reduce" });
page.on("pageerror", (e) => fails.push("页面异常: " + e.message));
await page.goto(`${BASE}?pref=zh&turns=2&mcpactions=1`, { waitUntil: "networkidle" });
await page.waitForSelector(".app");
await page.evaluate(() => document.querySelector('[data-action="chrome.settings"]')?.click());
await page.waitForSelector(".prefs-nav button");
await page.evaluate(() => [...document.querySelectorAll(".prefs-nav button")].find((b) => b.textContent.includes("扩展"))?.click());
await page.waitForSelector(".mcp-server");
await page.evaluate(() => document.querySelectorAll("details.mcp-server").forEach((d) => { d.open = true; }));
const r = await page.evaluate(() => {
const row = (name) => [...document.querySelectorAll(".mcp-server")].find((d) => (d.querySelector("summary") ?? d).textContent.includes(name));
const label = (el) => [...el.querySelectorAll("button")].map((b) => b.textContent.trim());
const spill = (el) => { const r = el.getBoundingClientRect(); return [...el.querySelectorAll("button")].filter((b) => b.getBoundingClientRect().right > r.right + 1 || b.getBoundingClientRect().left < r.left - 1).length; };
const figma = row("figma"), notes = row("notes"), wiki = row("wiki");
const overlap = (el) => { const bs = [...el.querySelectorAll("button")].map((b) => b.getBoundingClientRect()).filter((b) => b.width > 0); return bs.some((a, i) => bs.some((c, j) => i < j && a.left < c.right - 1 && c.left < a.right - 1 && a.top < c.bottom - 1 && c.top < a.bottom - 1)); };
return {
figma: figma ? label(figma) : null, notes: notes ? label(notes) : null, wiki: wiki ? label(wiki) : null,
followNote: wiki?.textContent.includes("这个服务器的改动会自动接受") ?? false,
spill: [figma, notes, wiki].filter(Boolean).map(spill),
overlap: [figma, notes, wiki].filter(Boolean).map(overlap),
};
});
check(`${tag}: a 401 row offers sign-in beside the retry`, r.figma?.includes("登录") && r.figma.includes("重连"), r.figma?.join("|"));
check(`${tag}: a row with withheld tools offers both approvals`, r.notes?.includes("批准并重新连接") && r.notes.includes("接受并以后都自动接受这个服务器的改动"), r.notes?.join("|"));
check(`${tag}: a followed row says so and offers to stop`, r.followNote && r.wiki?.includes("取消跟随"), r.wiki?.join("|"));
check(`${tag}: no button spills out of its row`, r.spill.every((n) => n === 0), r.spill.join(","));
check(`${tag}: no two buttons overlap`, r.overlap.every((o) => !o));
if (shotDir && width === 1280) {
await page.addStyleTag({ content: ".cfgbad { display: none !important; }" });
for (const name of ["figma", "notes", "wiki"]) {
const row = page.locator(".mcp-server", { hasText: name }).first();
await row.screenshot({ path: `${shotDir}/${scheme}-${name}.png` });
}
}
await page.close();
}
}
await browser.close();
if (fails.length) {
console.log(`\n${fails.length} failed`);
process.exit(1);
}
@@ -0,0 +1,55 @@
// A session this one has written to carries one small icon for where the
// conversation stands: it has to sit inside its row, leave the title readable,
// say its state to a reader who cannot hover, and not select the row it is on.
import { chromium } from "playwright";
const PAGE = process.env.PERF_URL ?? "http://localhost:4399/perf.html?pref=zh&ws=1&sess=6&peerthreads=1";
const fails = [];
const check = (name, ok, detail = "") => {
console.log(`${ok ? " ok" : "FAIL"} ${name}${detail ? " — " + detail : ""}`);
if (!ok) fails.push(name);
};
const shotDir = process.env.SHOTS;
const browser = await chromium.launch();
for (const scheme of ["light", "dark"]) {
const page = await browser.newPage({ viewport: { width: 1280, height: 800 }, locale: "zh-CN", colorScheme: scheme });
page.on("pageerror", (e) => fails.push("页面异常: " + e.message));
await page.goto(PAGE, { waitUntil: "networkidle" });
await page.waitForSelector(".sessrow");
await page.waitForSelector(".peerthread");
const rows = await page.evaluate(() =>
[...document.querySelectorAll(".sessrow")].map((row) => {
const mark = row.querySelector(".peerthread");
const title = row.querySelector(".sesstitle");
const r = row.getBoundingClientRect();
const m = mark?.getBoundingClientRect();
const t = title?.getBoundingClientRect();
return {
path: row.getAttribute("data-target"),
state: mark?.getAttribute("data-state") ?? null,
label: mark?.getAttribute("aria-label") ?? null,
inside: m ? m.left >= r.left && m.right <= r.right && m.top >= r.top && m.bottom <= r.bottom : null,
clear: m && t ? m.left >= t.right - 1 : null,
size: m ? Math.round(m.width) : null,
};
}),
);
const marked = rows.filter((r) => r.state);
check(`${scheme}: four sessions carry a mark, the rest none`, marked.length === 4 && marked.map((r) => r.state).sort().join() === "declined,replied,waiting,working", marked.map((r) => r.state).join(","));
check(`${scheme}: each mark says its state in words`, marked.every((r) => r.label && r.label.length > 6));
check(`${scheme}: each mark sits inside its row`, marked.every((r) => r.inside));
check(`${scheme}: no mark covers the title`, marked.every((r) => r.clear));
check(`${scheme}: marks are small`, marked.every((r) => r.size >= 14 && r.size <= 22), marked.map((r) => r.size).join(","));
const before = await page.locator('.sessrow[aria-selected="true"]').count();
await page.locator(".peerthread[data-state=\"waiting\"]").first().focus();
check(`${scheme}: a mark takes keyboard focus`, await page.evaluate(() => document.activeElement?.classList.contains("peerthread")));
if (shotDir) await page.locator(".rail").screenshot({ path: `${shotDir}/rail-${scheme}.png` });
void before;
await page.close();
}
await browser.close();
if (fails.length) {
console.log(`\n${fails.length} failed`);
process.exit(1);
}
@@ -0,0 +1,78 @@
// Session messages are switched on from the settings page, not from a config
// file; a message held for approval can be let through for its project for good,
// and says where that is kept and undone; and a saved switch says when it applies.
import { chromium } from "playwright";
const BASE = process.env.PERF_URL ?? "http://localhost:4399/perf.html";
const fails = [];
const check = (name, ok, detail = "") => {
console.log(`${ok ? " ok" : "FAIL"} ${name}${detail ? " — " + detail : ""}`);
if (!ok) fails.push(name);
};
const shotDir = process.env.SHOTS;
const browser = await chromium.launch();
for (const scheme of ["light", "dark"]) {
for (const width of [1280, 420]) {
const tag = `${scheme} ${width}px`;
const page = await browser.newPage({ viewport: { width, height: 900 }, locale: "zh-CN", colorScheme: scheme, reducedMotion: "reduce" });
page.on("pageerror", (e) => fails.push("页面异常: " + e.message));
await page.goto(`${BASE}?pref=zh&turns=2&peers=1&peerset=1&pending=1`, { waitUntil: "networkidle" });
await page.waitForSelector(".app");
await page.waitForSelector(".peerbar");
const held = await page.evaluate(() => {
const bar = document.querySelector(".peerbar");
const btn = [...bar.querySelectorAll("button")].find((b) => b.textContent.includes("记住项目"));
const note = bar.querySelector(".apv-note");
const r = bar.getBoundingClientRect();
const spill = [...bar.querySelectorAll("button, .apv-note")].some((e) => e.getBoundingClientRect().right > r.right + 1);
return { btn: btn?.textContent ?? "", note: note?.textContent ?? "", described: btn?.getAttribute("aria-describedby") === note?.id, spill, scroll: bar.scrollWidth - bar.clientWidth };
});
check(`${tag}: the held message offers to remember its project`, /记住项目「[^」]+」/.test(held.btn), held.btn);
check(`${tag}: it says where the choice is kept and undone`, held.note.includes("设置 → 会话间消息") && held.note.includes("/"), held.note.slice(0, 40));
check(`${tag}: the note is tied to the button`, held.described);
check(`${tag}: nothing in the held bar spills sideways`, !held.spill && held.scroll <= 1, `${held.scroll}px`);
if (shotDir && width === 1280) await page.locator(".compose").screenshot({ path: `${shotDir}/${scheme}-held.png` });
await page.evaluate(() => document.querySelector('[data-action="chrome.settings"]')?.click());
await page.waitForSelector(".prefs-nav button");
await page.evaluate(() => [...document.querySelectorAll(".prefs-nav button")].find((b) => b.textContent.includes("工具与权限"))?.click());
await page.waitForSelector('[role="switch"][aria-label="启用会话间消息"]', { timeout: 8000 });
const s = await page.evaluate(() => {
const sw = (n) => document.querySelector(`[role="switch"][aria-label="${n}"]`);
const list = document.querySelector(".peerreleased");
const box = sw("启用会话间消息").closest(".box");
const r = box.getBoundingClientRect();
return {
enabled: sw("启用会话间消息").getAttribute("aria-checked"),
dispatchOff: sw("允许模型把任务交给别的会话").disabled,
released: [...(list?.querySelectorAll("li") ?? [])].map((li) => li.textContent),
revoke: [...(list?.querySelectorAll("button") ?? [])].map((b) => b.getAttribute("aria-label")),
pending: document.body.textContent.includes("下个回合起生效"),
immediate: /立即生效/.test(box.textContent),
path: box.textContent.includes("/Users/you/.reasonix/config.toml"),
spill: box.scrollWidth - box.clientWidth,
within: [...box.querySelectorAll("li, .lrow, .peerhead")].every((e) => e.getBoundingClientRect().right <= r.right + 1),
};
});
check(`${tag}: the switch is on the page`, s.enabled === "true");
check(`${tag}: the hand-off switch is live while messages are on`, s.dispatchOff === false);
check(`${tag}: released projects are listed with a revoke each`, s.released.length === 2 && s.revoke.length === 2 && s.revoke.every((l) => l.startsWith("撤销放行")), s.revoke.join("|"));
check(`${tag}: a saved switch says the next turn, never at once`, s.pending && !s.immediate);
check(`${tag}: the file the switches live in is named`, s.path);
check(`${tag}: nothing spills out of the box`, s.within && s.spill <= 1, `${s.spill}px`);
if (shotDir && width === 1280) {
await page.addStyleTag({ content: ".cfgbad { display: none !important; }" });
await page.setViewportSize({ width, height: 1100 });
await page.waitForTimeout(250);
await page.locator("#set-peers").screenshot({ path: `${shotDir}/${scheme}-settings.png` });
}
await page.close();
}
}
await browser.close();
if (fails.length) {
console.log(`\n${fails.length} failed`);
process.exit(1);
}
+35 -6
View File
@@ -2,6 +2,7 @@
// and inside the conversation's own column — never over what sits beside or // and inside the conversation's own column — never over what sits beside or
// above it, whether the file panel is open or not and at any width. // above it, whether the file panel is open or not and at any width.
import { chromium } from "playwright"; import { chromium } from "playwright";
import { mkdir } from "node:fs/promises";
const PAGE = process.env.PERF_URL ?? "http://localhost:4399/perf.html?pref=zh&ws=2&sess=6&turns=3"; const PAGE = process.env.PERF_URL ?? "http://localhost:4399/perf.html?pref=zh&ws=2&sess=6&turns=3";
const fails = []; const fails = [];
@@ -12,13 +13,14 @@ const check = (name, ok, detail = "") => {
const shotDir = process.env.SHOTS; const shotDir = process.env.SHOTS;
const box = (el) => (el ? (({ left, top, right, bottom }) => ({ left, top, right, bottom }))(el.getBoundingClientRect()) : null); const box = (el) => (el ? (({ left, top, right, bottom }) => ({ left, top, right, bottom }))(el.getBoundingClientRect()) : null);
const browser = await chromium.launch(); if (shotDir) await mkdir(shotDir, { recursive: true });
const browser = await chromium.launch({ channel: process.env.PLAYWRIGHT_CHANNEL });
for (const scheme of ["light", "dark"]) { for (const scheme of ["light", "dark"]) {
for (const [width, files] of [[1400, false], [1400, true], [760, false], [760, true]]) { for (const [width, files, height = 800, zoom = 1] of [[1400, false], [1400, true], [760, false], [760, true], [390, false, 700], [900, false, 480], [760, false, 800, 1.3]]) {
const label = `${scheme} ${width}px${files ? " with the file panel" : ""}`; const label = `${scheme} ${width}x${height} zoom ${zoom}${files ? " with the file panel" : ""}`;
const page = await browser.newPage({ viewport: { width, height: 800 }, locale: "zh-CN", colorScheme: scheme }); const page = await browser.newPage({ viewport: { width, height }, locale: "zh-CN", colorScheme: scheme });
page.on("pageerror", (e) => fails.push("页面异常: " + e.message)); page.on("pageerror", (e) => fails.push("页面异常: " + e.message));
await page.goto(PAGE, { waitUntil: "networkidle" }); await page.goto(`${PAGE}&zoom=${zoom}`, { waitUntil: "networkidle" });
await page.waitForSelector(".compose"); await page.waitForSelector(".compose");
if (files) { if (files) {
await page.locator('.navbtn[aria-label="文件"]').first().click(); await page.locator('.navbtn[aria-label="文件"]').first().click();
@@ -41,7 +43,34 @@ for (const scheme of ["light", "dark"]) {
check(`${label}: it stays inside the window`, p.left >= 0 && p.right <= r.vw && p.top >= 0 && p.bottom <= r.vh); check(`${label}: it stays inside the window`, p.left >= 0 && p.right <= r.vw && p.top >= 0 && p.bottom <= r.vh);
check(`${label}: it does not cover the file panel`, r.beside.every((b) => p.right <= b.left || p.left >= b.right || p.bottom <= b.top || p.top >= b.bottom), files ? `${r.beside.length} file panel parts` : "no file panel"); check(`${label}: it does not cover the file panel`, r.beside.every((b) => p.right <= b.left || p.left >= b.right || p.bottom <= b.top || p.top >= b.bottom), files ? `${r.beside.length} file panel parts` : "no file panel");
check(`${label}: it opens above the button that asked`, p.bottom <= r.button.top && p.left <= r.button.left + 1, `button ${Math.round(r.button.left)}`); check(`${label}: it opens above the button that asked`, p.bottom <= r.button.top && p.left <= r.button.left + 1, `button ${Math.round(r.button.left)}`);
if (shotDir && !(width === 760 && files)) await page.screenshot({ path: `${shotDir}/${scheme}-${width}${files ? "-files" : ""}.png` }); const draft = page.locator('[data-action-change="side.draft"]');
await draft.fill("解释这个函数如何处理错误。".repeat(50));
await page.locator('[data-action="side.send"]').click();
await page.locator('.sideturn[data-role="side"]').waitFor();
await draft.fill("keep this draft");
const collapse = page.locator('[data-action="side.collapse"]');
await collapse.click();
const hidden = await draft.isHidden();
const compact = await page.locator(".sidepanel").evaluate((el) => {
const b = el.getBoundingClientRect();
const button = el.querySelector('[data-action="side.collapse"]');
const c = button.getBoundingClientRect();
const hit = document.elementFromPoint(c.x + c.width / 2, c.y + c.height / 2);
const flow = el.closest(".pane").querySelector('[data-pane="flow"]').getBoundingClientRect();
return { height: b.height, hit: hit === button || button.contains(hit), readable: b.top - flow.top, flowHeight: flow.height };
});
check(`${label}: collapse leaves a reachable header and hides inputs`, hidden && compact.hit && compact.height < 72, JSON.stringify(compact));
check(`${label}: collapse exposes the transcript`, compact.readable > 100 && compact.readable > compact.flowHeight / 2, JSON.stringify(compact));
await collapse.focus();
await page.keyboard.press("Tab");
check(`${label}: Tab skips the collapsed controls`, await page.locator('[data-action="side.close"]').evaluate((el) => el === document.activeElement));
if (shotDir && [1400, 390].includes(width) && !files) await page.screenshot({ path: `${shotDir}/${scheme}-${width}.png` });
await collapse.click();
await draft.waitFor({ state: "visible" });
check(`${label}: expansion keeps the draft`, await draft.inputValue() === "keep this draft");
await page.keyboard.press("Escape");
await page.locator(".sidepanel").waitFor({ state: "detached" });
check(`${label}: Escape returns focus`, await page.locator('[data-action="side.toggle"]').evaluate((el) => el === document.activeElement));
await page.close(); await page.close();
} }
} }
+59
View File
@@ -0,0 +1,59 @@
// The interface text size enlarges type and nothing else: the window keeps its
// layout, no column spills sideways at the largest step, and the control that
// sets it reaches the page.
import { chromium } from "playwright";
const PAGE = process.env.PERF_URL ?? "http://localhost:4399/perf.html?pref=zh&ws=2&sess=6&turns=3";
const fails = [];
const check = (name, ok, detail = "") => {
console.log(`${ok ? " ok" : "FAIL"} ${name}${detail ? " — " + detail : ""}`);
if (!ok) fails.push(name);
};
const shotDir = process.env.SHOTS;
const STEPS = [["标准", 1], ["大", 1.15], ["更大", 1.3]];
const measure = (page) => page.evaluate(() => {
const px = (sel, prop = "fontSize") => parseFloat(getComputedStyle(document.querySelector(sel))[prop]);
const box = (sel) => document.querySelector(sel).getBoundingClientRect();
const spill = [".rail", ".compose", ".panetabs", ".chrome"].map((s) => document.querySelector(s)).filter(Boolean).filter((el) => el.scrollWidth > el.clientWidth + 1).map((el) => el.className.split(" ")[0]);
return {
sessionTitle: px(".sesstitle"),
newTask: px(".studio-new-task b"),
railWidth: Math.round(box(".rail").width),
composeWidth: Math.round(box(".compose").width),
zoom: getComputedStyle(document.documentElement).getPropertyValue("--zoom"),
scale: document.documentElement.style.getPropertyValue("--fs-scale"),
spill,
};
});
const browser = await chromium.launch();
for (const scheme of ["light", "dark"]) {
const page = await browser.newPage({ viewport: { width: 1280, height: 800 }, locale: "zh-CN", colorScheme: scheme });
page.on("pageerror", (e) => fails.push("页面异常: " + e.message));
await page.goto(PAGE, { waitUntil: "networkidle" });
await page.waitForSelector(".sesstitle");
const base = await measure(page);
for (const [name, scale] of STEPS) {
await page.evaluate(() => document.querySelector('[data-action="chrome.settings"]')?.click());
await page.waitForSelector(".prefs-nav button");
await page.locator(".prefs-nav button", { hasText: "外观" }).first().click();
await page.locator('[data-action="appearance.text-size"]', { hasText: new RegExp(`^${name}$`) }).click();
await page.waitForTimeout(200);
if (shotDir && scale === 1.3) await page.screenshot({ path: `${shotDir}/${scheme}-settings-1.3.png` });
await page.keyboard.press("Escape");
await page.waitForTimeout(300);
const m = await measure(page);
const tag = `${scheme} ${name}`;
check(`${tag}: the control reaches the page`, scale === 1 ? m.scale === "" : Number(m.scale) === scale, `--fs-scale "${m.scale}"`);
check(`${tag}: type grows by the step`, Math.abs(m.sessionTitle / base.sessionTitle - scale) < 0.03 && Math.abs(m.newTask / base.newTask - scale) < 0.03, `${base.sessionTitle}→${m.sessionTitle}, ${base.newTask}→${m.newTask}`);
check(`${tag}: the layout keeps its room`, m.railWidth === base.railWidth && m.composeWidth === base.composeWidth && m.zoom === base.zoom, `rail ${m.railWidth}/${base.railWidth}, composer ${m.composeWidth}/${base.composeWidth}`);
check(`${tag}: nothing spills sideways`, m.spill.length === 0, m.spill.join(","));
if (shotDir) await page.screenshot({ path: `${shotDir}/${scheme}-${scale}.png` });
}
await page.close();
}
await browser.close();
if (fails.length) {
console.log(`\n${fails.length} failed`);
process.exit(1);
}
+61
View File
@@ -0,0 +1,61 @@
// Web search and fetch are set from the settings page: each kind is an ordered
// list with the paid backend last, consent to be billed is its own switch that
// is never ticked for the person and is inert without a DeepSeek key, and
// nothing spills at a phone's width.
import { chromium } from "playwright";
const BASE = process.env.PERF_URL ?? "http://localhost:4399/perf.html";
const fails = [];
const check = (name, ok, detail = "") => {
console.log(`${ok ? " ok" : "FAIL"} ${name}${detail ? " — " + detail : ""}`);
if (!ok) fails.push(name);
};
const shotDir = process.env.SHOTS;
const browser = await chromium.launch();
for (const scheme of ["light", "dark"]) {
for (const [width, ready] of [[1280, "ready"], [420, "ready"], [1280, "noready"]]) {
const tag = `${scheme} ${width}px${ready === "noready" ? " without a key" : ""}`;
const page = await browser.newPage({ viewport: { width, height: 900 }, locale: "zh-CN", colorScheme: scheme, reducedMotion: "reduce" });
page.on("pageerror", (e) => fails.push("页面异常: " + e.message));
await page.goto(`${BASE}?pref=zh&turns=2&webaccess=${ready}`, { waitUntil: "networkidle" });
await page.waitForSelector(".app");
await page.evaluate(() => document.querySelector('[data-action="chrome.settings"]')?.click());
await page.waitForSelector(".prefs-nav button");
await page.evaluate(() => [...document.querySelectorAll(".prefs-nav button")].find((b) => b.textContent.includes("工具与权限"))?.click());
await page.waitForSelector("#set-web-access");
const r = await page.evaluate(() => {
const g = document.querySelector("#set-web-access");
const box = g.querySelector(".box").getBoundingClientRect();
const lists = [...g.querySelectorAll(".wa-sec")].map((s) => [s.getAttribute("aria-label"), [...s.querySelectorAll(".wa-list > li > .tx .lb")].map((e) => e.textContent)]);
const consent = [...g.querySelectorAll('[role="switch"]')].filter((b) => /同意/.test(b.getAttribute("aria-label")));
const wide = [...g.querySelectorAll(".box *")].filter((e) => e.getBoundingClientRect().right > box.right + 1).map((e) => e.className || e.tagName);
return {
lists,
consent: consent.map((b) => [b.getAttribute("aria-checked"), b.disabled]),
hint: g.textContent.includes("需要一个带密钥的 DeepSeek 提供方"),
applies: g.textContent.includes("之后新开的窗格"),
path: g.textContent.includes("/Users/you/.reasonix/config.toml"),
wide: wide.slice(0, 3),
scroll: g.querySelector(".box").scrollWidth - g.querySelector(".box").clientWidth,
};
});
check(`${tag}: search and fetch are two ordered lists, the paid backend after the free one`, r.lists.length === 2 && r.lists[0][1][0] === "SearXNG" && r.lists[0][1][1] === "DeepSeek 官方搜索" && r.lists[1][1][0] === "本机抓取" && r.lists[1][1][1] === "DeepSeek 官方抓取", JSON.stringify(r.lists));
check(`${tag}: no consent is ticked for the person`, r.consent.length === 2 && r.consent.every(([on]) => on === "false"), JSON.stringify(r.consent));
check(`${tag}: the consent is ${ready === "noready" ? "inert and says why" : "live"}`, ready === "noready" ? r.consent.every(([, off]) => off) && r.hint : r.consent.every(([, off]) => !off) && !r.hint);
check(`${tag}: it says when a change applies and where it is kept`, r.applies && r.path);
check(`${tag}: nothing spills out of the box`, r.wide.length === 0 && r.scroll <= 1, `${r.wide.join(",")} ${r.scroll}px`);
if (shotDir && width === 1280 && ready === "ready") {
await page.addStyleTag({ content: ".cfgbad { display: none !important; }" });
await page.setViewportSize({ width, height: 1200 });
await page.waitForTimeout(250);
await page.locator("#set-web-access").screenshot({ path: `${shotDir}/${scheme}-web.png` });
}
await page.close();
}
}
await browser.close();
if (fails.length) {
console.log(`\n${fails.length} failed`);
process.exit(1);
}
+31
View File
@@ -0,0 +1,31 @@
---
owner: @esengine
backup: @SivanCola
status: active
reviewed: 2026-10-17
---
# Relay harness
A hosted page driving a real Studio through a stand-in relay.
- Real: the production page bundle, its encrypted channel, `reasonix-studio-host` with its kernel, and Chromium.
- Replaced: the accounts API and the message relay (`tools/relayfake`), and the model provider (a scripted stream in `stack.mjs`).
The page still dials `id.reasonix.io` and `remote.reasonix.io`; the browser
context answers those two hosts with the stand-ins.
```
go build -o relay/bin/studio-host ../../cmd/reasonix-studio-host
go build -o relay/bin/relayfake ../../tools/relayfake
pnpm exec vite build --outDir dist-relay --emptyOutDir
RELAY_HOST_BIN=$PWD/relay/bin/studio-host RELAY_FAKE_BIN=$PWD/relay/bin/relayfake \
RELAY_DIST=$PWD/dist-relay node relay/live.mjs
```
`relay/storm.mjs` is the other scenario: a page served by the Studio itself (no relay), nine panes on the shared stream, and the browser going offline and back across three turns. Run it the same way, with `RELAY_DIST` pointing at the same build.
`CHROMIUM_PATH` selects a browser other than the one Playwright installed.
A change to how events travel between a page and a kernel is judged by this
run, not only by a unit test of one side. Add a scenario to `live.mjs` rather
than a second harness.
+61
View File
@@ -0,0 +1,61 @@
// A hosted page driving a real Studio through a stand-in relay. What a shipped
// page does over that link is the claim: the production bundle, the encrypted
// channel, the Studio's own host and a real kernel are all real; only the two
// Reasonix services between them are replaced (tools/relayfake).
import { chromium } from "playwright";
import { startStack, routeServices, until } from "./stack.mjs";
const need = (name) => {
if (!process.env[name]) throw new Error(`${name} is not set; see relay/README.md`);
return process.env[name];
};
const stack = await startStack({ hostBin: need("RELAY_HOST_BIN"), fakeBin: need("RELAY_FAKE_BIN"), dist: need("RELAY_DIST") });
const browser = await chromium.launch(process.env.CHROMIUM_PATH ? { executablePath: process.env.CHROMIUM_PATH } : {});
const failures = [];
const check = (name, ok, detail = "") => {
console.log(`${ok ? "ok " : "FAIL"} ${name}${detail ? ` - ${detail}` : ""}`);
if (!ok) failures.push(name);
};
try {
const context = await browser.newContext({ viewport: { width: 1300, height: 850 } });
await routeServices(context, stack);
const page = await context.newPage();
const pageErrors = [];
page.on("pageerror", (error) => pageErrors.push(error.message));
await page.goto(stack.pageUrl());
await page.evaluate(() => { window.__survivor = true; });
await page.keyboard.press("Escape");
const pane = await stack.api("/runtimes", "POST", { root: stack.workspace });
check("a pane opens on the Studio", pane.status === 200, `status ${pane.status}`);
const composer = page.getByLabel("Task input").first();
await composer.waitFor({ timeout: 30_000 });
const text = () => page.evaluate(() => document.body.innerText);
const count = async (word) => ((await text()).match(new RegExp(word, "g")) ?? []).length;
const send = async (message) => {
await composer.fill(message);
await composer.press("Enter");
};
await send("first");
const live = await until("the reply to stream onto the page", async () => (await count("word11")) >= 1, 20_000).then(() => true, () => false);
check("a reply streams onto the page over the relay", live, `provider saw ${stack.provider.requests.length} request(s)`);
const dropped = await stack.ctl("drop-controllers", "POST");
check("the relay drops the page's socket", dropped.dropped === 1, JSON.stringify(dropped));
await until("the page to reconnect", async () => (await stack.ctl("state")).controllers === 1, 30_000).catch(() => {});
await send("second");
const after = await until("a reply after the reconnect", async () => (await count("word11")) >= 2, 40_000).then(() => true, () => false);
check("a reply streams onto the page after the relay drops the socket", after);
check("the page was never reloaded", await page.evaluate(() => window.__survivor === true));
check("the page threw no error", pageErrors.length === 0, pageErrors.join(" | "));
} catch (error) {
check("the scenario ran to the end", false, String(error));
} finally {
await browser.close();
await stack.stop();
}
process.exit(failures.length ? 1 : 0);
+172
View File
@@ -0,0 +1,172 @@
import http from "node:http";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { spawn } from "node:child_process";
const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
export async function until(what, probe, ms = 20_000) {
const end = Date.now() + ms;
for (;;) {
const value = await probe();
if (value) return value;
if (Date.now() > end) throw new Error(`timed out waiting for ${what}`);
await wait(100);
}
}
function firstLine(child, what) {
return new Promise((resolve, reject) => {
let buffer = "";
child.stdout.on("data", (chunk) => {
buffer += chunk;
const at = buffer.indexOf("\n");
if (at >= 0) resolve(buffer.slice(0, at));
});
child.once("exit", (code) => reject(new Error(`${what} exited (${code}) before it was ready`)));
});
}
function scriptedProvider(words, gapMs) {
const requests = [];
const server = http.createServer((req, res) => {
let body = "";
req.on("data", (chunk) => { body += chunk; });
req.on("end", () => {
if (!req.url.includes("chat/completions")) {
res.writeHead(404);
res.end("{}");
return;
}
requests.push({ at: Date.now(), body });
res.writeHead(200, { "content-type": "text/event-stream" });
const chunk = (delta) =>
`data: ${JSON.stringify({ id: "x", object: "chat.completion.chunk", model: "fake-m", choices: [{ index: 0, delta }] })}\n\n`;
let n = 0;
const timer = setInterval(() => {
if (n < words) {
res.write(chunk({ content: `word${n++} ` }));
return;
}
clearInterval(timer);
res.write(`data: ${JSON.stringify({ id: "x", object: "chat.completion.chunk", model: "fake-m", choices: [{ index: 0, delta: {}, finish_reason: "stop" }], usage: { prompt_tokens: 10, completion_tokens: 12, total_tokens: 22 } })}\n\n`);
res.write("data: [DONE]\n\n");
res.end();
}, gapMs);
res.on("close", () => clearInterval(timer));
});
});
return new Promise((resolve) => server.listen(0, "127.0.0.1", () => resolve({ server, requests, port: server.address().port })));
}
function pageServer(dist) {
const types = { ".html": "text/html", ".js": "text/javascript", ".css": "text/css", ".svg": "image/svg+xml", ".woff2": "font/woff2", ".json": "application/json", ".webmanifest": "application/manifest+json", ".png": "image/png" };
const server = http.createServer((req, res) => {
const target = path.join(dist, decodeURIComponent(new URL(req.url, "http://x").pathname));
const file = target.startsWith(dist) && fs.existsSync(target) && fs.statSync(target).isFile() ? target : path.join(dist, "index.html");
res.writeHead(200, { "content-type": types[path.extname(file)] ?? "application/octet-stream" });
fs.createReadStream(file).pipe(res);
});
return new Promise((resolve) => server.listen(0, "127.0.0.1", () => resolve({ server, origin: `http://127.0.0.1:${server.address().port}` })));
}
/**
* Starts everything between a hosted page and a Studio except the page's browser:
* the real studio-host binary on a fresh data home, a stand-in accounts API and
* relay, a scripted model provider, and a static server for the built page.
*/
export async function startStack({ hostBin, fakeBin, dist, servePage = false, words = 12, gapMs = 120 }) {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "relay-stack-"));
const home = path.join(root, "home");
const workspace = path.join(root, "workspace");
fs.mkdirSync(home, { recursive: true });
fs.mkdirSync(workspace, { recursive: true });
const provider = await scriptedProvider(words, gapMs);
fs.writeFileSync(path.join(home, "config.toml"), [
'language = "en"',
'default_model = "fake"',
`providers = [{ name = "fake", kind = "openai", base_url = "http://127.0.0.1:${provider.port}/v1", model = "fake-m", api_key_env = "FAKE_KEY" }]`,
"",
].join("\n"));
fs.writeFileSync(path.join(home, ".env"), "FAKE_KEY=fixture\n");
const fake = spawn(fakeBin, [], { stdio: ["ignore", "pipe", "inherit"] });
const urls = JSON.parse(await firstLine(fake, "relayfake"));
const ctl = async (route, method = "GET") => (await fetch(`${urls.accounts}/ctl/${route}`, { method })).json();
const host = spawn(hostBin, servePage ? ["-page", dist] : [], {
stdio: ["pipe", "pipe", "inherit"],
env: {
...process.env, HOME: root, USERPROFILE: root, REASONIX_HOME: home, REASONIX_STATE_HOME: home,
REASONIX_ACCOUNTS_URL: urls.accounts, REASONIX_REMOTE_URL: urls.relay, REASONIX_ACCOUNT_TOKEN: "fixture-token",
FAKE_KEY: "fixture",
},
});
const handshake = JSON.parse(await firstLine(host, "studio-host"));
const state = await until("the Studio to reach the relay", async () => {
const s = await ctl("state");
return s.deviceId && s.deviceOnline ? s : null;
});
const page = await pageServer(dist);
const api = async (route, method = "GET", body) => {
const response = await fetch(handshake.origin + route, {
method,
headers: { cookie: `reasonix_token=${handshake.token}`, origin: handshake.origin, "content-type": "application/json" },
body: body === undefined ? undefined : JSON.stringify(body),
});
return { status: response.status, json: await response.json().catch(() => null) };
};
return {
urls, deviceId: state.deviceId, pageOrigin: page.origin, hostOrigin: handshake.origin, hostToken: handshake.token, workspace, provider, ctl, api,
pageUrl: () => `${page.origin}/?device=${state.deviceId}`,
async stop() {
const gone = new Promise((resolve) => host.once("exit", resolve));
host.stdin.end();
host.kill();
await gone;
fake.kill();
page.server.close();
provider.server.close();
fs.rmSync(root, { recursive: true, force: true });
},
};
}
/**
* Points a browser context's two Reasonix services at the stand-ins. The page is
* the production build, so it still dials id.reasonix.io and remote.reasonix.io;
* the requests and the socket are answered here. The socket is a plain pipe:
* the page and the Studio speak their own encrypted protocol across it.
*/
export async function routeServices(context, stack) {
await context.route("https://id.reasonix.io/**", async (route) => {
const request = route.request();
const cors = {
"access-control-allow-origin": request.headers().origin ?? "*",
"access-control-allow-credentials": "true",
"access-control-allow-headers": "content-type",
};
if (request.method() === "OPTIONS") {
await route.fulfill({ status: 204, headers: cors });
return;
}
const answer = await fetch(stack.urls.accounts + new URL(request.url()).pathname, {
method: request.method(),
headers: { "content-type": "application/json" },
body: request.postData() ?? undefined,
});
await route.fulfill({ status: answer.status, headers: { ...cors, "content-type": "application/json" }, body: await answer.text() });
});
await context.routeWebSocket(/remote\.reasonix\.io/, (page) => {
const upstream = new WebSocket(`${stack.urls.relay}/v1/sessions/connect?fixture=1`, "reasonix.remote.v1");
const queued = [];
upstream.onopen = () => queued.splice(0).forEach((message) => upstream.send(message));
upstream.onmessage = (event) => page.send(String(event.data));
upstream.onclose = (event) => page.close({ code: event.code >= 1000 && event.code < 5000 ? event.code : 1011, reason: event.reason });
page.onMessage((message) => (upstream.readyState === 1 ? upstream.send(message) : queued.push(message)));
page.onClose(() => upstream.close());
});
}
+117
View File
@@ -0,0 +1,117 @@
// A real page on a real Studio while its network flaps. Nine panes stream on one
// multiplexed connection while the browser keeps going offline and back across
// three turns, so the flaps land on turn starts and ends. What is judged is what
// the person sees on the pane they have open, read without navigating: raw frames
// are not the claim, because a page may recover a lost one from a snapshot.
import fs from "node:fs";
import path from "node:path";
import { chromium } from "playwright";
import { startStack, until } from "./stack.mjs";
const need = (name) => {
if (!process.env[name]) throw new Error(`${name} is not set; see relay/README.md`);
return process.env[name];
};
const PANES = 9;
const TURNS = 3;
const WORDS = 15;
const stack = await startStack({ hostBin: need("RELAY_HOST_BIN"), fakeBin: need("RELAY_FAKE_BIN"), dist: need("RELAY_DIST"), servePage: true, words: WORDS, gapMs: 100 });
const browser = await chromium.launch(process.env.CHROMIUM_PATH ? { executablePath: process.env.CHROMIUM_PATH } : {});
const failures = [];
const check = (name, ok, detail = "") => {
console.log(`${ok ? "ok " : "FAIL"} ${name}${detail ? ` - ${detail}` : ""}`);
if (!ok) failures.push(name);
};
try {
const panes = [];
for (let i = 0; i < PANES; i++) {
const root = path.join(stack.workspace, `p${i}`);
fs.mkdirSync(root);
panes.push((await stack.api("/runtimes", "POST", { root })).json);
}
const context = await browser.newContext({ viewport: { width: 1300, height: 1400 } });
await context.addCookies([{ name: "reasonix_token", value: stack.hostToken, url: stack.hostOrigin }]);
await context.addInitScript(() => {
const Native = window.EventSource;
window.__streams = [];
window.EventSource = class extends Native {
constructor(url, options) {
super(url, options);
window.__streams.push(String(url));
}
};
});
const page = await context.newPage();
if (process.env.RELAY_DEBUG) {
page.on("console", (m) => console.log("[console]", m.type(), m.text().slice(0, 160)));
page.on("pageerror", (e) => console.log("[pageerror]", e.message));
page.on("framenavigated", (f) => console.log("[navigated]", f.url()));
}
await page.goto(stack.hostOrigin + "/");
await page.keyboard.press("Escape");
await until("the sidebar to list every project", async () => (await page.getByText(/^p\d$/).count()) >= PANES, 30_000).catch(() => {});
const turnOn = async (turn) => {
await Promise.all(panes.map((p, i) => stack.api(`${p.base}/submit`, "POST", { input: `pane${i} turn ${turn}` })));
return until(`turn ${turn} to finish on every pane`, async () => {
const states = await Promise.all(panes.map(async (p) => (await stack.api(`${p.base}/status`)).json));
return states.every((st) => st && st.running === false);
}, 60_000).then(() => true, () => false);
};
let settled = await turnOn(0);
await page.getByText(/^pane0 turn/).first().click({ timeout: 15_000 }).catch(async () => {
await page.getByText("p0", { exact: true }).first().click();
await page.getByText(/^pane0 turn/).first().click();
});
await page.waitForTimeout(1500);
if (process.env.RELAY_SHOT) await page.screenshot({ path: process.env.RELAY_SHOT.replace('.png', '-before.png') });
let flapping = true;
const flaps = (async () => {
while (flapping) {
await context.setOffline(true);
await new Promise((resolve) => setTimeout(resolve, 200));
await context.setOffline(false);
await new Promise((resolve) => setTimeout(resolve, 400));
}
})();
for (let turn = 1; turn <= TURNS && settled; turn++) settled = await turnOn(turn);
flapping = false;
await flaps;
await context.setOffline(false);
check("every pane finished every turn on the Studio", settled);
const streams = await page.evaluate(() => window.__streams);
check("no pane held a stream of its own", streams.every((u) => !/\/rt\/.*\/events/.test(u)), streams.filter((u) => /\/rt\//.test(u)).join(" "));
// The open pane is read where it stands, with no navigation: opening a pane
// reloads it from its transcript, which would hide a live stream that stopped.
// Recovery after the last flap has a bounded time to finish.
const read = () => page.evaluate(() => document.body.innerText);
const done = await until("the open pane to settle", async () => /\bIdle\b|Done/.test(await read()), 30_000).then(() => true, () => false);
const text = await read();
const replies = (text.match(new RegExp(`word${WORDS - 1}`, "g")) ?? []).length;
check("the open pane shows every reply it was sent while the network flapped", replies >= TURNS + 1, `${replies} of ${TURNS + 1} replies`);
check("the open pane returns to idle", done);
// The sidebar counts what is running from the page's own state of every pane,
// open or not; the Studio, the shared stream's metrics and the tree already agree
// that nothing runs, so a count that stays up is the page not recovering.
const live = () => page.evaluate(() => document.querySelector('.studio-session-segments button[data-value="live"] b')?.textContent?.trim() ?? "0");
const quiet = await until("the sidebar's running count to reach zero", async () => (await live()) === "0", 45_000).then(() => true, () => false);
const stuck = quiet ? "" : await page.evaluate(() => [...document.querySelectorAll(".sessrow[data-run]")].map((r) => `${r.textContent.trim().slice(0, 18)}:${r.getAttribute("data-run")}`).join(" | "));
check("the sidebar's running count settles to zero once every turn has finished", quiet, quiet ? "" : `reads ${await live()}; rows ${stuck}`);
if (process.env.RELAY_SHOT) {
await page.mouse.move(700, 700);
await page.waitForTimeout(2500);
await page.screenshot({ path: process.env.RELAY_SHOT });
}
} catch (error) {
check("the scenario ran to the end", false, String(error));
} finally {
await browser.close();
await stack.stop();
}
process.exit(failures.length ? 1 : 0);
+17 -1
View File
@@ -69,6 +69,9 @@ export const ACTIONS: UIAction[] = [
{ id: "appearance.session-tabs", kind: "view", target: "none", proof: "interaction" }, { id: "appearance.session-tabs", kind: "view", target: "none", proof: "interaction" },
{ id: "appearance.esc-stop", kind: "view", target: "none", proof: "interaction" }, { id: "appearance.esc-stop", kind: "view", target: "none", proof: "interaction" },
{ id: "workspace.configure", kind: "view", target: "none", proof: "interaction" }, { id: "workspace.configure", kind: "view", target: "none", proof: "interaction" },
{ id: "workspace.prepare-cancel", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
{ id: "workspace.prepare-details", kind: "view", target: "none", proof: "interaction" },
{ id: "workspace.prepare-edit", kind: "view", target: "none", proof: "interaction" },
{ id: "workspace.source-search", kind: "view", target: "none", proof: "interaction" }, { id: "workspace.source-search", kind: "view", target: "none", proof: "interaction" },
{ id: "workspace.source-navigate", kind: "view", target: "none", proof: "interaction" }, { id: "workspace.source-navigate", kind: "view", target: "none", proof: "interaction" },
{ id: "workspace.source-select", kind: "view", target: "none", proof: "interaction" }, { id: "workspace.source-select", kind: "view", target: "none", proof: "interaction" },
@@ -198,6 +201,9 @@ export const ACTIONS: UIAction[] = [
{ id: "mcp.enabled", kind: "kernel-mutation", target: "entity", proof: "interaction" }, { id: "mcp.enabled", kind: "kernel-mutation", target: "entity", proof: "interaction" },
{ id: "mcp.retry", kind: "repeatable", target: "entity", proof: "interaction" }, { id: "mcp.retry", kind: "repeatable", target: "entity", proof: "interaction" },
{ id: "mcp.trust", kind: "kernel-mutation", target: "entity", proof: "interaction" }, { id: "mcp.trust", kind: "kernel-mutation", target: "entity", proof: "interaction" },
{ id: "mcp.signin", kind: "kernel-mutation", target: "entity", proof: "interaction" },
{ id: "mcp.follow", kind: "kernel-mutation", target: "entity", proof: "interaction" },
{ id: "mcp.unfollow", kind: "kernel-mutation", target: "entity", proof: "interaction" },
{ id: "mcp.remove", kind: "destructive", target: "entity", proof: "interaction" }, { id: "mcp.remove", kind: "destructive", target: "entity", proof: "interaction" },
{ id: "mcp.load", kind: "kernel-mutation", target: "entity", proof: "interaction" }, { id: "mcp.load", kind: "kernel-mutation", target: "entity", proof: "interaction" },
{ id: "mcp.tool-search", kind: "view", target: "entity", proof: "interaction" }, { id: "mcp.tool-search", kind: "view", target: "entity", proof: "interaction" },
@@ -237,6 +243,7 @@ export const ACTIONS: UIAction[] = [
{ id: "feedback.contact", kind: "view", target: "none", proof: "interaction" }, { id: "feedback.contact", kind: "view", target: "none", proof: "interaction" },
{ id: "feedback.shot.pick", kind: "view", target: "none", proof: "interaction" }, { id: "feedback.shot.pick", kind: "view", target: "none", proof: "interaction" },
{ id: "feedback.shot.capture", kind: "shell-native", target: "none", proof: "interaction" }, { id: "feedback.shot.capture", kind: "shell-native", target: "none", proof: "interaction" },
{ id: "side.collapse", kind: "view", target: "none", proof: "interaction" },
{ id: "side.toggle", kind: "view", target: "none", proof: "interaction" }, { id: "side.toggle", kind: "view", target: "none", proof: "interaction" },
{ id: "side.quote", kind: "view", target: "none", proof: "interaction" }, { id: "side.quote", kind: "view", target: "none", proof: "interaction" },
{ id: "side.unquote", kind: "view", target: "none", proof: "interaction" }, { id: "side.unquote", kind: "view", target: "none", proof: "interaction" },
@@ -409,6 +416,7 @@ export const ACTIONS: UIAction[] = [
// Reached from the workspace tree and from the remote host tree, which are // Reached from the workspace tree and from the remote host tree, which are
// two surfaces for one intent; opening an existing session is not starting a // two surfaces for one intent; opening an existing session is not starting a
// new one, and the host asked differs. // new one, and the host asked differs.
{ id: "peer.thread-open", kind: "navigation", target: "none", proof: "interaction" },
{ id: "session.open", kind: "kernel-mutation", target: "none", proof: "authority-effect" }, { id: "session.open", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
{ id: "remote.open", kind: "kernel-mutation", target: "none", proof: "authority-effect" }, { id: "remote.open", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
// Asking a machine whether it answers. Nothing on either side changes, and // Asking a machine whether it answers. Nothing on either side changes, and
@@ -543,7 +551,12 @@ export const ACTIONS: UIAction[] = [
// two different things a reader wants, however alike they look in CSS. // two different things a reader wants, however alike they look in CSS.
{ id: "appearance.language", kind: "kernel-mutation", target: "none", proof: "authority-effect" }, { id: "appearance.language", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
{ id: "appearance.zoom", 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" },
{ id: "web-access.move", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
{ id: "web-access.consent", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
{ id: "web-access.searxng-url", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
{ id: "appearance.reading-size", kind: "kernel-mutation", target: "none", proof: "authority-effect" }, { id: "appearance.reading-size", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
{ id: "appearance.text-size", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
{ id: "appearance.measure", kind: "kernel-mutation", target: "none", proof: "authority-effect" }, { id: "appearance.measure", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
{ id: "appearance.background", kind: "kernel-mutation", target: "none", proof: "authority-effect" }, { id: "appearance.background", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
{ id: "appearance.font", kind: "kernel-mutation", target: "entity", proof: "authority-effect" }, { id: "appearance.font", kind: "kernel-mutation", target: "entity", proof: "authority-effect" },
@@ -642,6 +655,10 @@ export const ACTIONS: UIAction[] = [
{ id: "peers.expand", kind: "view", target: "none", proof: "interaction" }, { id: "peers.expand", kind: "view", target: "none", proof: "interaction" },
{ id: "peers.send", kind: "kernel-mutation", target: "none", proof: "authority-effect" }, { id: "peers.send", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
{ id: "peers.approve", kind: "kernel-mutation", target: "none", proof: "authority-effect" }, { id: "peers.approve", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
{ id: "peers.approve-project", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
{ id: "peers.enable", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
{ id: "peers.dispatch", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
{ id: "peers.release-revoke", kind: "kernel-mutation", target: "entity", proof: "authority-effect" },
{ id: "peers.deny", kind: "kernel-mutation", target: "none", proof: "authority-effect" }, { id: "peers.deny", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
// Read by the running turn at its next round, so no rebuild stands between the // Read by the running turn at its next round, so no rebuild stands between the
// click and the canonical change. // click and the canonical change.
@@ -653,7 +670,6 @@ export const ACTIONS: UIAction[] = [
{ id: "persona.prefix", kind: "view", target: "none", proof: "interaction" }, { id: "persona.prefix", kind: "view", target: "none", proof: "interaction" },
{ id: "persona.suffix", kind: "view", target: "none", proof: "interaction" }, { id: "persona.suffix", kind: "view", target: "none", proof: "interaction" },
{ id: "persona.preset", kind: "view", target: "optional", proof: "interaction" }, { id: "persona.preset", kind: "view", target: "optional", proof: "interaction" },
{ id: "persona.complete", kind: "view", target: "none", proof: "interaction" },
{ id: "persona.runtime-context", kind: "view", target: "none", proof: "interaction" }, { id: "persona.runtime-context", kind: "view", target: "none", proof: "interaction" },
{ id: "persona.save", kind: "kernel-mutation", target: "none", proof: "authority-effect" }, { id: "persona.save", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
// The kernel announces the change on the stream, so no rebuild stands between // The kernel announces the change on the stream, so no rebuild stands between
+71 -11
View File
@@ -63,6 +63,7 @@ export const EN: Record<string, string> = {
"收回 {rule}": "Take back {rule}", "收回 {rule}": "Take back {rule}",
"放大第 {n} 张": "Enlarge image {n}", "放大第 {n} 张": "Enlarge image {n}",
"工具截图 {n}": "Tool screenshot {n}", "工具截图 {n}": "Tool screenshot {n}",
"视频 {name}({detail})已原生发送给 {model}": "Video {name} ({detail}) sent to {model} as a video file",
"查看图片 {name}": "Preview image {name}", "查看图片 {name}": "Preview image {name}",
"关闭图片预览": "Close image preview", "关闭图片预览": "Close image preview",
"放大的工具截图": "The enlarged tool screenshot", "放大的工具截图": "The enlarged tool screenshot",
@@ -111,6 +112,8 @@ export const EN: Record<string, string> = {
"复制引用": "Copy reference", "复制引用": "Copy reference",
"加入对话": "Add to conversation", "加入对话": "Add to conversation",
"用默认浏览器打开此页": "Open this page in the default browser", "用默认浏览器打开此页": "Open this page in the default browser",
"用系统默认程序打开这个文件": "Open this file with the system default program",
"用系统默认程序打开这个文件,网页里的脚本会照常运行": "Opens this file with the system default program; scripts in the page run as usual",
"选中的内容": "Selected text", "选中的内容": "Selected text",
"引用 {name}": "Quote {name}", "引用 {name}": "Quote {name}",
"此名称不能表示为 @ 引用": "This name cannot be represented as an @ reference", "此名称不能表示为 @ 引用": "This name cannot be represented as an @ reference",
@@ -162,7 +165,6 @@ export const EN: Record<string, string> = {
"已完成": "Done", "已完成": "Done",
"已中断": "Interrupted", "已中断": "Interrupted",
"未执行": "Not run", "未执行": "Not run",
"已停止": "Stopped",
"请求未能送达内核(HTTP {status})": "That request never reached the kernel (HTTP {status})", "请求未能送达内核(HTTP {status})": "That request never reached the kernel (HTTP {status})",
"移动 {id} 的界面": "Move {id}'s view", "移动 {id} 的界面": "Move {id}'s view",
"等待批准": "Waiting for approval", "等待批准": "Waiting for approval",
@@ -209,6 +211,58 @@ export const EN: Record<string, string> = {
"改为输入路径": "Type a path instead", "改为输入路径": "Type a path instead",
"输入文件夹路径": "Type the folder's path", "输入文件夹路径": "Type the folder's path",
"界面大小": "Interface size", "界面大小": "Interface size",
"等待浏览器登录…": "Waiting for the browser sign-in…",
"接受并以后都自动接受这个服务器的改动": "Accept, and accept this server's changes by themselves from now on",
"自动接受这个服务器的改动": "Changes from this server are accepted by themselves",
"这个服务器的改动会自动接受,并在通知里告诉你。": "This server's changes are accepted by themselves, and you are told in a notice.",
"取消跟随": "Stop following",
"取消中…": "Stopping…",
"联网:搜索与抓取": "Web: search and fetch",
"agent 查资料和读网页时用哪些后端。免费的先试,付费的只有你明确同意、并且前面的都失败时才会用。": "Which backends the agent uses to look things up and read pages. Free ones are tried first; a paid one is used only if you clearly agreed and the ones before it failed.",
"网页抓取": "Web fetch",
"按顺序试:先用前面的免费后端,都失败了才会用付费的。": "Tried in order: the free backends first, and a paid one only when those all fail.",
"没有启用任何搜索后端:沿用模型自带的搜索。": "No search backend is on: the model's own search is used.",
"没有启用任何抓取后端:只用本机抓取。": "No fetch backend is on: only this machine's own fetching is used.",
"SearXNG": "SearXNG",
"DeepSeek 官方搜索": "DeepSeek search",
"本机抓取": "Local fetch",
"DeepSeek 官方抓取": "DeepSeek fetch",
"你自己部署的搜索服务,免费,需要填它的地址。": "A search service you run yourself; free, and it needs its address.",
"DeepSeek 官方的搜索接口,按次计费。": "DeepSeek's own search API; billed per call.",
"由本机直接下载网页,免费。": "This machine downloads the page directly; free.",
"DeepSeek 官方的网页抓取接口,按次计费。": "DeepSeek's own page-fetch API; billed per call.",
"同意网页搜索按次计费": "Agree to be billed per web search",
"同意网页抓取按次计费": "Agree to be billed per web fetch",
"同意后,前面的后端都失败时才会调用它,费用记在你的 DeepSeek 账户上。不勾选就不会用它。": "Once you agree, it is called only when the backends before it have failed, and the cost goes to your DeepSeek account. Without your agreement it is never used.",
"需要一个带密钥的 DeepSeek 提供方,才能使用并同意这一项。": "This needs a DeepSeek provider with a key before it can be used or agreed to.",
"目标用量": "Goal usage",
"已阻塞": "Blocked",
"已停止": "Stopped",
"达到花费上限": "the spend limit was reached",
"目标没有进展": "the goal was not making progress",
"无法评估目标是否完成": "the goal could not be evaluated",
"你手动停止": "you stopped it",
"在等你回答": "waiting for your answer",
"{turns} 轮 · {requests} 次请求 · {tokens} tokens · 用时 {time}": "{turns} turns · {requests} requests · {tokens} tokens · {time} of work",
"停下的原因:{cause}": "Why it stopped: {cause}",
"最近一次判断:{reason}": "Latest assessment: {reason}",
"SearXNG 地址": "SearXNG address",
"保存后只对之后新开的窗格生效,已开的窗格沿用旧设置。这些设置写在用户配置文件里:": "A saved change reaches only panes opened afterwards; open panes keep the old settings. These settings are kept in the user config file: ",
"这些设置现在是只读的:这个窗口没有被授予编辑权限。": "These settings are read-only now: this window has not been granted editing.",
"启用会话间消息": "Turn on session messages",
"同一台机器上的会话互相发文字。收到的文字先挂起,等你批准才会交给模型。": "Sessions on this machine send each other text. Incoming text is held until you approve it; only then does the model see it.",
"允许模型把任务交给别的会话": "Let the model hand a task to another session",
"给模型一个把任务交给另一个项目里会话的工具;对方的用户批准后才会送到。": "Gives the model a tool to hand a task to a session in another project; it is delivered once that session's user approves.",
"先启用会话间消息。": "Turn on session messages first.",
"这些项目收到的消息不再询问,直接交给模型:": "Messages sent to these projects are no longer asked about and go straight to the model:",
"已长期放行的项目": "Projects released for good",
"撤销放行:{name}": "Revoke release: {name}",
"撤销": "Revoke",
"已保存,下个回合起生效。": "Saved; takes effect from the next turn.",
"这些开关写在用户配置文件里:": "These switches are kept in the user config file: ",
"批准并记住项目「{name}」": "Approve and remember project \"{name}\"",
"“记住项目”记的是收到消息的项目「{name}」({path}):以后发到这个项目的消息不再询问。可在 设置 → 会话间消息 撤销。": "\"Remember project\" remembers the project that received the message, \"{name}\" ({path}): later messages sent to it are no longer asked about. Undo it in Settings → Session messages.",
"此会话不支持:没有可打开的收件夹,消息无处挂起。": "Not supported in this session: there is no folder to open, so a message has nowhere to be held.",
"正文字号": "Body text size", "正文字号": "Body text size",
"尚未派出": "None dispatched", "尚未派出": "None dispatched",
"运行中 {live} / 共 {total}": "{live} running / {total} total", "运行中 {live} / 共 {total}": "{live} running / {total} total",
@@ -354,6 +408,8 @@ export const EN: Record<string, string> = {
"读取代码、运行测试与修改文件均只在你选定的文件夹内进行。": "Reading code, running tests and editing files all happen inside the folder you choose.", "读取代码、运行测试与修改文件均只在你选定的文件夹内进行。": "Reading code, running tests and editing files all happen inside the folder you choose.",
"使用当前位置": "Stay where I am", "使用当前位置": "Stay where I am",
"隔离": "Isolated", "隔离": "Isolated",
"原目录": "Local checkout",
"隔离目录": "Worktree",
"移除": "Remove", "移除": "Remove",
"删除": "Delete", "删除": "Delete",
"删除该会话": "Delete this session", "删除该会话": "Delete this session",
@@ -398,6 +454,7 @@ export const EN: Record<string, string> = {
"暂停派发": "Hold", "暂停派发": "Hold",
"继续派发": "Release", "继续派发": "Release",
"排队": "Queued", "排队": "Queued",
"含图片,本轮结束后发送": "Has an image: sent when this turn ends",
"插话排队": "Steer queued", "插话排队": "Steer queued",
"后台任务续接": "Background job follow-on", "后台任务续接": "Background job follow-on",
"插话已收": "Steer accepted", "插话已收": "Steer accepted",
@@ -437,7 +494,9 @@ export const EN: Record<string, string> = {
"更大": "Larger", "更大": "Larger",
"小": "Small", "小": "Small",
"大": "Large", "大": "Large",
"「界面」会同时缩放边距与控件,「正文」仅调整对话中的文字大小,两者独立设置。": "Interface scales spacing and controls together; Body text changes only the text in the conversation. The two are set independently.", "「界面」会同时缩放边距与控件,「界面文字」只放大侧栏、设置等处的文字,「正文」仅调整对话中的文字大小,三者独立设置。": "Interface scales spacing and controls together; Interface text enlarges only the text in the sidebar, settings and the like; Body text changes only the text in the conversation. The three are set independently.",
"界面文字": "Interface text",
"界面文字大小": "Interface text size",
"字体": "Typeface", "字体": "Typeface",
"等宽": "Monospace", "等宽": "Monospace",
"清除": "Clear", "清除": "Clear",
@@ -615,6 +674,7 @@ export const EN: Record<string, string> = {
"文件管理器": "File manager", "文件管理器": "File manager",
"终端": "Terminal", "终端": "Terminal",
"在系统文件管理器中显示此文件": "Show this file in the system file manager", "在系统文件管理器中显示此文件": "Show this file in the system file manager",
"只读 · 来自你授权的文件夹": "Read-only · from a folder you granted",
"重命名页签": "Rename tab", "重命名页签": "Rename tab",
"显示隐藏文件": "Show hidden files", "显示隐藏文件": "Show hidden files",
"在访达中显示": "Reveal in Finder", "在访达中显示": "Reveal in Finder",
@@ -874,6 +934,7 @@ export const EN: Record<string, string> = {
"无法读取登录状态": "Sign-in status could not be read", "无法读取登录状态": "Sign-in status could not be read",
"登录状态不可用": "Sign-in status unavailable", "登录状态不可用": "Sign-in status unavailable",
"上次的登录已过期。": "The previous sign-in has expired.", "上次的登录已过期。": "The previous sign-in has expired.",
"这次登录请求已过期,再试一次。": "This sign-in request expired. Try again.",
"只清除本地登录凭证,不影响会话、记忆和配置": "只清除本地登录凭证,不影响会话、记忆和配置":
"Clears the local credential only; sessions, memory and config are untouched", "Clears the local credential only; sessions, memory and config are untouched",
"用于社区发帖与崩溃问题跟进。与模型 API key 无关 —— 登录不会上传你的对话、代码或密钥。": "用于社区发帖与崩溃问题跟进。与模型 API key 无关 —— 登录不会上传你的对话、代码或密钥。":
@@ -1195,6 +1256,8 @@ export const EN: Record<string, string> = {
"工作区空出来了,这个会话已经继续": "The workspace is free again; this session has continued", "工作区空出来了,这个会话已经继续": "The workspace is free again; this session has continued",
"一个 MCP 服务有工具的定义是新增的或与你批准过的不同,已暂不提供给模型;详情里是这些工具,核对服务后再接受": "一个 MCP 服务有工具的定义是新增的或与你批准过的不同,已暂不提供给模型;详情里是这些工具,核对服务后再接受":
"An MCP server has tools whose definitions are new or differ from what you approved; they are held out of the model's tools until you accept them (details list them)", "An MCP server has tools whose definitions are new or differ from what you approved; they are held out of the model's tools until you accept them (details list them)",
"你选择了跟随的一个 MCP 服务改了或新增了工具定义,已按你的选择直接采用;详情里是这些工具":
"An MCP server you follow changed or added tool definitions and they were taken as you chose; the details list them",
"还没轮到这个会话写,这次等待就结束了": "The wait ended before this session's turn to write came", "还没轮到这个会话写,这次等待就结束了": "The wait ended before this session's turn to write came",
// ── 宿主通知卡片自己的头部(NoticeCard)─────────────────────── // ── 宿主通知卡片自己的头部(NoticeCard)───────────────────────
@@ -1592,8 +1655,10 @@ export const EN: Record<string, string> = {
// ── 系统人设 ─────────────────────────────────────────────────── // ── 系统人设 ───────────────────────────────────────────────────
"人设": "Persona", "人设": "Persona",
"系统人设": "System persona", "系统人设": "System persona",
"系统人设决定 agent 的身份:前缀写在系统提示词最前面,后缀写在内置指令之后。保存只影响之后新建的会话,当前会话保持它打开时的提示词。": "已保存 · 下次会话构建时生效": "Saved · applies at the next session build",
"The system persona shapes the agent's identity: the prefix leads the system prompt and the suffix follows the built-in guidance. Saving affects only sessions started afterwards; the running session keeps the prompt it opened with.", "下次会话构建时生效": "Applies at the next session build",
"系统人设决定 agent 的身份:前缀写在系统提示词最前面,后缀写在内置指令之后。保存不会立即重建会话;新会话及现有会话下一次重建(例如切换模型)时生效。":
"The system persona shapes the agent's identity: the prefix leads the system prompt and the suffix follows the built-in guidance. Saving does not rebuild a session immediately. Changes apply to new sessions and the next rebuild of an existing session, such as a model switch.",
"无法读取人设设置。": "Couldn't read the persona settings.", "无法读取人设设置。": "Couldn't read the persona settings.",
"预设": "Presets", "预设": "Presets",
"严谨工程师": "Rigorous engineer", "严谨工程师": "Rigorous engineer",
@@ -1610,13 +1675,8 @@ export const EN: Record<string, string> = {
"作为系统提示词的第一段,写在内置指令之前;留空表示不添加。": "Leads the system prompt, ahead of the built-in guidance; leave it empty to add nothing.", "作为系统提示词的第一段,写在内置指令之前;留空表示不添加。": "Leads the system prompt, ahead of the built-in guidance; leave it empty to add nothing.",
"第一方指令之后": "After the first-party guidance", "第一方指令之后": "After the first-party guidance",
"紧跟在内置指令之后、运行时上下文之前;留空表示不添加。": "Follows the built-in guidance and precedes the runtime context; leave it empty to add nothing.", "紧跟在内置指令之后、运行时上下文之前;留空表示不添加。": "Follows the built-in guidance and precedes the runtime context; leave it empty to add nothing.",
"用前缀替换整个系统提示词(complete)": "Replace the whole system prompt with the prefix (complete)",
"先填写前缀才能开启。": "Fill in a prefix to turn this on.",
"开启后只用前缀作为整个系统提示词,其余内置指令、运行时上下文、记忆与技能不再写入(工具仍可用)。":
"When on, the prefix alone is the system prompt: the other built-in guidance, runtime context, memory and skills are not written (tools stay available).",
"不注入可选运行时上下文": "Do not inject optional runtime context", "不注入可选运行时上下文": "Do not inject optional runtime context",
"关闭后不注入探测到的运行环境与离线提示;指令、记忆与技能照常写入。": "When on, the probed environment and the offline note are not injected; instructions, memory and skills are written as usual.", "关闭后不注入探测到的运行环境与离线提示;指令、记忆与技能照常写入。": "When on, the probed environment and the offline note are not injected; instructions, memory and skills are written as usual.",
"启用「用前缀替换整个系统提示词」时,前缀不能为空。": "A prefix cannot be empty while it replaces the whole system prompt.", "写入用户配置,在新会话及现有会话下一次重建时生效;保存本身不会重建会话。": "Writes the user config; applies to new sessions and the next rebuild of an existing session. Saving itself does not rebuild it.",
"写入用户配置,从新会话开始生效;当前会话保持它打开时的系统提示词。": "Writes the user config; applies from new sessions, while the running session keeps the prompt it opened with.", "已保存,下次会话构建时生效": "Saved; applies at the next session build",
"已保存,新会话生效": "Saved; applies to new sessions",
}; };
+78 -1
View File
@@ -1,10 +1,74 @@
// The turn starts here: drafting, attachments, run posture, and the two actions // The turn starts here: drafting, attachments, run posture, and the two actions
// that remain available while an agent is live. // that remain available while an agent is live.
export const EN_COMPOSER: Record<string, string> = { export const EN_COMPOSER: Record<string, string> = {
"工作目录准备": "Preparing workspace",
"正在提交创建请求": "Submitting creation request",
"正在检查起始分支": "Checking source branch",
"正在创建工作目录": "Creating workspace",
"正在检出已提交文件": "Checking out committed files",
"正在复制任务附件": "Copying task attachments",
"正在绑定工作目录": "Binding workspace",
"正在清理未完成目录": "Cleaning up incomplete workspace",
"正在中止并清理": "Stopping and cleaning up",
"工作目录已绑定": "Workspace bound",
"worktree 创建失败": "Could not create worktree",
"已取消创建": "Creation cancelled",
"工作目录已就绪": "Workspace ready",
"需要确认创建结果": "Confirm creation outcome",
"正在取消创建…": "Cancelling creation…",
"正在创建 worktree…": "Creating worktree…",
"任务尚未发送,输入和附件已为你保留。": "The task has not been sent. Your draft and attachments are retained.",
"任务尚未发送,可以继续编辑。": "The task has not been sent. You can continue editing.",
"目录已经创建成功,取消未能撤回目录切换。任务尚未发送。": "The workspace was already created and bound. Cancellation could not reverse it. The task has not been sent.",
"即将进入对话,自动开始任务。": "Opening the conversation and starting your task.",
"暂不能确认操作或清理结果,请先查看详情并核对。": "The operation or cleanup outcome is unconfirmed. Check the details before proceeding.",
"请先保存任务与附件,在终端核对并处理保留的目录和任务分支。确认处理完成后,在会话菜单中关闭此会话,再从源目录新建会话重试。": "Save your task and attachments, then reconcile the retained folder and task branch in a terminal. Once reconciled, close this conversation from its menu and start a new one in the source folder to retry.",
"正在中止操作并清理未完成的目录。": "Stopping the operation and cleaning up the incomplete workspace.",
"创建耗时较长,尚未收到完成确认。": "Creation is taking longer. Completion has not been confirmed.",
"准备完成后将自动开始任务。": "Your task will start automatically when the workspace is ready.",
"正在等待状态确认": "Waiting for confirmation",
"清理未完成": "Cleanup did not finish",
"创建未完成": "Creation did not finish",
"创建已中止,目录已清理": "Creation stopped and workspace cleaned up",
"已等待 {n} 秒": "Waiting {n}s",
"用户任务": "Your task",
"请查看附件": "See the attachments",
"附件({n})": "Attachments ({n})",
"待交接": "Pending handoff",
"已保留": "Retained",
"查看详情": "View details",
"收起详情": "Hide details",
"仍在等待创建完成。你可以继续等待、查看详情,或取消创建。": "Still waiting for creation. You can keep waiting, view details, or cancel.",
"操作编号": "Operation ID",
"当前状态": "Current state",
"再次核对": "Check again",
"重试创建": "Retry creation",
"返回编辑": "Back to editing",
"任务和附件正在交接…": "Handing over the task and attachments…",
"取消创建": "Cancel creation",
"正在取消…": "Cancelling…",
"准备工作目录": "Preparing workspace",
"创建失败": "Creation failed",
"创建已取消": "Creation cancelled",
"切换当前分支": "Switch current branch",
"创建请求不完整或与原操作不一致,请返回编辑后重试": "The creation request is incomplete or differs from the original. Return to editing and try again.",
"无法确认此前创建的结果,请先检查工作目录,避免重复创建": "The previous outcome is unconfirmed. Check the workspace before creating another.",
"已有工作目录正在准备或等待清理,请先确认该操作的结果": "A workspace is being prepared or awaiting cleanup. Reconcile that operation first.",
"工作目录创建失败,任务和附件已保留": "Workspace creation failed. The task and attachments are retained.",
"未完成目录尚未清理,暂不能重试创建,请查看详情": "The incomplete workspace could not be cleaned up. Check the details before retrying.",
"创建已取消,任务尚未发送": "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", "配置工作目录": "Configure workspace",
"独立 worktree · {branch}": "New worktree · {branch}", "独立 worktree · {branch}": "Isolated worktree · {branch}",
"当前目录 · {branch}": "Current folder · {branch}", "当前目录 · {branch}": "Current folder · {branch}",
"新建 worktree": "New worktree", "新建 worktree": "New worktree",
"独立 worktree": "Isolated worktree",
"此会话已绑定独立 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.",
"为此会话创建独立的代码目录": "Create a separate code folder for this session", "为此会话创建独立的代码目录": "Create a separate code folder for this session",
"起始分支": "Source branch", "起始分支": "Source branch",
"任务分支": "Task branch", "任务分支": "Task branch",
@@ -14,6 +78,19 @@ export const EN_COMPOSER: Record<string, string> = {
"选择起始分支": "Choose source branch", "选择起始分支": "Choose source branch",
"搜索分支": "Search branches", "搜索分支": "Search branches",
"搜索分支…": "Search branches…", "搜索分支…": "Search branches…",
"当前目录": "Current folder",
"查看完整路径": "Show full path",
"复制完整路径": "Copy full path",
"复制操作编号": "Copy operation ID",
"任务分支自动创建": "A task branch will be created automatically",
"搜索起始分支…": "Search source branches…",
"搜索本地分支…": "Search local branches…",
"分支读取失败": "Could not read branches",
"正在读取分支…": "Reading branches…",
"其他 worktree 占用": "Used by another worktree",
"起点": "Source",
"从已提交版本创建,任务和附件将在就绪后发送": "Create from the committed version; the task and attachments are sent when ready",
"选择后立即切换,同目录会话也会受影响": "Switches immediately; other sessions in this folder are affected",
"远程分支": "Remote branches", "远程分支": "Remote branches",
"当前分支": "Current branch", "当前分支": "Current branch",
"没有匹配的分支": "No matching branches", "没有匹配的分支": "No matching branches",
@@ -165,6 +165,7 @@ export const EN_FEEDBACK: Record<string, string> = {
"已落地 {n} 条,已是最高等级": "{n} shipped, the highest level", "已落地 {n} 条,已是最高等级": "{n} shipped, the highest level",
"已落地 {n} 条,再 {left} 条升到「{name}」": "{n} shipped, {left} more to reach {name}", "已落地 {n} 条,再 {left} 条升到「{name}」": "{n} shipped, {left} more to reach {name}",
"已落地 {n} 条,再 {left} 条升到 L{level}": "{n} shipped, {left} more to reach L{level}", "已落地 {n} 条,再 {left} 条升到 L{level}": "{n} shipped, {left} more to reach L{level}",
"「已落地」指你的反馈已被修复或实现,并随新版本发布。": "Shipped means your report was fixed or built and released in a version.",
"每小时最多 {h} 条反馈 · 每天 {d} 条 · 每小时 {r} 条回复": "Up to {h} reports an hour · {d} a day · {r} replies an hour", "每小时最多 {h} 条反馈 · 每天 {d} 条 · 每小时 {r} 条回复": "Up to {h} reports an hour · {d} a day · {r} replies an hour",
"你现在较高的额度保留到 {date}。": "Your current higher limits stay until {date}.", "你现在较高的额度保留到 {date}。": "Your current higher limits stay until {date}.",
"较高的额度有效期至 {date},下一条落地的反馈会续上。": "Your higher limits run until {date}; the next report that ships renews them.", "较高的额度有效期至 {date},下一条落地的反馈会续上。": "Your higher limits run until {date}; the next report that ships renews them.",
+24 -1
View File
@@ -78,6 +78,16 @@ export const EN_KERNEL: Record<string, string> = {
"What this server launches changed since you enabled it; review the command and enable it again", "What this server launches changed since you enabled it; review the command and enable it again",
"这个服务没有被暂挡的工具定义": "This server has no held tool definitions", "这个服务没有被暂挡的工具定义": "This server has no held tool definitions",
"你看到的被挡定义已经变了,没有接受;请重新查看": "The held definitions changed since you saw them, so nothing was accepted; review them again", "你看到的被挡定义已经变了,没有接受;请重新查看": "The held definitions changed since you saw them, so nothing was accepted; review them again",
"这个客户端不能登录 MCP 服务器": "This client cannot sign in to MCP servers",
"你没有同意前往该登录站点,已取消登录": "You did not agree to go to that sign-in site, so sign-in was cancelled",
"这个服务器不使用 OAuth 登录": "This server does not use OAuth sign-in",
"这个服务器已经在登录中": "A sign-in for this server is already open",
"服务器给出的登录页没有主机名,已停止": "The server's sign-in page names no host, so it was stopped",
"这台机器打不开浏览器": "This machine cannot open a browser",
"登录没有在时限内完成": "Sign-in was not completed in time",
"登录已取消": "Sign-in was cancelled",
"登录没有完成:{detail}": "Sign-in did not complete: {detail}",
"没能记下这个选择:{detail}": "The choice could not be recorded: {detail}",
"开关未能保存:其存储文件无法读取或写入": "The switch was not stored: its file could not be read or written", "开关未能保存:其存储文件无法读取或写入": "The switch was not stored: its file could not be read or written",
// 待送达:条目、队列、这份存档各自会拒 // 待送达:条目、队列、这份存档各自会拒
"该条已不在待送达队列中": "That entry is no longer in the pending queue", "该条已不在待送达队列中": "That entry is no longer in the pending queue",
@@ -97,6 +107,7 @@ export const EN_KERNEL: Record<string, string> = {
"该条没有正文": "That entry has no body", "该条没有正文": "That entry has no body",
"该会话的待送达已关闭": "This session's pending queue is closed", "该会话的待送达已关闭": "This session's pending queue is closed",
"该待送达由更高版本写入,当前版本只能读取": "This queue was written by a newer version and is read-only here", "该待送达由更高版本写入,当前版本只能读取": "This queue was written by a newer version and is read-only here",
"这份待送达属于另一个运行,暂不能接管": "This pending queue belongs to another run and cannot be taken over here",
"该提交标识已被使用,且当时的内容不同": "That submission key was already used, with different content", "该提交标识已被使用,且当时的内容不同": "That submission key was already used, with different content",
// 配置文件本身坏了,以及每一个写设置的面板被它挡住时 // 配置文件本身坏了,以及每一个写设置的面板被它挡住时
"配置文件 {path} 无法读取,因此本次未保存。到「设置」里可以看到出错的位置并修复": "The config file {path} does not parse, so nothing was saved. Open Settings to see where it is broken and repair it", "配置文件 {path} 无法读取,因此本次未保存。到「设置」里可以看到出错的位置并修复": "The config file {path} does not parse, so nothing was saved. Open Settings to see where it is broken and repair it",
@@ -295,6 +306,12 @@ export const EN_KERNEL: Record<string, string> = {
"无法保存端口设置:{error}": "Could not save the port setting: {error}", "无法保存端口设置:{error}": "Could not save the port setting: {error}",
"无法保存配对记忆设置:{error}": "Could not save the paired-phones setting: {error}", "无法保存配对记忆设置:{error}": "Could not save the paired-phones setting: {error}",
"没有这台已配对的设备,可能已被移除。": "No paired device has that id; it may already have been removed.", "没有这台已配对的设备,可能已被移除。": "No paired device has that id; it may already have been removed.",
"没能用系统默认程序打开这个文件:{error}": "The file could not be opened with the system default program: {error}",
"这个内核没有窗口,打不开本机的文件。": "This kernel has no window, so it cannot open a file on this machine.",
"不能从这里打开这种类型的文件。": "This type of file cannot be opened from here.",
"只能打开文件,不能打开文件夹。": "Only a file can be opened, not a folder.",
"这个文件不存在。": "This file does not exist.",
"这个文件不在工作区或已授权的文件夹里。": "This file is not in the workspace or a granted folder.",
"这个内核没有窗口,打不开本机的编辑器。": "This kernel has no window, so it cannot open an editor on this machine.", "这个内核没有窗口,打不开本机的编辑器。": "This kernel has no window, so it cannot open an editor on this machine.",
"这个文件夹不在当前窗口的项目列表里。": "That folder is not a project in this window.", "这个文件夹不在当前窗口的项目列表里。": "That folder is not a project in this window.",
"项目文件夹已不在磁盘上。": "The project folder is no longer on disk.", "项目文件夹已不在磁盘上。": "The project folder is no longer on disk.",
@@ -345,6 +362,9 @@ export const EN_KERNEL: Record<string, string> = {
"这条消息太长": "That message is too long", "这条消息太长": "That message is too long",
"这条消息已经处理过了": "That message was already decided", "这条消息已经处理过了": "That message was already decided",
"会话间消息没能完成:{detail}": "The message between sessions did not go through: {detail}", "会话间消息没能完成:{detail}": "The message between sessions did not go through: {detail}",
"放行的项目必须是一个绝对路径的文件夹": "A released project must be a folder given as an absolute path",
"只有批准才能记住,拒绝只对这一条生效": "Only an approval can be remembered; a denial applies to this message alone",
"会话间消息的设置没能保存:{detail}": "The messaging settings could not be saved: {detail}",
"地址换了域名,已保存的密钥不会发过去;请填入这个地址对应的密钥": "The address moved to another origin, so the saved key is not sent there; enter the key for this address", "地址换了域名,已保存的密钥不会发过去;请填入这个地址对应的密钥": "The address moved to another origin, so the saved key is not sent there; enter the key for this address",
"请先写下要问的内容": "Write the question first", "请先写下要问的内容": "Write the question first",
"辅助提问没能完成,请重试": "The side question could not be completed; try again", "辅助提问没能完成,请重试": "The side question could not be completed; try again",
@@ -520,12 +540,15 @@ export const EN_KERNEL: Record<string, string> = {
"文件改动显示设置未能保存:{detail}": "The file-changes display setting was not saved: {detail}", "文件改动显示设置未能保存:{detail}": "The file-changes display setting was not saved: {detail}",
"折叠中间过程的设置未能保存:{detail}": "The fold-the-work setting was not saved: {detail}", "折叠中间过程的设置未能保存:{detail}": "The fold-the-work setting was not saved: {detail}",
"回合间叙述的设置未能保存:{detail}": "The narration setting was not saved: {detail}", "回合间叙述的设置未能保存:{detail}": "The narration setting was not saved: {detail}",
"这台服务器没有开启联网搜索与抓取设置的编辑:只有这台电脑上的窗口能改。": "Editing the web search and fetch settings is not enabled on this server: only the window on this computer can change them.",
"联网搜索与抓取的设置未能保存:{detail}": "The web search and fetch settings were not saved: {detail}",
"“{backend}”不是可选的联网后端,未做任何修改。": "\u201c{backend}\u201d is not a web backend you can choose; nothing was changed.",
"SearXNG 地址必须是带主机名的 http(s) 地址,不能含账号、查询串或片段,未做任何修改。": "The SearXNG address must be an http(s) address with a host, without credentials, query or fragment; nothing was changed.",
"无进展设置未能保存:{detail}": "The progress settings were not saved: {detail}", "无进展设置未能保存:{detail}": "The progress settings were not saved: {detail}",
"提问等待时长未能保存:{detail}": "The question wait time was not saved: {detail}", "提问等待时长未能保存:{detail}": "The question wait time was not saved: {detail}",
"等待时长超出允许范围,未做任何修改:{detail}": "That wait time is out of range; nothing was changed: {detail}", "等待时长超出允许范围,未做任何修改:{detail}": "That wait time is out of range; nothing was changed: {detail}",
"自动归档设置未能保存:{detail}": "The auto-archive settings were not saved: {detail}", "自动归档设置未能保存:{detail}": "The auto-archive settings were not saved: {detail}",
"天数超出允许范围,未做任何修改:{detail}": "The number of days is out of range; nothing was changed: {detail}", "天数超出允许范围,未做任何修改:{detail}": "The number of days is out of range; nothing was changed: {detail}",
"complete 模式必须填写前缀,未做任何修改:{detail}": "Complete persona mode needs a prefix before it can be the whole system prompt; nothing was changed: {detail}",
"人设内容超出长度上限,未做任何修改:{detail}": "The persona text is over the length limit; nothing was changed: {detail}", "人设内容超出长度上限,未做任何修改:{detail}": "The persona text is over the length limit; nothing was changed: {detail}",
"交付验收卡片的设置未能保存:{detail}": "The receipt card setting was not saved: {detail}", "交付验收卡片的设置未能保存:{detail}": "The receipt card setting was not saved: {detail}",
"主目录或磁盘根目录不能整体信任,请打开具体的项目文件夹": "A home directory or drive root cannot be trusted as a whole - open a specific project folder", "主目录或磁盘根目录不能整体信任,请打开具体的项目文件夹": "A home directory or drive root cannot be trusted as a whole - open a specific project folder",
@@ -6,6 +6,10 @@ export const EN_SETTINGS: Record<string, string> = {
"来自 {from} 的消息": "Message from {from}", "来自 {from} 的消息": "Message from {from}",
"来自另一个会话「{from}」的文字,不是你写的。批准后才会交给这个会话的模型。": "Text from another session, \u201c{from}\u201d; you did not write it. It reaches this session\u2019s model only after you approve it.", "来自另一个会话「{from}」的文字,不是你写的。批准后才会交给这个会话的模型。": "Text from another session, \u201c{from}\u201d; you did not write it. It reaches this session\u2019s model only after you approve it.",
"批准并交给模型": "Approve and give it to the model", "批准并交给模型": "Approve and give it to the model",
"已发给「{title}」({project}),等对方的用户批准": "Sent to \"{title}\" ({project}); waiting for their user to approve",
"「{title}」({project})已放行,正在处理你的消息": "\"{title}\" ({project}) accepted it and is working on your message",
"「{title}」({project})回复了你": "\"{title}\" ({project}) replied to you",
"「{title}」({project})的用户拒绝了你的消息": "The user of \"{title}\" ({project}) declined your message",
"等你决定的会话间消息": "Session messages waiting for your decision", "等你决定的会话间消息": "Session messages waiting for your decision",
"还有 {n} 条等你决定": "{n} more waiting for your decision", "还有 {n} 条等你决定": "{n} more waiting for your decision",
"无法读取会话间消息的状态。": "Could not read the state of messages between sessions.", "无法读取会话间消息的状态。": "Could not read the state of messages between sessions.",
@@ -21,6 +25,8 @@ export const EN_SETTINGS: Record<string, string> = {
"浏览器标签": "Browser tabs", "浏览器标签": "Browser tabs",
"辅助会话": "Side question", "辅助会话": "Side question",
"只读 · 不写入主会话 · 关闭即丢": "Read-only · not in the main session · gone when closed", "只读 · 不写入主会话 · 关闭即丢": "Read-only · not in the main session · gone when closed",
"收起辅助会话": "Collapse the side question",
"展开辅助会话": "Expand the side question",
"关闭辅助会话": "Close the side question", "关闭辅助会话": "Close the side question",
"移除引用 {n}": "Remove quote {n}", "移除引用 {n}": "Remove quote {n}",
"向辅助会话提问": "Ask the side question", "向辅助会话提问": "Ask the side question",
@@ -154,6 +160,8 @@ export const EN_SETTINGS: Record<string, string> = {
"已保存 · 重启后生效": "Saved · takes effect after a restart", "已保存 · 重启后生效": "Saved · takes effect after a restart",
"已保存 · 新开的会话生效": "Saved · applies to sessions opened from now on", "已保存 · 新开的会话生效": "Saved · applies to sessions opened from now on",
"新开的会话生效": "Applies to new sessions", "新开的会话生效": "Applies to new sessions",
"下个回合起生效": "Applies from the next turn",
"已保存 · 下个回合起生效": "Saved · applies from the next turn",
// ── 设置:分区与导航 ───────────────────────────────────────────── // ── 设置:分区与导航 ─────────────────────────────────────────────
"设置分类": "Settings sections", "设置分类": "Settings sections",
@@ -438,6 +446,13 @@ export const EN_SETTINGS: Record<string, string> = {
"{n} 个档位": "{n} levels", "{n} 个档位": "{n} levels",
"{n} 个模型单独设置": "{n} models with their own levels", "{n} 个模型单独设置": "{n} models with their own levels",
"按模型设置价格": "Price by model", "按模型设置价格": "Price by model",
"这个价格不在官方价目表里,选了人民币或美元显示时无法换算。": "This price is not in the vendor's price list, so it cannot be converted when a display currency is chosen.",
"缓存命中价是 0:命中的 token 会按免费计,费用会偏低。": "The cache-hit price is 0: cached tokens are counted as free, so cost reads too low.",
"{model} 恢复官方价": "{model} restore official price",
"恢复官方价": "Restore official price",
"{model}({currency})": "{model} ({currency})",
"、": ", ",
"这些模型的价格不在官方价目表里,无法换算成所选币种,费用只能按原币种显示:{models}。到「服务商」编辑该模型,点「恢复官方价」即可。": "These models' prices are not in the vendor's price list, so they cannot be converted to the chosen currency and cost shows in the original one: {models}. Edit the model under Providers and choose Restore official price.",
"每百万 token 的单价。留空表示未知,用量页会如实显示「未知」,而不是 0;填 0 表示免费。缓存命中价留空时按输入价计算。": "Price per million tokens. Left empty it is unknown, and the usage page says unknown rather than 0; enter 0 for free. An empty cache-hit price is charged at the input price.", "每百万 token 的单价。留空表示未知,用量页会如实显示「未知」,而不是 0;填 0 表示免费。缓存命中价留空时按输入价计算。": "Price per million tokens. Left empty it is unknown, and the usage page says unknown rather than 0; enter 0 for free. An empty cache-hit price is charged at the input price.",
"{model} 的输入单价": "{model} input price", "{model} 的输入单价": "{model} input price",
"{model} 的输出单价": "{model} output price", "{model} 的输出单价": "{model} output price",
+31 -1
View File
@@ -170,6 +170,13 @@ const SAID: Record<string, string> = {
"workspace.changing_disabled": "这台服务器不支持切换工作区", "workspace.changing_disabled": "这台服务器不支持切换工作区",
"workspace.worktree_source_missing": "起始分支已不存在,请重新选择分支", "workspace.worktree_source_missing": "起始分支已不存在,请重新选择分支",
"workspace.worktree_stale": "会话或工作目录已改变,请新建会话后重新选择 worktree", "workspace.worktree_stale": "会话或工作目录已改变,请新建会话后重新选择 worktree",
"workspace.worktree_request_invalid": "创建请求不完整或与原操作不一致,请返回编辑后重试",
"workspace.worktree_operation_unknown": "无法确认此前创建的结果,请先检查工作目录,避免重复创建",
"workspace.worktree_busy": "已有工作目录正在准备或等待清理,请先确认该操作的结果",
"workspace.worktree_failed": "工作目录创建失败,任务和附件已保留",
"workspace.worktree_cleanup_failed": "未完成目录尚未清理,暂不能重试创建,请查看详情",
"workspace.worktree_cancelled": "创建已取消,任务尚未发送",
"workspace.worktree_timeout": "创建等待超时,任务和附件已保留,可查看详情后重试",
"settings.unknown_preset": "不存在该预设", "settings.unknown_preset": "不存在该预设",
"settings.unknown_model": "没有找到这个模型,默认模型没有改动", "settings.unknown_model": "没有找到这个模型,默认模型没有改动",
"settings.default_model_brokered": "这个窗格的默认模型由模型所在的那台机器决定,请在本机的窗格里设置", "settings.default_model_brokered": "这个窗格的默认模型由模型所在的那台机器决定,请在本机的窗格里设置",
@@ -215,6 +222,12 @@ const SAID: Record<string, string> = {
"notifications.rejected": "通知设置没能保存:{error}", "notifications.rejected": "通知设置没能保存:{error}",
"editor.not_installed": "这台机器上没找到 VS Code、Cursor 这类编辑器。装一个,或在配置里用 [desktop] editor 指定路径。", "editor.not_installed": "这台机器上没找到 VS Code、Cursor 这类编辑器。装一个,或在配置里用 [desktop] editor 指定路径。",
"editor.launch_failed": "编辑器没能启动:{error}", "editor.launch_failed": "编辑器没能启动:{error}",
"document.launch_failed": "没能用系统默认程序打开这个文件:{error}",
"document.no_window": "这个内核没有窗口,打不开本机的文件。",
"document.not_allowed_type": "不能从这里打开这种类型的文件。",
"document.not_file": "只能打开文件,不能打开文件夹。",
"document.not_found": "这个文件不存在。",
"document.outside_roots": "这个文件不在工作区或已授权的文件夹里。",
"editor.no_window": "这个内核没有窗口,打不开本机的编辑器。", "editor.no_window": "这个内核没有窗口,打不开本机的编辑器。",
"workspace.not_listed": "这个文件夹不在当前窗口的项目列表里。", "workspace.not_listed": "这个文件夹不在当前窗口的项目列表里。",
"workspace.folder_missing": "项目文件夹已不在磁盘上。", "workspace.folder_missing": "项目文件夹已不在磁盘上。",
@@ -382,6 +395,16 @@ const SAID: Record<string, string> = {
"mcp.approval_owed": "这个服务要启动的内容在你启用后变了,请先查看命令并重新启用", "mcp.approval_owed": "这个服务要启动的内容在你启用后变了,请先查看命令并重新启用",
"mcp.nothing_held": "这个服务没有被暂挡的工具定义", "mcp.nothing_held": "这个服务没有被暂挡的工具定义",
"mcp.digest_mismatch": "你看到的被挡定义已经变了,没有接受;请重新查看", "mcp.digest_mismatch": "你看到的被挡定义已经变了,没有接受;请重新查看",
"mcp.editing_disabled": "这个客户端不能登录 MCP 服务器",
"mcp.signin_declined": "你没有同意前往该登录站点,已取消登录",
"mcp.signin_ineligible": "这个服务器不使用 OAuth 登录",
"mcp.signin_busy": "这个服务器已经在登录中",
"mcp.signin_no_host": "服务器给出的登录页没有主机名,已停止",
"mcp.signin_no_browser": "这台机器打不开浏览器",
"mcp.signin_timed_out": "登录没有在时限内完成",
"mcp.signin_cancelled": "登录已取消",
"mcp.signin_failed": "登录没有完成:{detail}",
"mcp.follow_unwritable": "没能记下这个选择:{detail}",
"activation.unavailable": "开关未能保存:其存储文件无法读取或写入", "activation.unavailable": "开关未能保存:其存储文件无法读取或写入",
// ── 待送达:条目、队列、这份存档各自会拒 ───────────────────────── // ── 待送达:条目、队列、这份存档各自会拒 ─────────────────────────
@@ -403,6 +426,7 @@ const SAID: Record<string, string> = {
"inbox.closed": "该会话的待送达已关闭", "inbox.closed": "该会话的待送达已关闭",
"inbox.schema_readonly": "该待送达由更高版本写入,当前版本只能读取", "inbox.schema_readonly": "该待送达由更高版本写入,当前版本只能读取",
"inbox.idempotency_conflict": "该提交标识已被使用,且当时的内容不同", "inbox.idempotency_conflict": "该提交标识已被使用,且当时的内容不同",
"inbox.foreign_run": "这份待送达属于另一个运行,暂不能接管",
// ── 配置文件本身坏了,以及每一个写设置的面板被它挡住时 ───────── // ── 配置文件本身坏了,以及每一个写设置的面板被它挡住时 ─────────
"config.unparsed": "配置文件 {path} 无法读取,因此本次未保存。到「设置」里可以看到出错的位置并修复", "config.unparsed": "配置文件 {path} 无法读取,因此本次未保存。到「设置」里可以看到出错的位置并修复",
@@ -438,6 +462,9 @@ const SAID: Record<string, string> = {
"peer.too_large": "这条消息太长", "peer.too_large": "这条消息太长",
"peer.not_held": "这条消息已经处理过了", "peer.not_held": "这条消息已经处理过了",
"peer.send_failed": "会话间消息没能完成:{detail}", "peer.send_failed": "会话间消息没能完成:{detail}",
"peer.invalid_project": "放行的项目必须是一个绝对路径的文件夹",
"peer.remember_denied": "只有批准才能记住,拒绝只对这一条生效",
"peer.settings_unwritable": "会话间消息的设置没能保存:{detail}",
"provider.key_origin_changed": "地址换了域名,已保存的密钥不会发过去;请填入这个地址对应的密钥", "provider.key_origin_changed": "地址换了域名,已保存的密钥不会发过去;请填入这个地址对应的密钥",
"write_lease.no_choice": "本次请求未说明选择,未做任何修改", "write_lease.no_choice": "本次请求未说明选择,未做任何修改",
"write.path_busy": "这个路径正被另一个并行的子代理或写入占用。本次写入未执行;请换一个路径,或等对方结束后再写", "write.path_busy": "这个路径正被另一个并行的子代理或写入占用。本次写入未执行;请换一个路径,或等对方结束后再写",
@@ -447,13 +474,16 @@ const SAID: Record<string, string> = {
"changed_files.save_failed": "文件改动显示设置未能保存:{detail}", "changed_files.save_failed": "文件改动显示设置未能保存:{detail}",
"fold_work.save_failed": "折叠中间过程的设置未能保存:{detail}", "fold_work.save_failed": "折叠中间过程的设置未能保存:{detail}",
"turn_narration.save_failed": "回合间叙述的设置未能保存:{detail}", "turn_narration.save_failed": "回合间叙述的设置未能保存:{detail}",
"web_access.editing_disabled": "这台服务器没有开启联网搜索与抓取设置的编辑:只有这台电脑上的窗口能改。",
"web_access.save_failed": "联网搜索与抓取的设置未能保存:{detail}",
"web_access.unknown_backend": "“{backend}”不是可选的联网后端,未做任何修改。",
"web_access.bad_searxng_url": "SearXNG 地址必须是带主机名的 http(s) 地址,不能含账号、查询串或片段,未做任何修改。",
"ask_timeout.save_failed": "提问等待时长未能保存:{detail}", "ask_timeout.save_failed": "提问等待时长未能保存:{detail}",
"ask_timeout.out_of_range": "等待时长超出允许范围,未做任何修改:{detail}", "ask_timeout.out_of_range": "等待时长超出允许范围,未做任何修改:{detail}",
"receipt.save_failed": "交付验收卡片的设置未能保存:{detail}", "receipt.save_failed": "交付验收卡片的设置未能保存:{detail}",
"progress_watch.save_failed": "无进展设置未能保存:{detail}", "progress_watch.save_failed": "无进展设置未能保存:{detail}",
"auto_archive.save_failed": "自动归档设置未能保存:{detail}", "auto_archive.save_failed": "自动归档设置未能保存:{detail}",
"auto_archive.out_of_range": "天数超出允许范围,未做任何修改:{detail}", "auto_archive.out_of_range": "天数超出允许范围,未做任何修改:{detail}",
"persona.complete_requires_prefix": "complete 模式必须填写前缀,未做任何修改:{detail}",
"persona.too_large": "人设内容超出长度上限,未做任何修改:{detail}", "persona.too_large": "人设内容超出长度上限,未做任何修改:{detail}",
"workspace.untrustable": "主目录或磁盘根目录不能整体信任,请打开具体的项目文件夹", "workspace.untrustable": "主目录或磁盘根目录不能整体信任,请打开具体的项目文件夹",
"workspace.trust_save_failed": "未能记下对此文件夹的信任决定:{detail}", "workspace.trust_save_failed": "未能记下对此文件夹的信任决定:{detail}",
@@ -18,6 +18,7 @@ export const NOTICE_TEXT: Record<string, string> = {
project_mcp_awaiting_approval: "这个项目声明的一个 MCP 服务要你批准后才会运行;详情里是它要启动的命令", project_mcp_awaiting_approval: "这个项目声明的一个 MCP 服务要你批准后才会运行;详情里是它要启动的命令",
project_mcp_changed: "一个已启用的项目 MCP 服务要启动的内容变了,本次没有运行,需要查看命令后重新启用", project_mcp_changed: "一个已启用的项目 MCP 服务要启动的内容变了,本次没有运行,需要查看命令后重新启用",
mcp_tools_held: "一个 MCP 服务有工具的定义是新增的或与你批准过的不同,已暂不提供给模型;详情里是这些工具,核对服务后再接受", mcp_tools_held: "一个 MCP 服务有工具的定义是新增的或与你批准过的不同,已暂不提供给模型;详情里是这些工具,核对服务后再接受",
mcp_tools_followed: "你选择了跟随的一个 MCP 服务改了或新增了工具定义,已按你的选择直接采用;详情里是这些工具",
hook_unevaluable: "有一个钩子无法被评估(匹配串无效、无法启动或载荷无法序列化),为安全起见已拦截这次操作;到钩子设置里修复或移除该钩子", hook_unevaluable: "有一个钩子无法被评估(匹配串无效、无法启动或载荷无法序列化),为安全起见已拦截这次操作;到钩子设置里修复或移除该钩子",
suspected_injection: "一条外部内容看起来在向智能体下指令,已提醒它只当资料看待", suspected_injection: "一条外部内容看起来在向智能体下指令,已提醒它只当资料看待",
await_user: "等待你的输入", await_user: "等待你的输入",
+1
View File
@@ -14,6 +14,7 @@ import "./styles/tips.css";
import "./styles/skillpin.css"; import "./styles/skillpin.css";
import "./styles/waitnote.css"; import "./styles/waitnote.css";
import "./styles/bring.css"; import "./styles/bring.css";
import "./styles/peerthreads.css";
import "./styles/line.css"; import "./styles/line.css";
import "./styles/notice.css"; import "./styles/notice.css";
import "./styles/peers.css"; import "./styles/peers.css";
+69 -4
View File
@@ -137,12 +137,53 @@ export interface PeerHeld {
body: string; body: string;
} }
export type PeerThreadState = "waiting" | "working" | "replied" | "declined";
export interface PeerThread {
key: string;
title: string;
project: string;
sessionPath?: string;
state: PeerThreadState;
since: string;
messageId: string;
}
export interface PeersState { export interface PeersState {
enabled: boolean; enabled: boolean;
peers: PeerView[]; peers: PeerView[];
held: PeerHeld[]; held: PeerHeld[];
threads?: PeerThread[];
} }
export interface PeerProject {
path: string;
name: string;
}
/** The session-messages switches as the kernel holds them (snake_case on the
* wire). Saved values reach the model only at the next turn boundary, so
* `pending` stays true until then. */
export interface PeersSettings {
enabled: boolean;
dispatch: boolean;
release_projects: PeerProject[];
path: string;
applies: "next_turn";
pending: boolean;
// False where the session has no folder to open (a headless run): there is
// nowhere for messages to be held, so the switches are inert.
available: boolean;
}
export interface PeersSettingsEdit {
enabled?: boolean;
dispatch?: boolean;
release_projects?: string[];
}
export type PeerRemember = "project" | "none";
export interface PeerReceipt { export interface PeerReceipt {
id: string; id: string;
to: PeerView; to: PeerView;
@@ -161,28 +202,34 @@ export interface PersonaPreset {
id: string; id: string;
prefix: string; prefix: string;
suffix: string; suffix: string;
complete: boolean;
include_runtime_context: boolean; include_runtime_context: boolean;
} }
export interface PersonaSettings { export interface PersonaSettings {
prefix: string; prefix: string;
suffix: string; suffix: string;
complete: boolean;
include_runtime_context: boolean; include_runtime_context: boolean;
path: string; path: string;
presets: PersonaPreset[]; presets: PersonaPreset[];
} }
/** The four fields a save writes; path and presets are the kernel's answer. */ /** The three fields a save writes; path and presets are the kernel's answer. */
export type PersonaDraft = Pick<PersonaSettings, "prefix" | "suffix" | "complete" | "include_runtime_context">; export type PersonaDraft = Pick<PersonaSettings, "prefix" | "suffix" | "include_runtime_context">;
export type DisplayCurrencyMode = "auto" | "CNY" | "USD"; export type DisplayCurrencyMode = "auto" | "CNY" | "USD";
// Which currency costs are shown in; the user file holds it. // Which currency costs are shown in; the user file holds it.
export interface UnconvertedPrice {
provider: string;
model: string;
currency: string;
}
export interface DisplayCurrencySettings { export interface DisplayCurrencySettings {
mode: DisplayCurrencyMode; mode: DisplayCurrencyMode;
path: string; path: string;
// Models whose price cannot be shown in a pinned currency.
unconverted: UnconvertedPrice[];
} }
// Whether the changed-files pill is drawn above the composer; the user file // Whether the changed-files pill is drawn above the composer; the user file
@@ -200,6 +247,24 @@ export interface TurnNarrationSettings {
path: string; path: string;
} }
// Where web_search and web_fetch may send things. The lists are ordered; a paid
// backend is tried only after the ones before it failed, and one named in a
// paid-allowed list is used without asking each time.
export interface WebAccessSettings {
search_backends: string[];
searxng_url: string;
search_paid_allowed: string[];
fetch_backends: string[];
fetch_paid_allowed: string[];
known_search_backends: string[];
known_fetch_backends: string[];
paid_backends: string[];
deepseek_api_ready: boolean;
path: string;
}
export type WebAccessPatch = Partial<Pick<WebAccessSettings, "search_backends" | "searxng_url" | "search_paid_allowed" | "fetch_backends" | "fetch_paid_allowed">>;
export interface ChangedFilesSettings { export interface ChangedFilesSettings {
show: boolean; show: boolean;
path: string; path: string;
+6 -1
View File
@@ -19,9 +19,14 @@ export const isUnderReview = (item: Pick<FeedbackItem, "status" | "underReview"
item.status === "received" && item.underReview === true && !item.statusUnavailable; item.status === "received" && item.underReview === true && !item.statusUnavailable;
// Only these take a reply from the reporter; the service has the last word. A // Only these take a reply from the reporter; the service has the last word. A
// reply to a fixed report says the fix did not hold and reopens it. // reply to a fixed report says the fix did not hold and reopens it. A received
// report takes one once it is released: one still under review reads as received
// too, and is not asked for anything.
export const FEEDBACK_REPLYABLE: readonly FeedbackStatus[] = ["needs_info", "answered", "recorded", "in_progress", "fixed"]; export const FEEDBACK_REPLYABLE: readonly FeedbackStatus[] = ["needs_info", "answered", "recorded", "in_progress", "fixed"];
export const canReplyTo = (item: Pick<FeedbackItem, "status" | "underReview" | "statusUnavailable">): boolean =>
!item.statusUnavailable && (FEEDBACK_REPLYABLE.includes(item.status) || (item.status === "received" && !isUnderReview(item)));
export interface FeedbackEnvInfo { export interface FeedbackEnvInfo {
version: string; version: string;
commit: string; commit: string;
+7 -2
View File
@@ -35,6 +35,8 @@ export interface RuntimeView {
export interface TreeSession { export interface TreeSession {
path: string; path: string;
workspaceRoot?: string;
isolated?: boolean;
name: string; name: string;
title?: string; title?: string;
turns?: number; turns?: number;
@@ -98,8 +100,7 @@ export interface OpenWithTarget {
export interface TreeWorkspace { export interface TreeWorkspace {
root: string; root: string;
name: string; name: string;
// A delivery worktree carries the project's own folder name, so the sidebar workspaceRoots?: string[];
// needs this to tell the copy from the original.
isolated?: boolean; isolated?: boolean;
missing?: boolean; missing?: boolean;
open?: boolean; open?: boolean;
@@ -117,6 +118,10 @@ export interface TreeWorkspace {
sessions: TreeSession[]; sessions: TreeSession[];
} }
export function workspaceContains(workspace: TreeWorkspace, root: string | undefined): boolean {
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 // How much of each folder tree() builds: `limit` for all of them (0 is every
// row), `limits` overriding it per folder root. // row), `limits` overriding it per folder root.
export type TreeScope = "" | "active" | "archived"; export type TreeScope = "" | "active" | "archived";
+3
View File
@@ -75,6 +75,9 @@ export interface Appearance {
// rather than carrying a copy. // rather than carrying a copy.
zoomRange?: ZoomRange; zoomRange?: ZoomRange;
readSize?: number; readSize?: number;
// Interface text alone, as a scale over the stylesheet's sizes; zoom moves
// spacing and controls with it, this does not.
textSize?: number;
fontUi?: string; fontUi?: string;
fontMono?: string; fontMono?: string;
// How far prose may run: "standard" holds it to a reading line length, // How far prose may run: "standard" holds it to a reading line length,
+2
View File
@@ -35,6 +35,8 @@ export interface McpEntry {
held?: McpHeld[]; held?: McpHeld[];
heldDigest?: string; heldDigest?: string;
heldMore?: number; heldMore?: number;
// The user takes this server's definition changes without being asked.
followsChanges?: boolean;
} }
// One withheld tool and how it differs from what was approved: added, changed, // One withheld tool and how it differs from what was approved: added, changed,
+9 -3
View File
@@ -4,7 +4,7 @@ import { approvalKind } from "./mock_approval";
import { HttpError } from "./port"; import { HttpError } from "./port";
import type { AccountState, AgentPort, Completion, CompletionItem, DeviceGrant, StudioLogs, VersionHub, VersionNotes, ApprovalMode, ApprovalVerdict, Checkpoint, RewindPlan, RewindResult, RewindUndo, RewindScope, HistoryMessage, HostTodo, BrowserTab, ModelEntry, Preset, ProviderSetup, RoleAssignments, RoleOverride, SessionEntry, SessionStatus, WalletReading, MemoryCatalog, MemoryEdit, UsageReport, MemoryEntry, WorkspaceInfo, Attachment, DroppedRef, Queue, QueueItem, Queued, ChipCall, NotifyPrefs, TrayPrefs, UsageQuery } from "./port"; import type { AccountState, AgentPort, Completion, CompletionItem, DeviceGrant, StudioLogs, VersionHub, VersionNotes, ApprovalMode, ApprovalVerdict, Checkpoint, RewindPlan, RewindResult, RewindUndo, RewindScope, HistoryMessage, HostTodo, BrowserTab, ModelEntry, Preset, ProviderSetup, RoleAssignments, RoleOverride, SessionEntry, SessionStatus, WalletReading, MemoryCatalog, MemoryEdit, UsageReport, MemoryEntry, WorkspaceInfo, Attachment, DroppedRef, Queue, QueueItem, Queued, ChipCall, NotifyPrefs, TrayPrefs, UsageQuery } from "./port";
import type { ExecutionGraphRead, TrajectoryRead, WireEvent } from "./wire"; import type { ExecutionGraphRead, TrajectoryRead, WireEvent } from "./wire";
import { MockWorkspace, mockWorkspaces, mockWorktreeSources } from "./mock_workspace"; import { MockWorkspace, mockWorkspaces, mockWorktreeSources, MockPreparations } from "./mock_workspace";
import { SCRIPT, mockMsgIndex, mockTurnStart } from "./fixture"; import { SCRIPT, mockMsgIndex, mockTurnStart } from "./fixture";
import { MockExecutionHold, mockExecutionGraph } from "./mock_graph"; import { MockExecutionHold, mockExecutionGraph } from "./mock_graph";
import { mockStorage, mockStoragePlan } from "./mock_storage"; import { mockStorage, mockStoragePlan } from "./mock_storage";
@@ -180,7 +180,7 @@ export class MockPort extends MockWorkspace implements AgentPort {
this.mem = this.mem.filter((m) => m.name !== name); this.mem = this.mem.filter((m) => m.name !== name);
} }
async versions(): Promise<VersionHub> { async versions(_refresh?: boolean): Promise<VersionHub> {
return { current: "dev", pinned: "", stalePin: false, latest: "", newer: false, versions: [] }; return { current: "dev", pinned: "", stalePin: false, latest: "", newer: false, versions: [] };
} }
@@ -238,6 +238,11 @@ export class MockPort extends MockWorkspace implements AgentPort {
return mockWorktreeSources(this.state.workspaceRoot ?? "", this.branchState.branch); return mockWorktreeSources(this.state.workspaceRoot ?? "", this.branchState.branch);
} }
private preparations = new MockPreparations();
prepareWorktree(id: string, selection: import("./workspace").WorktreeSelection) { return this.preparations.prepare(id, selection, async () => { await this.isolateWorkspace(selection); return (await this.status()).workspaceRoot ?? ""; }); }
worktreePreparation(id: string) { return this.preparations.read(id); }
cancelWorktreePreparation(id: string) { return this.preparations.read(id); }
async isolateWorkspace(_selection?: import("./workspace").WorktreeSelection) { async isolateWorkspace(_selection?: import("./workspace").WorktreeSelection) {
await this.setWorkspace((this.state.workspaceRoot ?? "") + " (隔离副本)"); await this.setWorkspace((this.state.workspaceRoot ?? "") + " (隔离副本)");
this.branchState.branch = "reasonix/delivery-demo"; this.branchState.branch = "reasonix/delivery-demo";
@@ -246,6 +251,7 @@ export class MockPort extends MockWorkspace implements AgentPort {
async openInEditor() { async openInEditor() {
return { editor: "Visual Studio Code", root: "/work/demo" }; return { editor: "Visual Studio Code", root: "/work/demo" };
} }
async openDocument() {}
revealsFiles() { return true; } revealsFiles() { return true; }
async revealInFileManager() {} async revealInFileManager() {}
@@ -381,7 +387,7 @@ export class MockPort extends MockWorkspace implements AgentPort {
if (path === ".reasonix") return { files: [".reasonix/REASONIX.local.md"], directories: [] }; if (path === ".reasonix") return { files: [".reasonix/REASONIX.local.md"], directories: [] };
return hidden ? { files: [".gitignore", "README.md"], directories: [".reasonix", "internal"] } : { files: ["README.md"], directories: ["internal"] }; return hidden ? { files: [".gitignore", "README.md"], directories: [".reasonix", "internal"] } : { files: ["README.md"], directories: ["internal"] };
} }
async workspaceFile(path: string) { return { path, content: `// ${path}\n`, revision: "fixture" }; } async workspaceFile(path: string, _root?: string): Promise<import("./port").WorkspaceFile> { return { path, content: `// ${path}\n`, revision: "fixture" }; }
workspaceImageURL(path: string) { return `/fixture/${path}`; } workspaceImageURL(path: string) { return `/fixture/${path}`; }
async saveWorkspaceFile(file: import("./port").WorkspaceFile) { return { ...file, revision: file.revision + "-saved" }; } async saveWorkspaceFile(file: import("./port").WorkspaceFile) { return { ...file, revision: file.revision + "-saved" }; }
+41 -14
View File
@@ -1,5 +1,5 @@
import type { Adjudications, BrowserToolsSettings, SubagentClaimsSettings, WriteLeaseSettings, PlanApprovalSettings, PeerReceipt, PeersState, ConfigProblem, ConfigRepair, PermissionLists, PermissionRules, SandboxSettings, UpdateMode } from "./port"; import type { Adjudications, BrowserToolsSettings, SubagentClaimsSettings, WriteLeaseSettings, PlanApprovalSettings, PeerReceipt, PeersState, PeersSettings, PeersSettingsEdit, PeerRemember, ConfigProblem, ConfigRepair, PermissionLists, PermissionRules, SandboxSettings, UpdateMode } from "./port";
import type { AskTimeoutSettings, AutoArchiveSettings, ChangedFilesSettings, FoldWorkSettings, TurnNarrationSettings, DisplayCurrencyMode, DisplayCurrencySettings, ProgressWatchSettings, WorkspaceRoots, ReceiptSettings, PersonaDraft, PersonaSettings } from "./boundary"; import type { AskTimeoutSettings, AutoArchiveSettings, ChangedFilesSettings, FoldWorkSettings, TurnNarrationSettings, WebAccessPatch, WebAccessSettings, DisplayCurrencyMode, DisplayCurrencySettings, ProgressWatchSettings, WorkspaceRoots, ReceiptSettings, PersonaDraft, PersonaSettings } from "./boundary";
import { HttpError } from "./http_error"; import { HttpError } from "./http_error";
import { MockShell } from "./mock_shell"; import { MockShell } from "./mock_shell";
@@ -173,7 +173,7 @@ export class MockBoundary extends MockShell {
protected peerState: PeersState = { enabled: false, peers: [], held: [] }; protected peerState: PeersState = { enabled: false, peers: [], held: [] };
async peers(): Promise<PeersState> { async peers(): Promise<PeersState> {
return { ...this.peerState, peers: [...this.peerState.peers], held: [...this.peerState.held] }; return { ...this.peerState, peers: [...this.peerState.peers], held: [...this.peerState.held], threads: [...(this.peerState.threads ?? [])] };
} }
async sendPeerMessage(to: string, text: string): Promise<PeerReceipt> { async sendPeerMessage(to: string, text: string): Promise<PeerReceipt> {
@@ -182,10 +182,28 @@ export class MockBoundary extends MockShell {
return { id: `m-${text.length}`, to: target }; return { id: `m-${text.length}`, to: target };
} }
async resolvePeerMessage(id: string, _approve: boolean): Promise<void> { async resolvePeerMessage(id: string, _approve: boolean, _remember: PeerRemember = "none"): Promise<void> {
this.peerState = { ...this.peerState, held: this.peerState.held.filter((h) => h.id !== id) }; this.peerState = { ...this.peerState, held: this.peerState.held.filter((h) => h.id !== id) };
} }
protected peerSettings: PeersSettings = { enabled: false, dispatch: false, release_projects: [], path: "/Users/you/.reasonix/config.toml", applies: "next_turn", pending: false, available: true };
async peersSettings(): Promise<PeersSettings> {
return { ...this.peerSettings, release_projects: [...this.peerSettings.release_projects] };
}
async savePeersSettings(edit: PeersSettingsEdit): Promise<PeersSettings> {
const keep = edit.release_projects;
this.peerSettings = {
...this.peerSettings,
enabled: edit.enabled ?? this.peerSettings.enabled,
dispatch: edit.dispatch ?? this.peerSettings.dispatch,
release_projects: keep ? this.peerSettings.release_projects.filter((p) => keep.includes(p.path)) : this.peerSettings.release_projects,
pending: true,
};
return this.peersSettings();
}
private archive: AutoArchiveSettings = { enabled: false, days: 30, defaultDays: 30, path: "/Users/you/.reasonix/config.toml" }; private archive: AutoArchiveSettings = { enabled: false, days: 30, defaultDays: 30, path: "/Users/you/.reasonix/config.toml" };
async autoArchive(): Promise<AutoArchiveSettings> { async autoArchive(): Promise<AutoArchiveSettings> {
@@ -197,23 +215,20 @@ export class MockBoundary extends MockShell {
this.archive = { ...this.archive, ...s }; this.archive = { ...this.archive, ...s };
return { ...this.archive }; return { ...this.archive };
} }
// Two starting points are enough for the page's layout: one that is a whole
// prefix (complete) and one that only trails the built-in guidance.
private personaState: PersonaSettings = { private personaState: PersonaSettings = {
prefix: "", suffix: "", complete: false, include_runtime_context: true, prefix: "", suffix: "", include_runtime_context: true,
path: "/Users/you/.reasonix/config.toml", path: "/Users/you/.reasonix/config.toml",
presets: [ presets: [
{ {
id: "engineer", id: "engineer",
prefix: "You are a rigorous staff engineer. Verify before claiming, make the smallest correct change, and state the residual risk.", prefix: "You are a rigorous staff engineer. Verify before claiming, make the smallest correct change, and state the residual risk.",
suffix: "", complete: true, include_runtime_context: true, suffix: "", include_runtime_context: true,
}, },
{ {
id: "concise", id: "concise",
prefix: "", prefix: "",
suffix: "Be terse. Skip the preamble; prefer code and bullets.", suffix: "Be terse. Skip the preamble; prefer code and bullets.",
complete: false, include_runtime_context: false, include_runtime_context: false,
}, },
], ],
}; };
@@ -223,14 +238,11 @@ export class MockBoundary extends MockShell {
} }
async savePersonaSettings(s: PersonaDraft): Promise<PersonaSettings> { async savePersonaSettings(s: PersonaDraft): Promise<PersonaSettings> {
// The same rule the kernel enforces, so the fixture cannot show a screen
// accepting something the real save refuses.
if (s.complete && !s.prefix.trim()) throw new Error("complete requires a prefix");
this.personaState = { ...this.personaState, ...s }; this.personaState = { ...this.personaState, ...s };
return this.persona(); return this.persona();
} }
private currency: DisplayCurrencySettings = { mode: "auto", path: "/Users/you/.reasonix/config.toml" }; private currency: DisplayCurrencySettings = { mode: "auto", path: "/Users/you/.reasonix/config.toml", unconverted: [] };
async displayCurrency(): Promise<DisplayCurrencySettings> { async displayCurrency(): Promise<DisplayCurrencySettings> {
return { ...this.currency }; return { ...this.currency };
@@ -269,6 +281,21 @@ export class MockBoundary extends MockShell {
return { ...this.narration }; return { ...this.narration };
} }
private webAccess: WebAccessSettings = {
search_backends: [], searxng_url: "", search_paid_allowed: [], fetch_backends: [], fetch_paid_allowed: [],
known_search_backends: ["searxng", "deepseek_api"], known_fetch_backends: ["local", "deepseek_api"],
paid_backends: ["deepseek_api"], deepseek_api_ready: false, path: "/Users/you/.reasonix/config.toml",
};
async webAccessSettings(): Promise<WebAccessSettings> {
return { ...this.webAccess };
}
async saveWebAccessSettings(patch: WebAccessPatch): Promise<WebAccessSettings> {
this.webAccess = { ...this.webAccess, ...patch };
return { ...this.webAccess };
}
async saveFoldWorkSettings(fold: boolean): Promise<FoldWorkSettings> { async saveFoldWorkSettings(fold: boolean): Promise<FoldWorkSettings> {
this.foldWork = { ...this.foldWork, fold }; this.foldWork = { ...this.foldWork, fold };
return { ...this.foldWork }; return { ...this.foldWork };
+18 -1
View File
@@ -1,4 +1,5 @@
import { MockProvider } from "./mock_provider"; import { MockProvider } from "./mock_provider";
import { HttpError } from "./http_error";
import type { CapabilityScope, McpCatalog, McpDraft, McpDraftServer, McpEntry, McpInstallResult, McpLoad, McpRisk, ScopeLayer } from "./port"; import type { CapabilityScope, McpCatalog, McpDraft, McpDraftServer, McpEntry, McpInstallResult, McpLoad, McpRisk, ScopeLayer } from "./port";
// Fixtures for the add-a-server flow: what a draft looks like once accepted, // Fixtures for the add-a-server flow: what a draft looks like once accepted,
@@ -61,7 +62,7 @@ export class MockMcp extends MockProvider {
{ {
// 真实的连不上就是这个长度:端点把整句话都塞进 error,而它有多长不由我们 // 真实的连不上就是这个长度:端点把整句话都塞进 error,而它有多长不由我们
// 定 —— 右栏那一行必须扛得住,否则名字先被挤没。 // 定 —— 右栏那一行必须扛得住,否则名字先被挤没。
name: "figma", state: "failed", enabled: true, transport: "http", tools: 1, name: "figma", state: "failed", enabled: true, transport: "http", tools: 1, httpStatus: 401,
error: "401 unauthorized: figma-mcp needs a personal access token with file_read scope; set FIGMA_TOKEN and reconnect", error: "401 unauthorized: figma-mcp needs a personal access token with file_read scope; set FIGMA_TOKEN and reconnect",
description: "Read Figma files, frames and comments.", remembered: true, stale: true, description: "Read Figma files, frames and comments.", remembered: true, stale: true,
toolList: [{ name: "get_file", description: "Read one Figma file's node tree." }], toolList: [{ name: "get_file", description: "Read one Figma file's node tree." }],
@@ -117,6 +118,22 @@ export class MockMcp extends MockProvider {
return { state: "ready", tools: s.tools }; return { state: "ready", tools: s.tools };
} }
async signinMcp(name: string) {
const s = this.servers.find((x) => x.name === name);
if (!s) throw new HttpError(404, "no configured MCP server named " + name, { code: "mcp.signin_ineligible" });
s.state = "ready";
s.error = undefined;
s.httpStatus = undefined;
return { name, state: "ready", host: "this machine", tools: s.tools };
}
async followMcp(name: string, follow: boolean) {
const s = this.servers.find((x) => x.name === name);
if (!s) throw new HttpError(404, "no configured MCP server named " + name, { code: "mcp.approval_owed" });
s.followsChanges = follow;
return { name, follow, tools: s.tools };
}
async reconnectMcp(name: string) { async reconnectMcp(name: string) {
const s = this.servers.find((x) => x.name === name); const s = this.servers.find((x) => x.name === name);
if (!s) return { state: "failed", error: "no configured MCP server named " + name }; if (!s) return { state: "failed", error: "no configured MCP server named " + name };
@@ -1,3 +1,4 @@
import { reason } from "../i18n/kernel";
import { HttpError } from "./http_error"; import { HttpError } from "./http_error";
import type { KeptText } from "./attachment"; import type { KeptText } from "./attachment";
import type { ChangeDiff, GitHistory, StageRequest, WorkspaceChange, WorkspaceChanges, WorkspaceGit, WorkspaceBranches } from "./workspace"; import type { ChangeDiff, GitHistory, StageRequest, WorkspaceChange, WorkspaceChanges, WorkspaceGit, WorkspaceBranches } from "./workspace";
@@ -118,3 +119,28 @@ export class MockWorkspace extends MockFeedback {
} }
} }
export class MockPreparations {
private items = new Map<string, import("./workspace").WorktreePreparation>();
async prepare(id: string, selection: import("./workspace").WorktreeSelection, create: () => Promise<string>) {
const found = this.items.get(id);
if (found) return found;
const value: import("./workspace").WorktreePreparation = { id, phase: "validating", sourceRoot: selection.sourceRoot, ref: selection.ref, startedAt: Date.now() };
try {
value.workspaceRoot = await create();
value.phase = "ready";
value.branch = "reasonix/demo";
} catch (error) {
value.phase = "failed";
value.code = "workspace.worktree_failed";
value.detail = reason(error);
}
this.items.set(id, value);
return value;
}
async read(id: string) {
const value = this.items.get(id);
if (!value) throw new Error("unknown worktree operation");
return value;
}
}
+20 -4
View File
@@ -46,7 +46,7 @@ import type { ExecutionGraphRead, TrajectoryRead, WireEvent } from "./wire";
import type { PluginExport, PluginInstallRequest, PluginPackage, PluginPlan } from "./plugin"; import type { PluginExport, PluginInstallRequest, PluginPackage, PluginPlan } from "./plugin";
import type { MarketDetail, MarketList, MarketOwnRequest, MarketPackage, MarketPlan, MarketPublished, MarketQuery, MarketRequest, MarketSubmission, MarketVote } from "./market"; import type { MarketDetail, MarketList, MarketOwnRequest, MarketPackage, MarketPlan, MarketPublished, MarketQuery, MarketRequest, MarketSubmission, MarketVote } from "./market";
import type { Appearance, ThemeImport, ThemePack } from "./look"; import type { Appearance, ThemeImport, ThemePack } from "./look";
import type { AskTimeoutSettings, AutoArchiveSettings, ChangedFilesSettings, FoldWorkSettings, TurnNarrationSettings, BrowserToolsSettings, SubagentClaimsSettings, WriteLeaseSettings, PlanApprovalSettings, PeerReceipt, PeersState, ConfigProblem, ConfigRepair, DisplayCurrencyMode, DisplayCurrencySettings, PermissionLists, PermissionRules, ProgressWatchSettings, SandboxSettings, WorkspaceRoots, ReceiptSettings, PersonaDraft, PersonaSettings } from "./boundary"; import type { AskTimeoutSettings, AutoArchiveSettings, ChangedFilesSettings, FoldWorkSettings, TurnNarrationSettings, WebAccessPatch, WebAccessSettings, BrowserToolsSettings, SubagentClaimsSettings, WriteLeaseSettings, PlanApprovalSettings, PeerReceipt, PeersState, PeersSettings, PeersSettingsEdit, PeerRemember, ConfigProblem, ConfigRepair, DisplayCurrencyMode, DisplayCurrencySettings, PermissionLists, PermissionRules, ProgressWatchSettings, SandboxSettings, WorkspaceRoots, ReceiptSettings, PersonaDraft, PersonaSettings } from "./boundary";
import type { Protocol, ProviderCheck, ProviderCheckDraft, ProviderDraft, ProviderEdit, ProviderEntry, ProviderModelCheck, ProviderModelCheckRequest, ProviderProbe, ProviderSetup, ProviderRemoval, ProviderPreset, ProviderPresetInstall } from "./provider"; import type { Protocol, ProviderCheck, ProviderCheckDraft, ProviderDraft, ProviderEdit, ProviderEntry, ProviderModelCheck, ProviderModelCheckRequest, ProviderProbe, ProviderSetup, ProviderRemoval, ProviderPreset, ProviderPresetInstall } from "./provider";
export type { ModelEffort, ModelLimit, ModelRate, Protocol, ProviderCheck, ProviderCheckDraft, ProviderDraft, ProviderEdit, ProviderEntry, ProviderModelCheck, ProviderModelCheckRequest, ProviderProbe, ProviderSetup, ProviderRemoval, ProviderPreset, ProviderPresetInstall } from "./provider"; export type { ModelEffort, ModelLimit, ModelRate, Protocol, ProviderCheck, ProviderCheckDraft, ProviderDraft, ProviderEdit, ProviderEntry, ProviderModelCheck, ProviderModelCheckRequest, ProviderProbe, ProviderSetup, ProviderRemoval, ProviderPreset, ProviderPresetInstall } from "./provider";
import type { StoragePlan, StorageQuery, StorageState } from "./storage"; import type { StoragePlan, StorageQuery, StorageState } from "./storage";
@@ -131,6 +131,9 @@ export interface QueueItem {
// Files this entry froze at the moment it was queued. Their presence is what // Files this entry froze at the moment it was queued. Their presence is what
// makes re-freezing meaningful; the entry quotes them as they were. // makes re-freezing meaningful; the entry quotes them as they were.
refs?: { path?: string }[]; refs?: { path?: string }[];
// A picture was frozen with it. Such a line cannot be slipped into a running
// turn as guidance; it is sent as a turn of its own when the current one ends.
images?: boolean;
} }
/** Both limits bite: the queue refuses on count and on total bytes, and a /** Both limits bite: the queue refuses on count and on total bytes, and a
@@ -285,7 +288,9 @@ export interface AgentPort {
saveWriteLease(optimistic: boolean): Promise<WriteLeaseSettings>; saveWriteLease(optimistic: boolean): Promise<WriteLeaseSettings>;
peers(): Promise<PeersState>; peers(): Promise<PeersState>;
sendPeerMessage(to: string, text: string): Promise<PeerReceipt>; sendPeerMessage(to: string, text: string): Promise<PeerReceipt>;
resolvePeerMessage(id: string, approve: boolean): Promise<void>; resolvePeerMessage(id: string, approve: boolean, remember?: PeerRemember): Promise<void>;
peersSettings(): Promise<PeersSettings>;
savePeersSettings(edit: PeersSettingsEdit): Promise<PeersSettings>;
displayCurrency(): Promise<DisplayCurrencySettings>; displayCurrency(): Promise<DisplayCurrencySettings>;
// Applies live: the kernel announces it and every ledger rebinds; no rebuild. // Applies live: the kernel announces it and every ledger rebinds; no rebuild.
saveDisplayCurrency(mode: DisplayCurrencyMode): Promise<DisplayCurrencySettings>; saveDisplayCurrency(mode: DisplayCurrencyMode): Promise<DisplayCurrencySettings>;
@@ -295,6 +300,8 @@ export interface AgentPort {
saveFoldWorkSettings(fold: boolean): Promise<FoldWorkSettings>; saveFoldWorkSettings(fold: boolean): Promise<FoldWorkSettings>;
turnNarrationSettings(): Promise<TurnNarrationSettings>; turnNarrationSettings(): Promise<TurnNarrationSettings>;
saveTurnNarrationSettings(level: TurnNarrationSettings["level"]): Promise<TurnNarrationSettings>; saveTurnNarrationSettings(level: TurnNarrationSettings["level"]): Promise<TurnNarrationSettings>;
webAccessSettings(): Promise<WebAccessSettings>;
saveWebAccessSettings(patch: WebAccessPatch): Promise<WebAccessSettings>;
progressWatch(): Promise<ProgressWatchSettings>; progressWatch(): Promise<ProgressWatchSettings>;
// Applies to the running turn at its next round; no rebuild. // Applies to the running turn at its next round; no rebuild.
saveProgressWatch(s: Pick<ProgressWatchSettings, "pause" | "rounds" | "tokenMultiple">): Promise<ProgressWatchSettings>; saveProgressWatch(s: Pick<ProgressWatchSettings, "pause" | "rounds" | "tokenMultiple">): Promise<ProgressWatchSettings>;
@@ -326,6 +333,9 @@ export interface AgentPort {
// Approves the withheld definitions the digest names, then reconnects. The // Approves the withheld definitions the digest names, then reconnects. The
// kernel refuses a digest that no longer matches what it withholds. // kernel refuses a digest that no longer matches what it withholds.
trustMcp(name: string, digest: string): Promise<{ state: string; tools?: number; error?: string }>; trustMcp(name: string, digest: string): Promise<{ state: string; tools?: number; error?: string }>;
// Blocks until the browser sign-in ends (up to five minutes); a refusal is a thrown coded error.
signinMcp(name: string): Promise<{ name: string; state: string; host?: string; tools?: number }>;
followMcp(name: string, follow: boolean): Promise<{ name: string; follow: boolean; tools?: number }>;
setMcpEnabled(name: string, enabled: boolean, scope?: ScopeLayer, root?: string): Promise<void>; setMcpEnabled(name: string, enabled: boolean, scope?: ScopeLayer, root?: string): Promise<void>;
clearMcpOverride(name: string, root?: string): Promise<void>; clearMcpOverride(name: string, root?: string): Promise<void>;
setMcpLoad(name: string, load: McpLoad): Promise<void>; setMcpLoad(name: string, load: McpLoad): Promise<void>;
@@ -384,7 +394,7 @@ export interface AgentPort {
welcomeSeen(): Promise<boolean>; welcomeSeen(): Promise<boolean>;
markWelcomed(): Promise<void>; markWelcomed(): Promise<void>;
removeProvider(name: string): Promise<ProviderRemoval>; removeProvider(name: string): Promise<ProviderRemoval>;
versions(): Promise<VersionHub>; versions(refresh?: boolean): Promise<VersionHub>;
pinVersion(version: string): Promise<void>; pinVersion(version: string): Promise<void>;
updateMode(): Promise<UpdateMode>; updateMode(): Promise<UpdateMode>;
setUpdateMode(mode: UpdateMode): Promise<UpdateMode>; setUpdateMode(mode: UpdateMode): Promise<UpdateMode>;
@@ -455,6 +465,9 @@ export interface AgentPort {
setWorkspace(path: string): Promise<void>; setWorkspace(path: string): Promise<void>;
isolateWorkspace(selection?: import("./workspace").WorktreeSelection): Promise<void>; isolateWorkspace(selection?: import("./workspace").WorktreeSelection): Promise<void>;
worktreeSources(): Promise<import("./workspace").WorktreeSources>; worktreeSources(): Promise<import("./workspace").WorktreeSources>;
prepareWorktree(id: string, selection: import("./workspace").WorktreeSelection): Promise<import("./workspace").WorktreePreparation>;
worktreePreparation(id: string): Promise<import("./workspace").WorktreePreparation>;
cancelWorktreePreparation(id: string): Promise<import("./workspace").WorktreePreparation>;
// The native folder picker, or "" where there is none (a browser tab) or // The native folder picker, or "" where there is none (a browser tab) or
// when the user cancelled. Only the shell can open one. // when the user cancelled. Only the shell can open one.
// The native folder picker: the chosen path, "" when the user cancelled, and // The native folder picker: the chosen path, "" when the user cancelled, and
@@ -469,6 +482,9 @@ export interface AgentPort {
* one it found, so a window with several installed can say. It refuses with * one it found, so a window with several installed can say. It refuses with
* a code rather than doing nothing when there is none. */ * a code rather than doing nothing when there is none. */
openInEditor(): Promise<{ editor: string; root: string }>; openInEditor(): Promise<{ editor: string; root: string }>;
/** Open one workspace file (html, svg, pdf) with the machine's default
* program. Only a click may call it; the kernel refuses other types. */
openDocument(path: string): Promise<void>;
/** Whether this window can show a workspace entry in the system file manager: /** Whether this window can show a workspace entry in the system file manager:
* only a shell on the kernel's own machine can. */ * only a shell on the kernel's own machine can. */
revealsFiles(): boolean; revealsFiles(): boolean;
@@ -549,7 +565,7 @@ export interface AgentPort {
stageChanges(req: StageRequest): Promise<void>; stageChanges(req: StageRequest): Promise<void>;
// Dot entries are left out unless hidden is set; VCS stores never appear. // Dot entries are left out unless hidden is set; VCS stores never appear.
workspaceFiles(path?: string, query?: string, hidden?: boolean): Promise<WorkspaceFiles>; workspaceFiles(path?: string, query?: string, hidden?: boolean): Promise<WorkspaceFiles>;
workspaceFile(path: string): Promise<WorkspaceFile>; workspaceFile(path: string, root?: string): Promise<WorkspaceFile>;
saveWorkspaceFile(file: WorkspaceFile): Promise<WorkspaceFile>; saveWorkspaceFile(file: WorkspaceFile): Promise<WorkspaceFile>;
// Where an image in the workspace is served, for a rendered document to show. // Where an image in the workspace is served, for a rendered document to show.
/** root names a folder the user granted this workspace; absent means the workspace itself. */ /** root names a folder the user granted this workspace; absent means the workspace itself. */
@@ -63,6 +63,8 @@ export interface ProviderEntry {
// Per model: the price per million tokens it declares for itself. A model // Per model: the price per million tokens it declares for itself. A model
// with none is absent, which means unknown, not zero. // with none is absent, which means unknown, not zero.
modelPrices?: Record<string, ModelRate>; modelPrices?: Record<string, ModelRate>;
// A vendor's own endpoint: its table can say whether a stored price is its own.
officialPricing?: boolean;
// How the latest plain test of this saved entry ended, in this kernel process. // How the latest plain test of this saved entry ended, in this kernel process.
// Absent when none ran or the entry changed since. // Absent when none ran or the entry changed since.
lastCheck?: { at: number; ok: boolean; code?: string }; lastCheck?: { at: number; ok: boolean; code?: string };
@@ -276,6 +278,8 @@ export interface ModelRate {
output: number; output: number;
cacheHit: number; cacheHit: number;
currency: string; currency: string;
// True when the card is one the vendor's table has; sent by the kernel, never back.
official?: boolean;
} }
// One model's own context window and output cap; an absent field inherits. // One model's own context window and output cap; an absent field inherits.
+13
View File
@@ -250,6 +250,8 @@ export interface SessionStatus {
bypass: boolean; bypass: boolean;
goal: string; goal: string;
goalStatus: string; goalStatus: string;
// Present while there is a goal; absent from a kernel that predates it.
goalRuntime?: GoalRuntime;
cwd: string; cwd: string;
workspaceRoot?: string; workspaceRoot?: string;
sessionPath?: string; sessionPath?: string;
@@ -262,6 +264,17 @@ export interface SessionStatus {
jobs?: JobEntry[]; jobs?: JobEntry[];
} }
/** What a goal has used so far. work time counts active work, not pauses. */
export interface GoalRuntime {
turnsUsed: number;
tokensUsed: number;
requestsUsed: number;
workDurationMs: number;
noProgressTurns: number;
lastReason?: string;
stopCause?: string;
}
/** One currency of a wallet, rendered where the symbol rules live. Two /** One currency of a wallet, rendered where the symbol rules live. Two
* currencies are two lines and never a sum — combining them would mean * currencies are two lines and never a sum — combining them would mean
* inventing an exchange rate — so the only thing done here is stack them. */ * inventing an exchange rate — so the only thing done here is stack them. */
+23 -93
View File
@@ -1,6 +1,6 @@
import type { SurfaceActionRequest, SurfaceActionRecord } from "./surface"; import type { SurfaceActionRequest, SurfaceActionRecord } from "./surface";
import { PLAN_ACTIONS, type PlanAction } from "./session"; import { PLAN_ACTIONS, type PlanAction } from "./session";
import type { AccountState, AgentPort, ChipCall, CompactionSettings, Completion, DeviceGrant, ProviderProbe, UpdateProgress, StudioLogs, VersionHub, VersionNotes, ApprovalMode, ApprovalVerdict, Checkpoint, RewindPlan, RewindResult, RewindUndo, RewindScope, HistoryMessage, HostTodo, BrowserTab, ModelEntry, Preset, ProviderSetup, RoleAssignments, RoleOverride, SessionEntry, SessionStatus, WalletReading, HookDryRun, HookEntry, MemoryCatalog, MemoryEdit, MemoryEntry, UsageQuery, UsageReport, McpDraft, PluginExport, Queue, Queued, NotifyPrefs, TrayPrefs, WorkspaceInfo } from "./port"; import type { AccountState, AgentPort, ChipCall, CompactionSettings, Completion, DeviceGrant, UpdateProgress, StudioLogs, VersionHub, VersionNotes, ApprovalMode, ApprovalVerdict, Checkpoint, RewindPlan, RewindResult, RewindUndo, RewindScope, HistoryMessage, HostTodo, BrowserTab, ModelEntry, Preset, ProviderSetup, RoleAssignments, RoleOverride, SessionEntry, SessionStatus, WalletReading, MemoryCatalog, MemoryEdit, MemoryEntry, UsageQuery, UsageReport, Queue, Queued, NotifyPrefs, TrayPrefs, WorkspaceInfo } from "./port";
import { HttpError, type CommitProposal, type CommitRequest, type CommitResult, type WorkspaceFile, type WorkspaceFiles } from "./port"; import { HttpError, type CommitProposal, type CommitRequest, type CommitResult, type WorkspaceFile, type WorkspaceFiles } from "./port";
import { SseWorkspace } from "./sse_workspace"; import { SseWorkspace } from "./sse_workspace";
import type { StoragePlan, StorageQuery, StorageState } from "./storage"; import type { StoragePlan, StorageQuery, StorageState } from "./storage";
@@ -66,38 +66,6 @@ export class SsePort extends SseWorkspace implements AgentPort {
return ((await res.json()) as { text: string }).text; return ((await res.json()) as { text: string }).text;
} }
async exportPlugin(name: string): Promise<PluginExport> {
const res = await fetch(this.base + "/plugins/" + encodeURIComponent(name) + "/export", {
credentials: "same-origin",
});
if (!res.ok) await SsePort.fail(`/plugins/${encodeURIComponent(name)}/export`, res);
const required = (res.headers.get("X-Reasonix-Required-Env") ?? "").split(",").filter(Boolean);
const blob = await res.blob();
// A shell with a save dialog puts the archive where it is asked to; the
// anchor below is the browser's own way and the only one a tab has.
const saved = await host().saveBytes(`${name}.zip`, new Uint8Array(await blob.arrayBuffer()));
if (saved !== null) return { required, savedTo: saved || undefined };
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `${name}.zip`;
a.click();
URL.revokeObjectURL(url);
return { required };
}
async dryRunHook(h: HookEntry): Promise<HookDryRun> {
const res = await fetch(this.base + "/hooks/dry-run", {
method: "POST",
headers: { "content-type": "application/json" },
credentials: "same-origin",
body: JSON.stringify({ event: h.event, match: h.match, command: h.command, timeout: h.timeout, cwd: h.cwd }),
});
const body = (await res.json().catch(() => ({}))) as HookDryRun & { error?: string };
if (!res.ok) throw new Error(body.error || `/hooks/dry-run: ${res.status}`);
return body;
}
usage(days: number, source?: string): Promise<UsageReport>; usage(days: number, source?: string): Promise<UsageReport>;
usage(query: UsageQuery): Promise<UsageReport>; usage(query: UsageQuery): Promise<UsageReport>;
usage(query: number | UsageQuery, source?: string) { usage(query: number | UsageQuery, source?: string) {
@@ -139,53 +107,6 @@ export class SsePort extends SseWorkspace implements AgentPort {
return this.post("/memory/restore", { name, revision }); return this.post("/memory/restore", { name, revision });
} }
async trustMcp(name: string, digest: string) {
const res = await fetch(this.base + "/mcp/trust", {
method: "POST",
headers: { "content-type": "application/json" },
credentials: "same-origin",
body: JSON.stringify({ name, digest }),
});
const body = (await res.json().catch(() => ({}))) as { state?: string; tools?: number | unknown[]; error?: string };
if (!res.ok && !body.error) throw new Error(`/mcp/trust: ${res.status}`);
return { state: body.state ?? (res.ok ? "ready" : "failed"), tools: typeof body.tools === "number" ? body.tools : body.tools?.length, error: body.error };
}
async reconnectMcp(name: string) {
const res = await fetch(this.base + "/mcp/reconnect", {
method: "POST",
headers: { "content-type": "application/json" },
credentials: "same-origin",
body: JSON.stringify({ name }),
});
const body = (await res.json().catch(() => ({}))) as { state?: string; tools?: number; error?: string };
if (!res.ok && !body.error) throw new Error(`/mcp/reconnect: ${res.status}`);
return { state: body.state ?? (res.ok ? "ready" : "failed"), tools: body.tools, error: body.error };
}
async parseMcp(input: string): Promise<McpDraft> {
const res = await fetch(this.base + "/mcp/parse", {
method: "POST",
headers: { "content-type": "application/json" },
credentials: "same-origin",
body: JSON.stringify({ input }),
});
const body = (await res.json().catch(() => ({}))) as McpDraft & { error?: string };
if (!res.ok) throw new Error(body.error || `/mcp/parse: ${res.status}`);
return { servers: body.servers ?? [], risks: body.risks ?? [] };
}
async probeProvider(baseUrl: string, apiKey: string): Promise<ProviderProbe> {
const res = await fetch(this.base + "/providers/probe", {
method: "POST",
headers: { "content-type": "application/json" },
credentials: "same-origin",
body: JSON.stringify({ baseUrl, apiKey }),
});
const text = await res.text();
if (!res.ok) throw new Error(text.trim() || `/providers/probe: ${res.status}`);
return JSON.parse(text) as ProviderProbe;
}
async welcomeSeen(): Promise<boolean> { async welcomeSeen(): Promise<boolean> {
const res = await this.get<{ seen: boolean }>("/welcome"); const res = await this.get<{ seen: boolean }>("/welcome");
return !!res.seen; return !!res.seen;
@@ -229,8 +150,8 @@ export class SsePort extends SseWorkspace implements AgentPort {
// Which build runs belongs to the application, not to a pane: a remote pane's // Which build runs belongs to the application, not to a pane: a remote pane's
// base names another machine, whose version is not the one this window would // base names another machine, whose version is not the one this window would
// install. So these two ignore this port's base, the way the tray does. // install. So these two ignore this port's base, the way the tray does.
async versions(): Promise<VersionHub> { async versions(refresh = false): Promise<VersionHub> {
const res = await fetch("/studio/versions", { credentials: "same-origin" }); const res = await fetch(refresh ? "/studio/versions?refresh=1" : "/studio/versions", { credentials: "same-origin" });
if (!res.ok) await SsePort.fail("/studio/versions", res); if (!res.ok) await SsePort.fail("/studio/versions", res);
return (await res.json()) as VersionHub; return (await res.json()) as VersionHub;
} }
@@ -376,15 +297,8 @@ export class SsePort extends SseWorkspace implements AgentPort {
return (await res.json()) as TrayPrefs; return (await res.json()) as TrayPrefs;
} }
async accountPoll(deviceCode: string) { accountPoll(deviceCode: string) {
const res = await fetch(this.base + "/account/poll", { return this.post0<{ status: "pending" | "complete"; slowDown?: boolean }>("/account/poll", { deviceCode });
method: "POST",
headers: { "content-type": "application/json" },
credentials: "same-origin",
body: JSON.stringify({ deviceCode }),
});
if (!res.ok) throw new Error(await res.text());
return (await res.json()) as { status: "pending" | "complete"; slowDown?: boolean };
} }
accountLogout() { accountLogout() {
@@ -407,10 +321,26 @@ export class SsePort extends SseWorkspace implements AgentPort {
return this.get<import("./workspace").WorktreeSources>("/workspace/worktree-sources"); return this.get<import("./workspace").WorktreeSources>("/workspace/worktree-sources");
} }
prepareWorktree(id: string, selection: import("./workspace").WorktreeSelection) {
return this.post0<import("./workspace").WorktreePreparation>("/workspace/prepare", { id, selection });
}
worktreePreparation(id: string) {
return this.get<import("./workspace").WorktreePreparation>(`/workspace/prepare/${encodeURIComponent(id)}`);
}
cancelWorktreePreparation(id: string) {
return this.post0<import("./workspace").WorktreePreparation>(`/workspace/prepare/${encodeURIComponent(id)}/cancel`);
}
async openInEditor(): Promise<{ editor: string; root: string }> { async openInEditor(): Promise<{ editor: string; root: string }> {
return this.post0<{ editor: string; root: string }>("/workspace/editor"); return this.post0<{ editor: string; root: string }>("/workspace/editor");
} }
async openDocument(path: string): Promise<void> {
await this.post0("/workspace/open-document", { path });
}
revealsFiles() { revealsFiles() {
return host().revealsFiles(); return host().revealsFiles();
} }
@@ -474,8 +404,8 @@ export class SsePort extends SseWorkspace implements AgentPort {
return this.get<WorkspaceFiles>(`/workspace/files${params.size ? `?${params}` : ""}`); return this.get<WorkspaceFiles>(`/workspace/files${params.size ? `?${params}` : ""}`);
} }
workspaceFile(path: string) { workspaceFile(path: string, root?: string) {
return this.get<WorkspaceFile>(`/workspace/file?path=${encodeURIComponent(path)}`); return this.get<WorkspaceFile>(`/workspace/file?path=${encodeURIComponent(path)}${root ? `&root=${encodeURIComponent(root)}` : ""}`);
} }
workspaceImageURL(path: string, root?: string) { workspaceImageURL(path: string, root?: string) {
+16 -4
View File
@@ -1,6 +1,6 @@
import { SseShell } from "./sse_shell"; import { SseShell } from "./sse_shell";
import type { Adjudications, BrowserToolsSettings, SubagentClaimsSettings, WriteLeaseSettings, PlanApprovalSettings, PeerReceipt, PeersState, ConfigProblem, ConfigRepair, PermissionLists, PermissionRules, SandboxSettings } from "./port"; import type { Adjudications, BrowserToolsSettings, SubagentClaimsSettings, WriteLeaseSettings, PlanApprovalSettings, PeerReceipt, PeersState, PeersSettings, PeersSettingsEdit, PeerRemember, ConfigProblem, ConfigRepair, PermissionLists, PermissionRules, SandboxSettings } from "./port";
import type { AskTimeoutSettings, AutoArchiveSettings, ChangedFilesSettings, FoldWorkSettings, TurnNarrationSettings, DisplayCurrencyMode, DisplayCurrencySettings, ProgressWatchSettings, WorkspaceRoots, ReceiptSettings, PersonaDraft, PersonaSettings } from "./boundary"; import type { AskTimeoutSettings, AutoArchiveSettings, ChangedFilesSettings, FoldWorkSettings, TurnNarrationSettings, WebAccessPatch, WebAccessSettings, DisplayCurrencyMode, DisplayCurrencySettings, ProgressWatchSettings, WorkspaceRoots, ReceiptSettings, PersonaDraft, PersonaSettings } from "./boundary";
// Where the agent may reach: the permission rules a call is matched against and // Where the agent may reach: the permission rules a call is matched against and
// the sandbox the shell runs in. // the sandbox the shell runs in.
@@ -62,8 +62,14 @@ export class SseBoundary extends SseShell {
sendPeerMessage(to: string, text: string) { sendPeerMessage(to: string, text: string) {
return this.post0<PeerReceipt>("/peers/send", { to, text }); return this.post0<PeerReceipt>("/peers/send", { to, text });
} }
resolvePeerMessage(id: string, approve: boolean) { resolvePeerMessage(id: string, approve: boolean, remember: PeerRemember = "none") {
return this.post(`/peers/held/${encodeURIComponent(id)}`, { approve }); return this.post(`/peers/held/${encodeURIComponent(id)}`, { approve, remember });
}
peersSettings() {
return this.get<PeersSettings>("/peers/settings");
}
savePeersSettings(edit: PeersSettingsEdit) {
return this.post0<PeersSettings>("/peers/settings", edit);
} }
saveSubagentClaims(optimistic: boolean) { saveSubagentClaims(optimistic: boolean) {
return this.post0<SubagentClaimsSettings>("/subagent-claims", { optimistic }); return this.post0<SubagentClaimsSettings>("/subagent-claims", { optimistic });
@@ -92,6 +98,12 @@ export class SseBoundary extends SseShell {
saveTurnNarrationSettings(level: TurnNarrationSettings["level"]) { saveTurnNarrationSettings(level: TurnNarrationSettings["level"]) {
return this.post0<TurnNarrationSettings>("/turn-narration-settings", { level }); return this.post0<TurnNarrationSettings>("/turn-narration-settings", { level });
} }
webAccessSettings() {
return this.get<WebAccessSettings>("/web-access-settings");
}
saveWebAccessSettings(patch: WebAccessPatch) {
return this.post0<WebAccessSettings>("/web-access-settings", patch);
}
progressWatch() { progressWatch() {
return this.get<ProgressWatchSettings>("/progress-watch"); return this.get<ProgressWatchSettings>("/progress-watch");
} }
+6 -1
View File
@@ -107,7 +107,12 @@ export class SseExtensions extends SseLook {
}); });
if (!res.ok) await SseExtensions.fail(`/plugins/${encodeURIComponent(name)}/export`, res); if (!res.ok) await SseExtensions.fail(`/plugins/${encodeURIComponent(name)}/export`, res);
const required = (res.headers.get("X-Reasonix-Required-Env") ?? "").split(",").filter(Boolean); const required = (res.headers.get("X-Reasonix-Required-Env") ?? "").split(",").filter(Boolean);
const url = URL.createObjectURL(await res.blob()); const blob = await res.blob();
// A shell with a save dialog puts the archive where it is asked to; the
// anchor below is the browser's own way and the only one a tab has.
const saved = await host().saveBytes(`${name}.zip`, new Uint8Array(await blob.arrayBuffer()));
if (saved !== null) return { required, savedTo: saved || undefined };
const url = URL.createObjectURL(blob);
const a = document.createElement("a"); const a = document.createElement("a");
a.href = url; a.href = url;
a.download = `${name}.zip`; a.download = `${name}.zip`;
@@ -0,0 +1,37 @@
import { afterEach, expect, it, vi } from "vitest";
import { HttpError, type HookEntry } from "./port";
import { SsePort } from "./sse";
afterEach(() => vi.unstubAllGlobals());
// The real port is a chain of faces. A method an outer face declares again hides
// the inner one, and a fix made in the inner one then never runs.
it("no face of the real port redeclares a method of a face it extends", () => {
const repeated: string[] = [];
for (let face = SsePort.prototype; face !== Object.prototype; face = Object.getPrototypeOf(face)) {
for (const name of Object.getOwnPropertyNames(face)) {
if (name === "constructor") continue;
for (let below = Object.getPrototypeOf(face); below !== Object.prototype; below = Object.getPrototypeOf(below)) {
if (Object.hasOwn(below, name)) repeated.push(`${face.constructor.name}.${name} hides ${below.constructor.name}.${name}`);
}
}
}
expect(repeated).toEqual([]);
});
function refusing(status: number, refusal: { code: string; error: string }) {
const body = JSON.stringify(refusal);
vi.stubGlobal("fetch", vi.fn(async () => ({ ok: false, status, text: async () => body, json: async () => refusal })));
}
const hook: HookEntry = { event: "PreToolUse", command: "true", scope: "project" };
it.each([
["probeProvider", 401, "provider.probe.unauthorized", (p: SsePort) => p.probeProvider("https://api.example.test", "sk-test")],
["dryRunHook", 400, "hooks.dry_run_failed", (p: SsePort) => p.dryRunHook(hook)],
])("%s hands the refusal's code to the reader", async (_, status, code, call) => {
refusing(status, { code, error: "refused" });
const err = await call(new SsePort("", "r1")).catch((e: unknown) => e);
expect(err).toBeInstanceOf(HttpError);
expect((err as HttpError).reason?.code).toBe(code);
});
+4 -15
View File
@@ -14,6 +14,7 @@ export class SseLook extends SseHttp {
language: look.language ?? "", language: look.language ?? "",
zoom: look.zoom ?? 0, zoom: look.zoom ?? 0,
readSize: look.readSize ?? 0, readSize: look.readSize ?? 0,
textSize: look.textSize ?? 0,
fontUi: look.fontUi ?? "", fontUi: look.fontUi ?? "",
fontMono: look.fontMono ?? "", fontMono: look.fontMono ?? "",
width: look.width ?? "", width: look.width ?? "",
@@ -30,21 +31,9 @@ export class SseLook extends SseHttp {
// cross-site form from posting here at all. // cross-site form from posting here at all.
async uploadWallpaper(blob: Blob) { async uploadWallpaper(blob: Blob) {
guardUpload(blob.size, UPLOAD_LIMIT_MB.wallpaper, tooLarge("wallpaper.too_large", "limit")); guardUpload(blob.size, UPLOAD_LIMIT_MB.wallpaper, tooLarge("wallpaper.too_large", "limit"));
const res = await fetch(this.base + "/appearance/wallpaper", { return this.post0<Appearance>("/appearance/wallpaper", { mime: blob.type, data: await base64Of(blob) });
method: "POST",
headers: { "content-type": "application/json" },
credentials: "same-origin",
body: JSON.stringify({ mime: blob.type, data: await base64Of(blob) }),
});
const body = (await res.json().catch(() => ({}))) as Appearance & { error?: string };
if (!res.ok) throw new Error(body.error || `/appearance/wallpaper: ${res.status}`);
return body;
} }
async clearWallpaper() { clearWallpaper() {
const res = await fetch(this.base + "/appearance/wallpaper", { return this.del("/appearance/wallpaper");
method: "DELETE",
credentials: "same-origin",
});
if (!res.ok) throw new Error(`/appearance/wallpaper: ${res.status}`);
} }
} }
@@ -33,6 +33,12 @@ export class SseMcp extends SseProvider {
if (!res.ok && !body.error) throw new Error(`/mcp/trust: ${res.status}`); if (!res.ok && !body.error) throw new Error(`/mcp/trust: ${res.status}`);
return { state: body.state ?? (res.ok ? "ready" : "failed"), tools: typeof body.tools === "number" ? body.tools : body.tools?.length, error: body.error }; return { state: body.state ?? (res.ok ? "ready" : "failed"), tools: typeof body.tools === "number" ? body.tools : body.tools?.length, error: body.error };
} }
signinMcp(name: string) {
return this.post0<{ name: string; state: string; host?: string; tools?: number }>("/mcp/signin", { name });
}
followMcp(name: string, follow: boolean) {
return this.post0<{ name: string; follow: boolean; tools?: number }>("/mcp/follow", { name, follow });
}
async reconnectMcp(name: string) { async reconnectMcp(name: string) {
const res = await fetch(this.base + "/mcp/reconnect", { const res = await fetch(this.base + "/mcp/reconnect", {
method: "POST", method: "POST",
+2 -10
View File
@@ -7,16 +7,8 @@ export class SseNetwork extends SseHook {
network() { network() {
return this.get<NetworkSettings>("/network"); return this.get<NetworkSettings>("/network");
} }
async saveNetwork(settings: NetworkSettings, password: string, clearPassword: boolean) { saveNetwork(settings: NetworkSettings, password: string, clearPassword: boolean) {
const res = await fetch(this.base + "/network", { return this.post0<NetworkSettings>("/network", { ...settings, password, clearPassword });
method: "POST",
headers: { "content-type": "application/json" },
credentials: "same-origin",
body: JSON.stringify({ ...settings, password, clearPassword }),
});
const body = (await res.json().catch(() => ({}))) as NetworkSettings & { error?: string };
if (!res.ok) throw new Error(body.error || `/network: ${res.status}`);
return body;
} }
async diagnoseNetwork() { async diagnoseNetwork() {
const r = await this.post0<{ probes?: NetworkProbe[] }>("/network/diagnose"); const r = await this.post0<{ probes?: NetworkProbe[] }>("/network/diagnose");
@@ -0,0 +1,26 @@
import { afterEach, expect, it, vi } from "vitest";
import { HttpError, type NetworkSettings } from "./port";
import { SsePort } from "./sse";
afterEach(() => vi.unstubAllGlobals());
function refusing(status: number, refusal: { code: string; error: string }) {
const body = JSON.stringify(refusal);
vi.stubGlobal("fetch", vi.fn(async () => ({ ok: false, status, text: async () => body, json: async () => refusal })));
}
const proxy: NetworkSettings = { mode: "custom", effective: "" };
// The settings panels word a refusal from its code; a port that throws only the
// kernel's text leaves them printing that fallback in whatever language it is.
it.each([
["saveNetwork", 400, "network.rejected", (p: SsePort) => p.saveNetwork(proxy, "", false)],
["uploadWallpaper", 422, "wallpaper.unsupported_type", (p: SsePort) => p.uploadWallpaper(new Blob(["BM"], { type: "image/bmp" }))],
["clearWallpaper", 403, "loopback.unauthorized", (p: SsePort) => p.clearWallpaper()],
["accountPoll", 403, "account.signin_disabled", (p: SsePort) => p.accountPoll("device-secret")],
])("%s hands the refusal's code to the reader", async (_, status, code, call) => {
refusing(status, { code, error: "refused" });
const err = await call(new SsePort("", "r1")).catch((e: unknown) => e);
expect(err).toBeInstanceOf(HttpError);
expect((err as HttpError).reason?.code).toBe(code);
});
+3
View File
@@ -115,6 +115,9 @@ export interface Tool {
// What the call showed the model, as data URLs. The output text only names // What the call showed the model, as data URLs. The output text only names
// them, so without these a person is told a picture was taken and not shown it. // them, so without these a person is told a picture was taken and not shown it.
images?: string[]; images?: string[];
// Video files the call handed to the model to watch natively: name and size,
// never the bytes.
videos?: { name: string; size: number; durationMs?: number; model: string }[];
err?: string; err?: string;
// The host's dotted identity for a refusal. err is only its wording. // The host's dotted identity for a refusal. err is only its wording.
refusalCode?: string; refusalCode?: string;
@@ -92,6 +92,10 @@ export interface WorkspaceFile {
path: string; path: string;
content: string; content: string;
revision: string; revision: string;
// The granted folder the file was read from; absent for the workspace itself.
root?: string;
// True for a file read from a granted folder: it can be read here, never saved.
readOnly?: boolean;
} }
export interface WorkspaceEntry { export interface WorkspaceEntry {
@@ -128,6 +132,18 @@ export interface WorktreeSelection {
attachments?: string[]; attachments?: string[];
} }
export interface WorktreePreparation {
id: string;
phase: string;
sourceRoot: string;
ref: string;
startedAt: number;
branch?: string;
workspaceRoot?: string;
code?: string;
detail?: string;
}
export interface CommitFile { export interface CommitFile {
path: string; path: string;
// git raw status letter: "A", "M", "D", "T". // git raw status letter: "A", "M", "D", "T".
@@ -0,0 +1,23 @@
import { expect, it } from "vitest";
import { paneReader } from "./paneread";
import type { RuntimeView } from "../port/hub";
const pane = { id: "r1", base: "/rt/r1", root: "/w", name: "w" } as RuntimeView;
it("reads as empty when the very first read fails, so a window is never left blank", async () => {
const read = paneReader(() => Promise.reject(new Error("offline")));
expect(await read()).toEqual([]);
});
it("returns null for a failure once a read has answered, and recovers on the next answer", async () => {
let fail = false;
const read = paneReader(async () => {
if (fail) throw new Error("offline");
return [pane];
});
expect(await read()).toEqual([pane]);
fail = true;
expect(await read()).toBeNull();
fail = false;
expect(await read()).toEqual([pane]);
});
@@ -0,0 +1,20 @@
import type { RuntimeView } from "../port/hub";
/**
* Reads the pane list so that a failed read is never mistaken for an empty one.
* Once a read has answered, a later failure returns null and the caller keeps
* what it holds; before that, a failure reads as empty so a window never stays
* blank waiting for a list that is not coming.
*/
export function paneReader(read: () => Promise<RuntimeView[]>): () => Promise<RuntimeView[] | null> {
let answered = false;
return async () => {
try {
const list = await read();
answered = true;
return list;
} catch {
return answered ? null : [];
}
};
}
@@ -114,6 +114,10 @@ describe("a retry row names why", () => {
expect(line(s, 0)).toBe("retry 1/10 · scope=headers · cause=upstream_status 502"); expect(line(s, 0)).toBe("retry 1/10 · scope=headers · cause=upstream_status 502");
}); });
it("counts against nothing when the user removed the retry limit", () => {
expect(line(run([retrying({ retryAttempt: 14, retryMax: 0 })]), 0)).toBe("retry 14/∞ · scope=headers");
});
it("stays as it was for a kernel that sends no class", () => { it("stays as it was for a kernel that sends no class", () => {
expect(line(run([retrying({})]), 0)).toBe("retry 1/10 · scope=headers"); expect(line(run([retrying({})]), 0)).toBe("retry 1/10 · scope=headers");
}); });
@@ -185,7 +185,7 @@ function record(ev: WireEvent): Made | null {
kind: "protocol_recovery", kind: "protocol_recovery",
payload: [ payload: [
{ t: "retry " }, { t: "retry " },
{ n: `${ev.retryAttempt ?? 0}/${ev.retryMax ?? 0}` }, { n: `${ev.retryAttempt ?? 0}/${ev.retryMax ? ev.retryMax : "∞"}` },
{ t: " · scope=" }, { t: " · scope=" },
{ b: ev.retryScope ?? "stream" }, { b: ev.retryScope ?? "stream" },
...(ev.retryCause ...(ev.retryCause
+9 -1
View File
@@ -102,6 +102,8 @@ rules are written in.
- The separator between crumbs is a written character, not the 1px hairline - The separator between crumbs is a written character, not the 1px hairline
`.sep`. Sharing that name puts the glyph in a 1px-wide box with a background, `.sep`. Sharing that name puts the glyph in a 1px-wide box with a background,
and the strokes spill out of it. and the strokes spill out of it.
- Project and checkout context shrink together inside `.crumb-context`; the
conversation title keeps its four-em floor even when multiple labels are visible.
- The desktop sidebar's divider owns collapse, expansion and width adjustment. - The desktop sidebar's divider owns collapse, expansion and width adjustment.
- Its grip remains visible when collapsed; the separator supports Enter/Space for toggling, arrow keys for sizing, - Its grip remains visible when collapsed; the separator supports Enter/Space for toggling, arrow keys for sizing,
and Home or a double click for restoring the default width. The open width survives collapse and reload. and Home or a double click for restoring the default width. The open width survives collapse and reload.
@@ -1579,5 +1581,11 @@ what exists.
## Brand lockup ## Brand lockup
- The mark at the top of the rail is one row: the wordmark and a compact filled uppercase badge ("STUDIO" by CSS; the text and the aria label are unchanged), vertically centred on each other, gap 8px. The badge is `--text` on `--page` inverted, so it is dark on light and light on dark and never moves with the theme. - The mark at the top of the rail is one row: the wordmark and a compact outlined uppercase badge ("STUDIO" by CSS; the text and the aria label are unchanged), vertically centred on each other, gap 8px.
- The badge has a transparent fill, a 1px `--hair` border and `--muted` text, so it reads as a label and not as a notice; its outer size is unchanged.
- The badge is hidden at 760px and below. The wordmark box is cropped to its drawn width so the badge sits next to the letters, not next to the asset's padding. - The badge is hidden at 760px and below. The wordmark box is cropped to its drawn width so the badge sits next to the letters, not next to the asset's padding.
## Side question
- The panel stays anchored above its own composer. Collapsing it leaves a header with separate expand and close controls so the conversation can be read without discarding the side question.
- The mounted content keeps its draft, quotes, answers and pending request while collapsed. Errors, quotes and replies share the scroll region; the input stays outside it.
+18 -13
View File
@@ -201,7 +201,7 @@
.btn.sm { padding: 2.5px 8px; font-size: var(--fs-meta); } .btn.sm { padding: 2.5px 8px; font-size: var(--fs-meta); }
.picker { position: relative; flex: none } .picker { position: relative; flex: none }
.isolab { 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(--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[hidden] { display: none } .isolab[hidden] { display: none }
.hostlab { color: var(--net-ink); background: color-mix(in srgb, var(--net) 13%, transparent) } .hostlab { color: var(--net-ink); background: color-mix(in srgb, var(--net) 13%, transparent) }
@@ -781,7 +781,7 @@
color: var(--faint); opacity: .92; color: var(--faint); opacity: .92;
} }
.hero .t { .hero .t {
margin-top: 26px; font: var(--w-reg) 25px/1.25 var(--ui); margin-top: 26px; font: var(--w-reg) calc(25px * var(--fs-scale, 1))/1.25 var(--ui);
letter-spacing: var(--track-display); color: var(--text); letter-spacing: var(--track-display); color: var(--text);
} }
.hero .s { margin-top: 12px; max-width: 48ch; font-size: var(--fs-ui); line-height: 1.75; color: var(--faint) } .hero .s { margin-top: 12px; max-width: 48ch; font-size: var(--fs-ui); line-height: 1.75; color: var(--faint) }
@@ -1120,6 +1120,8 @@
.out .txt.bad { padding-left: 10px; border-left: 2px solid var(--warn) } .out .txt.bad { padding-left: 10px; border-left: 2px solid var(--warn) }
.tvideos { padding: 6px 0 2px }
.tvideo { color: var(--muted); font: var(--w-reg) var(--fs-ui) var(--ui); overflow-wrap: anywhere }
.tshots { display: flex; flex-wrap: wrap; gap: 8px; padding: 6px 0 2px 31px } .tshots { display: flex; flex-wrap: wrap; gap: 8px; padding: 6px 0 2px 31px }
.tshot { padding: 0; border: 1px solid var(--border); border-radius: var(--r-sm); background: var(--sunk); overflow: hidden; cursor: zoom-in } .tshot { padding: 0; border: 1px solid var(--border); border-radius: var(--r-sm); background: var(--sunk); overflow: hidden; cursor: zoom-in }
.tshot img { display: block; max-width: 260px; max-height: 170px; object-fit: contain } .tshot img { display: block; max-width: 260px; max-height: 170px; object-fit: contain }
@@ -1182,7 +1184,7 @@
.term { .term {
margin-top: 8px; padding: 11px 13px 12px; border-radius: var(--r-lg); margin-top: 8px; padding: 11px 13px 12px; border-radius: var(--r-lg);
background: var(--face-sunk); background: var(--face-sunk);
font: var(--w-reg) var(--fs-ui)/19px var(--mono); color: var(--muted); overflow-x: auto; font: var(--w-reg) var(--fs-ui)/calc(19px * var(--fs-scale, 1)) var(--mono); color: var(--muted); overflow-x: auto;
} }
.term-l { white-space: pre } .term-l { white-space: pre }
.term[data-one] { margin: 5px 0 0; padding: 0; background: none; border-radius: 0 } .term[data-one] { margin: 5px 0 0; padding: 0; background: none; border-radius: 0 }
@@ -1258,7 +1260,7 @@
.hits { margin-top: 7px; display: flex; flex-direction: column; gap: 1px } .hits { margin-top: 7px; display: flex; flex-direction: column; gap: 1px }
.hit-row { display: grid; grid-template-columns: 16px 1fr; gap: 9px; padding: 6px 8px; border-radius: var(--r-xs) } .hit-row { display: grid; grid-template-columns: 16px 1fr; gap: 9px; padding: 6px 8px; border-radius: var(--r-xs) }
.hit-row:hover { background: var(--overlay) } .hit-row:hover { background: var(--overlay) }
.hit-row .i { font: var(--w-reg) var(--fs-micro)/17px var(--mono); color: var(--ghost); text-align: right; font-variant-numeric: tabular-nums } .hit-row .i { font: var(--w-reg) var(--fs-micro)/calc(17px * var(--fs-scale, 1)) var(--mono); color: var(--ghost); text-align: right; font-variant-numeric: tabular-nums }
.hit-row .t { font-size: var(--fs-ui); color: var(--text); line-height: 1.45 } .hit-row .t { font-size: var(--fs-ui); color: var(--text); line-height: 1.45 }
.hit-row .u { margin-top: 2px; font: var(--w-reg) var(--fs-meta) var(--mono); color: var(--link); overflow: hidden; text-overflow: ellipsis; white-space: nowrap } .hit-row .u { margin-top: 2px; font: var(--w-reg) var(--fs-meta) var(--mono); color: var(--link); overflow: hidden; text-overflow: ellipsis; white-space: nowrap }
.hit-row .q { margin-top: 4px; font-size: var(--fs-ui); line-height: 1.55; color: var(--muted); border-left: 2px solid var(--border); padding-left: 8px } .hit-row .q { margin-top: 4px; font-size: var(--fs-ui); line-height: 1.55; color: var(--muted); border-left: 2px solid var(--border); padding-left: 8px }
@@ -1289,10 +1291,10 @@
.dif-hd .ro { margin-left: auto; font-size: var(--fs-micro); letter-spacing: var(--track-cap); text-transform: uppercase; color: var(--ghost) } .dif-hd .ro { margin-left: auto; font-size: var(--fs-micro); letter-spacing: var(--track-cap); text-transform: uppercase; color: var(--ghost) }
.dlwrap { overflow-x: auto; overscroll-behavior-x: contain } .dlwrap { overflow-x: auto; overscroll-behavior-x: contain }
.dl { display: grid; grid-template-columns: 42px 14px 1fr; font: var(--w-reg) var(--fs-ui)/20px var(--mono); white-space: pre; min-width: max-content } .dl { display: grid; grid-template-columns: 42px 14px 1fr; font: var(--w-reg) var(--fs-ui)/calc(20px * var(--fs-scale, 1)) var(--mono); white-space: pre; min-width: max-content }
.dhunk { .dhunk {
display: grid; grid-template-columns: 42px 1fr; font: var(--w-reg) var(--fs-micro)/18px var(--mono); display: grid; grid-template-columns: 42px 1fr; font: var(--w-reg) var(--fs-micro)/calc(18px * var(--fs-scale, 1)) var(--mono);
color: var(--ghost); background: color-mix(in srgb, var(--overlay) 55%, transparent); color: var(--ghost); background: color-mix(in srgb, var(--overlay) 55%, transparent);
border-top: 1px solid var(--hair); border-bottom: 1px solid var(--hair); border-top: 1px solid var(--hair); border-bottom: 1px solid var(--hair);
} }
@@ -2111,7 +2113,9 @@
.agents { display: flex; flex-direction: column; gap: 7px; min-height: 18px } .agents { display: flex; flex-direction: column; gap: 7px; min-height: 18px }
.ag { display: flex; align-items: center; gap: 9px; font: var(--w-reg) var(--fs-ui) var(--mono); color: var(--muted); animation: card var(--t-card) var(--ease-out) both } .ag { display: flex; align-items: center; gap: 9px; font: var(--w-reg) var(--fs-ui) var(--mono); color: var(--muted); animation: card var(--t-card) var(--ease-out) both }
.ag .nm { color: var(--text); flex: none } .ag .lbl { display: block; flex: 1 1 0; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap } .ag .nm { color: var(--text); flex: none } .ag .lbl { display: block; flex: 1 1 0; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap }
.ag .rt { margin-left: auto; font-size: var(--fs-meta); color: var(--ghost); font-variant-numeric: tabular-nums } .ag .rt { margin-left: auto; font-size: var(--fs-meta); color: var(--ghost); font-variant-numeric: tabular-nums; white-space: nowrap }
.ag[data-status="done"] .rt { color: var(--ok-ink) }
.ag[data-status="failed"] .rt { color: var(--warn-ink) }
.jobs { display: flex; flex-direction: column; gap: 7px; min-height: 18px } .jobs { display: flex; flex-direction: column; gap: 7px; min-height: 18px }
.job { display: flex; align-items: center; gap: 8px; font: var(--w-reg) var(--fs-meta) var(--mono); color: var(--muted); animation: card var(--t-card) var(--ease-out) both } .job { display: flex; align-items: center; gap: 8px; font: var(--w-reg) var(--fs-meta) var(--mono); color: var(--muted); animation: card var(--t-card) var(--ease-out) both }
@@ -3111,6 +3115,7 @@
.mp-row { grid-template-columns: repeat(4, minmax(0, 1fr)) } .mp-row { grid-template-columns: repeat(4, minmax(0, 1fr)) }
.mp-row .me-name { grid-column: 1 / -1 } .mp-row .me-name { grid-column: 1 / -1 }
.mp-row .why { grid-column: 1 / -1 } .mp-row .why { grid-column: 1 / -1 }
.mp-foreign { display: flex; flex-wrap: wrap; align-items: center; gap: 4px 10px }
.mp-row select { padding: 6px 8px; font-size: var(--fs-meta) } .mp-row select { padding: 6px 8px; font-size: var(--fs-meta) }
.ml-row .me-name { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font: var(--w-med) var(--fs-ui) var(--mono); color: var(--text) } .ml-row .me-name { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font: var(--w-med) var(--fs-ui) var(--mono); color: var(--text) }
.ml-row input::placeholder { color: var(--ghost) } .ml-row input::placeholder { color: var(--ghost) }
@@ -3853,7 +3858,7 @@
.code-body { display: flex; align-items: flex-start } .code-body { display: flex; align-items: flex-start }
.code-gutter { .code-gutter {
flex: none; padding: 11px 10px 12px 12px; text-align: end; flex: none; padding: 11px 10px 12px 12px; text-align: end;
font: var(--w-reg) var(--fs-ui)/19px var(--mono); color: var(--ghost); user-select: none; font: var(--w-reg) var(--fs-ui)/calc(19px * var(--fs-scale, 1)) var(--mono); color: var(--ghost); user-select: none;
border-inline-end: 1px solid var(--hair); border-inline-end: 1px solid var(--hair);
} }
.code-gutter > span { display: block } .code-gutter > span { display: block }
@@ -4096,7 +4101,7 @@
.pal-nm b, .pal-nm em { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; line-height: 16px } .pal-nm b, .pal-nm em { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; line-height: 16px }
.pal-nm b { font-weight: var(--w-reg) } .pal-nm b { font-weight: var(--w-reg) }
.pal[data-on] .pal-nm b { color: var(--text) } .pal[data-on] .pal-nm b { color: var(--text) }
.pal-nm em { margin-top: 2px; font: var(--w-reg) var(--fs-meta)/16px var(--ui); font-style: normal; color: var(--ghost) } .pal-nm em { margin-top: 2px; font: var(--w-reg) var(--fs-meta)/calc(16px * var(--fs-scale, 1)) var(--ui); font-style: normal; color: var(--ghost) }
.extpanel { display: flex; flex-direction: column; gap: 4px; padding: 6px 0 } .extpanel { display: flex; flex-direction: column; gap: 4px; padding: 6px 0 }
.extpanel + .extpanel { border-top: 1px solid var(--hair) } .extpanel + .extpanel { border-top: 1px solid var(--hair) }
@@ -4245,7 +4250,7 @@
.oobe-lines { position: relative; height: 96px; width: 100%; margin-top: 4px } .oobe-lines { position: relative; height: 96px; width: 100%; margin-top: 4px }
.oobe-line { position: absolute; inset: 0; display: grid; place-items: center; opacity: 0 } .oobe-line { position: absolute; inset: 0; display: grid; place-items: center; opacity: 0 }
.oobe-line b { display: block; font: 300 30px/1.35 var(--ui); color: #fff } .oobe-line b { display: block; font: 300 calc(30px * var(--fs-scale, 1))/1.35 var(--ui); color: #fff }
.oobe-line span { display: block; margin-top: 12px; max-width: 40ch; .oobe-line span { display: block; margin-top: 12px; max-width: 40ch;
font: 300 var(--fs-lead)/1.7 var(--ui); color: rgb(255 255 255 / .5) } font: 300 var(--fs-lead)/1.7 var(--ui); color: rgb(255 255 255 / .5) }
.oobe[data-play] .l1 { animation: oobesay 2.6s var(--ease-out) 4s forwards } .oobe[data-play] .l1 { animation: oobesay 2.6s var(--ease-out) 4s forwards }
@@ -4419,7 +4424,7 @@
text-align: center; color: var(--faint); text-align: center; color: var(--faint);
animation: card var(--t-card) var(--ease-out) both; animation: card var(--t-card) var(--ease-out) both;
} }
.panes-empty .mk { font: 300 30px/1 var(--ui); color: var(--ghost); margin-bottom: 2px } .panes-empty .mk { font: 300 calc(30px * var(--fs-scale, 1))/1 var(--ui); color: var(--ghost); margin-bottom: 2px }
.panes-empty .t { margin: 0; font: var(--w-med) var(--fs-lead) var(--ui); color: var(--muted) } .panes-empty .t { margin: 0; font: var(--w-med) var(--fs-lead) var(--ui); color: var(--muted) }
.panes-empty .h { margin: 0 0 4px; font: var(--w-reg) var(--fs-ui)/1.6 var(--ui); color: var(--faint); max-width: 34ch } .panes-empty .h { margin: 0 0 4px; font: var(--w-reg) var(--fs-ui)/1.6 var(--ui); color: var(--faint); max-width: 34ch }
.panes-empty button { .panes-empty button {
@@ -5435,7 +5440,7 @@
.uhero { padding: 22px 24px; background: var(--raised); border: 1px solid var(--border); border-radius: var(--r-md) } .uhero { padding: 22px 24px; background: var(--raised); border: 1px solid var(--border); border-radius: var(--r-md) }
.uhero-k { color: var(--muted); font: var(--w-med) var(--fs-meta) var(--ui); letter-spacing: var(--track-lead); text-transform: uppercase } .uhero-k { color: var(--muted); font: var(--w-med) var(--fs-meta) var(--ui); letter-spacing: var(--track-lead); text-transform: uppercase }
.uhero-fig { display: flex; align-items: baseline; gap: 12px; flex-wrap: wrap; margin-top: 10px } .uhero-fig { display: flex; align-items: baseline; gap: 12px; flex-wrap: wrap; margin-top: 10px }
.uhero-v { font: var(--w-med) 38px/1 var(--mono); font-variant-numeric: tabular-nums; letter-spacing: var(--track-tight) } .uhero-v { font: var(--w-med) calc(38px * var(--fs-scale, 1))/1 var(--mono); font-variant-numeric: tabular-nums; letter-spacing: var(--track-tight) }
.uhero-scope { .uhero-scope {
padding: 4px 10px; border-radius: var(--r-pill); background: var(--overlay); padding: 4px 10px; border-radius: var(--r-pill); background: var(--overlay);
color: var(--muted); font: var(--w-med) var(--fs-ui) var(--ui); color: var(--muted); font: var(--w-med) var(--fs-ui) var(--ui);
@@ -5513,7 +5518,7 @@
.uranges { width: 100%; overflow-x: auto } .uranges { width: 100%; overflow-x: auto }
.uspan { width: 100%; text-align: center } .uspan { width: 100%; text-align: center }
.uhero { padding: 18px 16px } .uhero { padding: 18px 16px }
.uhero-v { font-size: 30px; overflow-wrap: anywhere } .uhero-v { font-size: calc(30px * var(--fs-scale, 1)); overflow-wrap: anywhere }
.utiles { grid-template-columns: repeat(2, minmax(0, 1fr)) } .utiles { grid-template-columns: repeat(2, minmax(0, 1fr)) }
.ucard { padding: 14px 12px 10px } .ucard { padding: 14px 12px 10px }
.utwo { grid-template-columns: minmax(0, 1fr) } .utwo { grid-template-columns: minmax(0, 1fr) }
@@ -0,0 +1,22 @@
import { expect, it } from "vitest";
const CSS = import.meta.glob("./studio.css", { query: "?raw", import: "default", eager: true }) as Record<string, string>;
const css = CSS["./studio.css"];
function badge(): string {
const rules = [...css.matchAll(/\.studio-brand-sub\s*\{([^}]*)\}/g)].map((m) => m[1]!);
expect(rules.length).toBeGreaterThan(0);
return rules.join("\n");
}
it("draws the Studio badge as an outline, not a filled block", () => {
const rule = badge();
expect(rule).toMatch(/background:\s*transparent/);
expect(rule).toMatch(/border:\s*1px solid var\(--hair\)/);
expect(rule).toMatch(/color:\s*var\(--muted\)/);
expect(rule).not.toMatch(/background:\s*var\(--text\)/);
});
it("keeps the badge the same outer size as before the border was added", () => {
expect(badge()).toMatch(/padding:\s*2px 5px 2px 6px/);
});
@@ -1,6 +1,6 @@
:root :is(.portmenu, .polmenu, .worktree-panel).composer-menu { :root :is(.portmenu, .polmenu, .worktree-panel).composer-menu {
--choice-size: 13px; --choice-size: calc(13px * var(--fs-scale, 1));
--choice-note: 11px; --choice-note: calc(11px * var(--fs-scale, 1));
--choice-width: 280px; --choice-width: 280px;
width: min(var(--choice-width), calc(100vw / var(--zoom, 1) - 24px)); width: min(var(--choice-width), calc(100vw / var(--zoom, 1) - 24px));
min-width: 0; min-width: 0;
@@ -18,49 +18,49 @@
} }
:root :is(.polmenu, .studio-model-menu).composer-menu { --choice-width: 340px; } :root :is(.polmenu, .studio-model-menu).composer-menu { --choice-width: 340px; }
:root :is(.worktree-panel, .studio-effort-menu).composer-menu { --choice-width: 320px; } :root :is(.worktree-panel, .studio-effort-menu).composer-menu { --choice-width: 320px; }
:root .composer-menu :is(.studio-picker-title, .studio-policy-head, .worktree-heading), :root .composer-menu > .mi.head:first-child { display: flex; align-items: center; justify-content: space-between; gap: 8px; height: auto; min-height: 30px; margin: 0 0 4px; padding: 2px 8px 8px; border: 0; border-bottom: 1px solid var(--hair); border-radius: 0; color: var(--muted); font: 500 12px/1.4 var(--ui); } :root .composer-menu :is(.studio-picker-title, .studio-policy-head, .worktree-heading), :root .composer-menu > .mi.head:first-child { display: flex; align-items: center; justify-content: space-between; gap: 8px; height: auto; min-height: 30px; margin: 0 0 4px; padding: 2px 8px 8px; border: 0; border-bottom: 1px solid var(--hair); border-radius: 0; color: var(--muted); font: 500 calc(12px * var(--fs-scale, 1))/1.4 var(--ui); }
:root .composer-menu :is(.studio-picker-title b, .studio-policy-head b), :root .composer-menu > .mi.head:first-child .lb { color: var(--muted); font: inherit; letter-spacing: 0; } :root .composer-menu :is(.studio-picker-title b, .studio-policy-head b), :root .composer-menu > .mi.head:first-child .lb { color: var(--muted); font: inherit; letter-spacing: 0; }
:root .composer-menu :is(.studio-picker-title small, .studio-policy-head small), :root .composer-menu > .mi.head:first-child .rt { margin-left: auto; color: var(--muted); font: 400 11px/1.4 var(--ui); text-align: right; } :root .composer-menu :is(.studio-picker-title small, .studio-policy-head small), :root .composer-menu > .mi.head:first-child .rt { margin-left: auto; color: var(--muted); font: 400 calc(11px * var(--fs-scale, 1))/1.4 var(--ui); text-align: right; }
:root .composer-menu :is(button.mi, .studio-permission-option, .worktree-row, .worktree-sources button) { min-height: 36px; padding: 8px 10px; gap: 8px; border-radius: var(--r-sm); color: var(--text); font: 500 var(--choice-size)/1.4 var(--ui); } :root .composer-menu :is(button.mi, .studio-permission-option, .worktree-row, .worktree-sources button) { min-height: 36px; padding: 8px 10px; gap: 8px; border-radius: var(--r-sm); color: var(--text); font: 500 var(--choice-size)/1.4 var(--ui); }
:root .composer-menu :is(.mi .lb, .studio-permission-option b, .studio-policy-boundary b, .worktree-toggle span) { color: inherit; font: 500 var(--choice-size)/1.4 var(--ui); } :root .composer-menu :is(.mi .lb, .studio-permission-option b, .studio-policy-boundary b, .worktree-toggle span) { color: inherit; font: 500 var(--choice-size)/1.4 var(--ui); }
:root .composer-menu :is(.mi .ds, .studio-permission-option small, .studio-policy-boundary small, .worktree-toggle small) { display: block; margin-top: 2px; color: var(--muted); font: 400 var(--choice-note)/1.45 var(--ui); } :root .composer-menu :is(.mi .ds, .studio-permission-option small, .studio-policy-boundary small, .worktree-toggle small) { display: block; margin-top: 2px; color: var(--muted); font: 400 var(--choice-note)/1.45 var(--ui); }
:root .composer-menu .studio-permission-option > span:first-child { gap: 0; } :root .composer-menu .studio-permission-option > span:first-child { gap: 0; }
:root .composer-menu :is(button.mi, .studio-permission-option, .worktree-sources button):is([data-on], [aria-selected="true"]) { background: var(--overlay); color: var(--text); } :root .composer-menu :is(button.mi, .studio-permission-option, .worktree-sources button):is([data-on], [aria-selected="true"]) { background: var(--overlay); color: var(--text); }
:root .composer-menu :is(button.mi, .studio-permission-option, button.worktree-row, .worktree-sources button):not(:disabled):hover { background: var(--float-hi); } :root .composer-menu :is(button.mi, .studio-permission-option, button.worktree-row, .worktree-sources button):not(:disabled):hover { background: var(--float-hi); }
:root .composer-menu .mi[data-on]::after, :root .composer-menu .studio-permission-check { flex-shrink: 0; color: var(--text); font-size: 13px; } :root .composer-menu .mi[data-on]::after, :root .composer-menu .studio-permission-check { flex-shrink: 0; color: var(--text); font-size: calc(13px * var(--fs-scale, 1)); }
:root .composer-menu :is(button, input):focus-visible { outline: 2px solid var(--focus); outline-offset: -2px; } :root .composer-menu :is(button, input):focus-visible { outline: 2px solid var(--focus); outline-offset: -2px; }
:root .composer-menu :is(.studio-policy-note, .worktree-note) { margin: 4px 0 0; padding: 8px; border-top: 1px solid var(--hair); color: var(--muted); font: 400 var(--choice-note)/1.45 var(--ui); } :root .composer-menu :is(.studio-policy-note, .worktree-note) { margin: 4px 0 0; padding: 8px; border-top: 1px solid var(--hair); color: var(--muted); font: 400 var(--choice-note)/1.45 var(--ui); }
:root .composer-menu .studio-policy-primary { padding: 0; } :root .composer-menu .studio-policy-primary { padding: 0; }
:root .composer-menu .studio-policy-risk { padding: 8px 10px 2px; color: var(--warn-ink); font: 500 11px/1.4 var(--ui); } :root .composer-menu .studio-policy-risk { padding: 8px 10px 2px; color: var(--warn-ink); font: 500 calc(11px * var(--fs-scale, 1))/1.4 var(--ui); }
:root .composer-menu .studio-permission-option.studio-permission-yolo b { color: var(--warn-ink); } :root .composer-menu .studio-permission-option.studio-permission-yolo b { color: var(--warn-ink); }
:root .composer-menu .studio-policy-boundary { min-height: 36px; padding: 8px 10px; border-top-color: var(--hair); border-radius: 0 0 var(--r-sm) var(--r-sm); } :root .composer-menu .studio-policy-boundary { min-height: 36px; padding: 8px 10px; border-top-color: var(--hair); border-radius: 0 0 var(--r-sm) var(--r-sm); }
:root .composer-menu .studio-policy-boundary > span:first-child { gap: 0; } :root .composer-menu .studio-policy-boundary > span:first-child { gap: 0; }
:root .composer-menu .worktree-toggle { padding: 8px 10px; gap: 8px; margin: 0; border-bottom: 0; } :root .composer-menu .worktree-toggle { padding: 8px 10px; gap: 8px; margin: 0; border-bottom: 0; }
:root .composer-menu .worktree-toggle > .studio-icon, :root .composer-menu .worktree-row > .studio-icon { width: 16px; height: 16px; } :root .composer-menu .worktree-toggle > .studio-icon, :root .composer-menu .worktree-row > .studio-icon { width: 16px; height: 16px; }
:root .composer-menu .worktree-value { font-size: 12px; } :root .composer-menu .worktree-value { font-size: calc(12px * var(--fs-scale, 1)); }
:root .composer-menu .sw { width: 32px; height: 18px; } :root .composer-menu .sw { width: 32px; height: 18px; }
:root .composer-menu .sw i { width: 14px; height: 14px; margin-left: 2px; } :root .composer-menu .sw i { width: 14px; height: 14px; margin-left: 2px; }
:root .composer-menu .sw[aria-checked="true"] i { transform: translateX(14px); } :root .composer-menu .sw[aria-checked="true"] i { transform: translateX(14px); }
:root .composer-menu :is(.mfind, input[type="search"]) { position: static; width: calc(100% - 16px); margin: 4px 8px 6px; padding: 6px 8px; border: 1px solid var(--hair); border-radius: var(--r-sm); background: var(--raised); color: var(--text); font: 400 12px/1.5 var(--ui); } :root .composer-menu :is(.mfind, input[type="search"]) { position: static; width: calc(100% - 16px); margin: 4px 8px 6px; padding: 6px 8px; border: 1px solid var(--hair); border-radius: var(--r-sm); background: var(--raised); color: var(--text); font: 400 calc(12px * var(--fs-scale, 1))/1.5 var(--ui); }
:root .composer-menu .mchips { margin: 0 8px 6px; padding: 0; } :root .composer-menu .mchips { margin: 0 8px 6px; padding: 0; }
:root .composer-menu .mchip { font-size: 11px; } :root .composer-menu .mchip { font-size: calc(11px * var(--fs-scale, 1)); }
:root .composer-menu.studio-model-menu .mi.head { position: static; min-height: 28px; margin: 4px 0 2px; padding: 6px 10px; border: 0; background: transparent; } :root .composer-menu.studio-model-menu .mi.head { position: static; min-height: 28px; margin: 4px 0 2px; padding: 6px 10px; border: 0; background: transparent; }
:root .composer-menu.studio-model-menu .mi.head .lb { color: var(--muted); font: 500 11px/1.4 var(--ui); } :root .composer-menu.studio-model-menu .mi.head .lb { color: var(--muted); font: 500 calc(11px * var(--fs-scale, 1))/1.4 var(--ui); }
:root .composer-menu.studio-model-menu .mi.head .rt { color: var(--muted); font: 400 11px/1.4 var(--mono); } :root .composer-menu.studio-model-menu .mi.head .rt { color: var(--muted); font: 400 calc(11px * var(--fs-scale, 1))/1.4 var(--mono); }
:root .composer-menu.studio-model-menu .mi.head .mi-mono { display: none; } :root .composer-menu.studio-model-menu .mi.head .mi-mono { display: none; }
:root .composer-menu.studio-model-menu .mi .rt { color: var(--muted); font-size: 11px; } :root .composer-menu.studio-model-menu .mi .rt { color: var(--muted); font-size: calc(11px * var(--fs-scale, 1)); }
:root .composer-menu.studio-model-menu .studio-item-line { flex-wrap: wrap; row-gap: 2px; } :root .composer-menu.studio-model-menu .studio-item-line { flex-wrap: wrap; row-gap: 2px; }
:root .composer-menu.studio-model-menu .studio-item-line > .lb { flex-basis: 100%; white-space: normal; overflow-wrap: anywhere; } :root .composer-menu.studio-model-menu .studio-item-line > .lb { flex-basis: 100%; white-space: normal; overflow-wrap: anywhere; }
:root .composer-menu.studio-effort-menu .mi:not(.head) { min-height: 36px; grid-template-columns: minmax(0, 1fr) 13px; } :root .composer-menu.studio-effort-menu .mi:not(.head) { min-height: 36px; grid-template-columns: minmax(0, 1fr) 13px; }
:root .composer-menu .studio-effort-strength { display: none; } :root .composer-menu .studio-effort-strength { display: none; }
:root .composer-menu .studio-item-meta { color: var(--muted); font-size: 11px; } :root .composer-menu .studio-item-meta { color: var(--muted); font-size: calc(11px * var(--fs-scale, 1)); }
:root .composer-menu .studio-item-badge { font-size: 11px; } :root .composer-menu .studio-item-badge { font-size: calc(11px * var(--fs-scale, 1)); }
:root .composer-menu.studio-effort-menu > .mi.head:last-child { min-height: 0; margin: 4px 0 0; padding: 8px; border: 0; border-top: 1px solid var(--hair); } :root .composer-menu.studio-effort-menu > .mi.head:last-child { min-height: 0; margin: 4px 0 0; padding: 8px; border: 0; border-top: 1px solid var(--hair); }
:root .composer-menu.studio-effort-menu > .mi.head:last-child :is(.lb, .rt) { color: var(--muted); font: 400 11px/1.45 var(--ui); } :root .composer-menu.studio-effort-menu > .mi.head:last-child :is(.lb, .rt) { color: var(--muted); font: 400 calc(11px * var(--fs-scale, 1))/1.45 var(--ui); }
:root .composer-menu .worktree-sources { max-height: 320px; margin: 0; } :root .composer-menu .worktree-sources { max-height: 320px; margin: 0; }
:root .composer-menu .worktree-sources > div > small { padding: 6px 10px 2px; font-size: 11px; } :root .composer-menu .worktree-sources > div > small { padding: 6px 10px 2px; font-size: calc(11px * var(--fs-scale, 1)); }
:root .composer-menu .worktree-sources button small { color: var(--muted); font-size: 11px; } :root .composer-menu .worktree-sources button small { color: var(--muted); font-size: calc(11px * var(--fs-scale, 1)); }
:root .composer-menu .worktree-back { margin: 4px 0 0; padding: 6px 10px; font-size: 12px; } :root .composer-menu .worktree-back { margin: 4px 0 0; padding: 6px 10px; font-size: calc(12px * var(--fs-scale, 1)); }
@container conversation (max-width: 470px) { @container conversation (max-width: 470px) {
:root .composer-menu.polmenu { left: 0; right: 0; width: auto; max-width: none; } :root .composer-menu.polmenu { left: 0; right: 0; width: auto; max-width: none; }
} }
@@ -1,14 +1,14 @@
.chrome-context-parts .chrome-context-more { .chrome-context-parts .chrome-context-more {
display: inline-flex; align-items: center; gap: 3px; padding: 0 5px; border: 1px solid var(--border); border-radius: 999px; background: transparent; cursor: pointer; display: inline-flex; align-items: center; gap: 3px; padding: 0 5px; border: 1px solid var(--border); border-radius: 999px; background: transparent; cursor: pointer;
color: var(--muted); font: 550 9px/1.5 var(--mono); color: var(--muted); font: 550 calc(9px * var(--fs-scale, 1))/1.5 var(--mono);
} }
.chrome-context-parts .chrome-context-more::after { content: "›"; transition: transform .12s; } .chrome-context-parts .chrome-context-more::after { content: "›"; transition: transform .12s; }
.chrome-context-parts .chrome-context-more[aria-expanded="true"] { color: var(--text); background: var(--overlay); } .chrome-context-parts .chrome-context-more[aria-expanded="true"] { color: var(--text); background: var(--overlay); }
.chrome-context-parts .chrome-context-more[aria-expanded="true"]::after { transform: rotate(90deg); } .chrome-context-parts .chrome-context-more[aria-expanded="true"]::after { transform: rotate(90deg); }
.chrome-context-parts .chrome-context-more:focus-visible { outline: 2px solid var(--focus); outline-offset: 2px; } .chrome-context-parts .chrome-context-more:focus-visible { outline: 2px solid var(--focus); outline-offset: 2px; }
.chrome-context-items { list-style: none; margin: 6px 0 0; padding: 0 0 0 8px; border-left: 1px solid var(--border); display: grid; gap: 3px; } .chrome-context-items { list-style: none; margin: 6px 0 0; padding: 0 0 0 8px; border-left: 1px solid var(--border); display: grid; gap: 3px; }
.chrome-context-items li { display: flex; align-items: center; gap: 8px; color: var(--muted); font: 500 10px/1.4 var(--ui); } .chrome-context-items li { display: flex; align-items: center; gap: 8px; color: var(--muted); font: 500 calc(10px * var(--fs-scale, 1))/1.4 var(--ui); }
.chrome-context-items span[data-tool] { font-family: var(--mono); } .chrome-context-items span[data-tool] { font-family: var(--mono); }
.chrome-context-items em { font-style: normal; color: var(--accent); } .chrome-context-items em { font-style: normal; color: var(--accent); }
.chrome-context-items b { margin-left: auto; color: var(--text); font: 550 9px/1 var(--mono); } .chrome-context-items b { margin-left: auto; color: var(--text); font: 550 calc(9px * var(--fs-scale, 1))/1 var(--mono); }
.chrome-context-items li.head { color: var(--ghost); font: 550 9px/1.4 var(--ui); } .chrome-context-items li.head { color: var(--ghost); font: 550 calc(9px * var(--fs-scale, 1))/1.4 var(--ui); }
@@ -240,11 +240,11 @@
.studio-feedback .fbk-badge { .studio-feedback .fbk-badge {
margin-left: auto; min-width: 16px; height: 16px; padding: 0 5px; box-sizing: border-box; display: inline-grid; place-items: center; margin-left: auto; min-width: 16px; height: 16px; padding: 0 5px; box-sizing: border-box; display: inline-grid; place-items: center;
border-radius: var(--r-pill); background: var(--accent); color: var(--accent-fg); font: var(--w-med) 10px/1 var(--ui); border-radius: var(--r-pill); background: var(--accent); color: var(--accent-fg); font: var(--w-med) calc(10px * var(--fs-scale, 1))/1 var(--ui);
} }
.fbk-msg { unicode-bidi: isolate; } .fbk-msg { unicode-bidi: isolate; }
.fbk-tl li[data-tone="neutral"] i { border-color: var(--ghost); background: var(--ghost); } .fbk-tl li[data-tone="neutral"] i { border-color: var(--ghost); background: var(--ghost); }
.palette-hit > small[data-alert] { color: var(--text); font: 600 11px/1 var(--ui); } .palette-hit > small[data-alert] { color: var(--text); font: 600 calc(11px * var(--fs-scale, 1))/1 var(--ui); }
.fbk { --badge-ink: var(--muted); --badge-lock: var(--faint); } .fbk { --badge-ink: var(--muted); --badge-lock: var(--faint); }
.lvl-badge { flex: none; color: var(--badge-ink); } .lvl-badge { flex: none; color: var(--badge-ink); }
@@ -277,7 +277,7 @@
.fbk-level-bar { flex: 1 1 auto; height: 6px; border-radius: var(--r-pill); background: var(--hair); overflow: hidden; } .fbk-level-bar { flex: 1 1 auto; height: 6px; border-radius: var(--r-pill); background: var(--hair); overflow: hidden; }
.fbk-level-fill { display: block; height: 100%; border-radius: inherit; background: var(--badge-ink); } .fbk-level-fill { display: block; height: 100%; border-radius: inherit; background: var(--badge-ink); }
.fbk-level-next { display: inline-flex; align-items: center; gap: 5px; flex: none; font: var(--w-med) var(--fs-meta) var(--ui); color: var(--muted); } .fbk-level-next { display: inline-flex; align-items: center; gap: 5px; flex: none; font: var(--w-med) var(--fs-meta) var(--ui); color: var(--muted); }
.fbk-level-limits, .fbk-level-note, .fbk-level-stale { margin: 0; font: var(--w-reg) var(--fs-meta)/1.55 var(--ui); color: var(--muted); } .fbk-level-limits, .fbk-level-note, .fbk-level-meaning, .fbk-level-stale { margin: 0; font: var(--w-reg) var(--fs-meta)/1.55 var(--ui); color: var(--muted); }
.fbk-level-limits { color: var(--text); } .fbk-level-limits { color: var(--text); }
.fbk-level-gone { margin: 0; } .fbk-level-gone { margin: 0; }
.fbk-reply-shots { display: grid; gap: 6px; margin-top: 8px; } .fbk-reply-shots { display: grid; gap: 6px; margin-top: 8px; }
@@ -0,0 +1,46 @@
import { describe, expect, it } from "vitest";
const SHEETS = import.meta.glob("./*.css", { query: "?raw", import: "default", eager: true }) as Record<string, string>;
const STEP = /\bcalc\([0-9.]+px \* var\(--fs-scale, 1\)\)/g;
const LENGTH = /(?<![\w.-])-?[0-9]*\.?[0-9]+(px|pt)\b/;
// The text-size setting moves --fs-scale, so a size that reaches the page as a
// bare pixel count is one it can never move. Sizes ride the tokens, or a pixel
// count inside calc(<px> * var(--fs-scale, 1)); the transcript's own dial is
// --read, which a size may derive from by var().
function fontDeclarations(css: string): { at: number; text: string }[] {
const found: { at: number; text: string }[] = [];
for (const m of css.matchAll(/(?<![-\w])(font-size|font)\s*:\s*([^;}]*)/g)) found.push({ at: css.slice(0, m.index).split("\n").length, text: m[2] });
return found;
}
describe("a size written into a stylesheet", () => {
it("never reaches a font declaration as a bare pixel count", () => {
const bare: string[] = [];
for (const [name, css] of Object.entries(SHEETS)) {
for (const d of fontDeclarations(css)) if (LENGTH.test(d.text.replace(STEP, ""))) bare.push(`${name}:${d.at} ${d.text.trim().slice(0, 60)}`);
}
expect(bare).toEqual([]);
});
it("never reaches one through a custom property that is only a pixel count", () => {
const all = Object.values(SHEETS).join("\n");
const used = new Set<string>();
for (const d of fontDeclarations(all)) for (const m of d.text.matchAll(/var\((--[\w-]+)/g)) used.add(m[1]);
const bare: string[] = [];
for (const [name, css] of Object.entries(SHEETS)) {
for (const m of css.matchAll(/(--[\w-]+)\s*:\s*([^;}]*)/g)) {
if (used.has(m[1]) && m[1] !== "--read" && /^-?[0-9.]+px$/.test(m[2].trim())) bare.push(`${name} ${m[1]}: ${m[2].trim()}`);
}
}
expect(bare).toEqual([]);
});
it("moves the shared tokens with the scale", () => {
const tokens = SHEETS["./tokens.css"];
for (const t of ["--fs-micro", "--fs-meta", "--fs-ui", "--fs-lead", "--fs-title", "--fs-display"]) {
expect(tokens).toMatch(new RegExp(`${t}: calc\\([0-9.]+px \\* var\\(--fs-scale, 1\\)\\)`));
}
});
});
@@ -1,18 +1,18 @@
.workbench-history { flex: none; border-bottom: 1px solid var(--hair); } .workbench-history { flex: none; border-bottom: 1px solid var(--hair); }
.workbench-history > summary { display: flex; align-items: center; gap: 8px; height: 32px; padding: 0 12px; cursor: pointer; list-style: none; color: var(--muted); font: 620 12px/1 var(--ui); text-transform: uppercase; } .workbench-history > summary { display: flex; align-items: center; gap: 8px; height: 32px; padding: 0 12px; cursor: pointer; list-style: none; color: var(--muted); font: 620 calc(12px * var(--fs-scale, 1))/1 var(--ui); text-transform: uppercase; }
.workbench-history > summary::-webkit-details-marker { display: none; } .workbench-history > summary::-webkit-details-marker { display: none; }
.workbench-history > summary small { margin-left: auto; color: var(--faint); } .workbench-history > summary small { margin-left: auto; color: var(--faint); }
.workbench-history > summary:hover { color: var(--text); } .workbench-history > summary:hover { color: var(--text); }
.workbench-history > summary:focus-visible { outline: 2px solid var(--focus); outline-offset: -2px; } .workbench-history > summary:focus-visible { outline: 2px solid var(--focus); outline-offset: -2px; }
.gh-note { display: flex; align-items: center; gap: 8px; margin: 0; padding: 6px 12px 10px; color: var(--muted); font: 450 12px/1.4 var(--ui); } .gh-note { display: flex; align-items: center; gap: 8px; margin: 0; padding: 6px 12px 10px; color: var(--muted); font: 450 calc(12px * var(--fs-scale, 1))/1.4 var(--ui); }
.gh-list { margin: 0; padding: 0 0 6px; list-style: none; max-height: 320px; overflow: auto; } .gh-list { margin: 0; padding: 0 0 6px; list-style: none; max-height: 320px; overflow: auto; }
.gh-row { display: flex; align-items: center; gap: 6px; height: 28px; padding-right: 10px; color: var(--muted); font: 450 12px/1 var(--ui); content-visibility: auto; contain-intrinsic-size: 28px; } .gh-row { display: flex; align-items: center; gap: 6px; height: 28px; padding-right: 10px; color: var(--muted); font: 450 calc(12px * var(--fs-scale, 1))/1 var(--ui); content-visibility: auto; contain-intrinsic-size: 28px; }
.gh-row:hover { background: var(--overlay); color: var(--text); } .gh-row:hover { background: var(--overlay); color: var(--text); }
.gh-lanes { flex: none; overflow: visible; } .gh-lanes { flex: none; overflow: visible; }
.gh-lanes path { fill: none; stroke-width: 1.5; stroke-linecap: round; } .gh-lanes path { fill: none; stroke-width: 1.5; stroke-linecap: round; }
.gh-lanes circle[data-merge] { stroke: var(--page); stroke-width: 1.5; } .gh-lanes circle[data-merge] { stroke: var(--page); stroke-width: 1.5; }
.gh-subject { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--text); } .gh-subject { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--text); }
.gh-ref { flex: none; max-width: 40%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; padding: 2px 6px; border: 1px solid var(--hair); border-radius: 999px; font: 600 10.5px/1.2 var(--mono); font-style: normal; color: var(--muted); } .gh-ref { flex: none; max-width: 40%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; padding: 2px 6px; border: 1px solid var(--hair); border-radius: 999px; font: 600 calc(10.5px * var(--fs-scale, 1))/1.2 var(--mono); font-style: normal; color: var(--muted); }
.gh-ref[data-kind="branch"] { color: var(--accent-ink); border-color: var(--accent); } .gh-ref[data-kind="branch"] { color: var(--accent-ink); border-color: var(--accent); }
.gh-meta { flex: none; color: var(--faint); font: 450 11px/1 var(--ui); font-variant-numeric: tabular-nums; } .gh-meta { flex: none; color: var(--faint); font: 450 calc(11px * var(--fs-scale, 1))/1 var(--ui); font-variant-numeric: tabular-nums; }
.gh-more { padding: 6px 12px; color: var(--faint); font: 450 11.5px/1.3 var(--ui); } .gh-more { padding: 6px 12px; color: var(--faint); font: 450 calc(11.5px * var(--fs-scale, 1))/1.3 var(--ui); }
@@ -1,11 +1,11 @@
.studio-model-menu.portmenu .mi.head { margin: 6px -8px 3px; padding: 8px 17px 7px; gap: 9px; border-top: 1px solid var(--border); border-bottom: 1px solid var(--border); background: color-mix(in srgb, var(--text) 6%, var(--float)); } .studio-model-menu.portmenu .mi.head { margin: 6px -8px 3px; padding: 8px 17px 7px; gap: 9px; border-top: 1px solid var(--border); border-bottom: 1px solid var(--border); background: color-mix(in srgb, var(--text) 6%, var(--float)); }
.studio-model-menu.portmenu .mi.head:first-of-type { margin-top: 0; border-top: 0; } .studio-model-menu.portmenu .mi.head:first-of-type { margin-top: 0; border-top: 0; }
.studio-model-menu.portmenu .mi.head .lb { color: var(--text); font: var(--w-semi) 12.5px/1.2 var(--ui); letter-spacing: 0; } .studio-model-menu.portmenu .mi.head .lb { color: var(--text); font: var(--w-semi) calc(12.5px * var(--fs-scale, 1))/1.2 var(--ui); letter-spacing: 0; }
.studio-model-menu.portmenu .mi.head .rt { color: var(--muted); font: 500 10px/1 var(--mono); } .studio-model-menu.portmenu .mi.head .rt { color: var(--muted); font: 500 calc(10px * var(--fs-scale, 1))/1 var(--mono); }
.studio-model-menu.portmenu .mi.head .mi-mono { width: 20px; height: 20px; font-size: 11px; } .studio-model-menu.portmenu .mi.head .mi-mono { width: 20px; height: 20px; font-size: calc(11px * var(--fs-scale, 1)); }
.studio-model-menu.portmenu button.mi { padding-left: 24px; } .studio-model-menu.portmenu button.mi { padding-left: 24px; }
.mchips { display: flex; flex-wrap: wrap; gap: 6px; margin: 2px -2px 6px; padding: 4px 2px; } .mchips { display: flex; flex-wrap: wrap; gap: 6px; margin: 2px -2px 6px; padding: 4px 2px; }
.mchip { border: 1px solid var(--border); border-radius: 999px; background: transparent; color: var(--muted); font: var(--w-med) 11.5px/1 var(--ui); padding: 5px 10px; cursor: pointer; } .mchip { border: 1px solid var(--border); border-radius: 999px; background: transparent; color: var(--muted); font: var(--w-med) calc(11.5px * var(--fs-scale, 1))/1 var(--ui); padding: 5px 10px; cursor: pointer; }
.mchip:hover { color: var(--text); background: var(--overlay); } .mchip:hover { color: var(--text); background: var(--overlay); }
.mchip[aria-pressed="true"] { color: var(--text); background: var(--overlay); border-color: var(--text); } .mchip[aria-pressed="true"] { color: var(--text); background: var(--overlay); border-color: var(--text); }
.mchip.clear { border-style: dashed; } .mchip.clear { border-style: dashed; }
@@ -6,6 +6,8 @@
.studio-model-control .model-picker .nm { max-width: none; } .studio-model-control .model-picker .nm { max-width: none; }
} }
.chrome .r-fold { display: contents; } .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 { 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"] .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; } [data-fold~="bar"] .tab { flex: 0 1 auto; min-width: 0; padding-inline: 6px; overflow: hidden; text-overflow: ellipsis; }
@@ -8,19 +8,19 @@
.studio-overview-anchor[data-open] .studio-overview-popover { opacity: 1; visibility: visible; pointer-events: auto; transform: none; transition-delay: 0s; } .studio-overview-anchor[data-open] .studio-overview-popover { opacity: 1; visibility: visible; pointer-events: auto; transform: none; transition-delay: 0s; }
.studio-meterrail .studio-overview-anchor > button { .studio-meterrail .studio-overview-anchor > button {
height: 25px; display: inline-flex; align-items: center; gap: 5px; padding: 0 6px; border: 0; border-radius: 5px; background: transparent; height: 25px; display: inline-flex; align-items: center; gap: 5px; padding: 0 6px; border: 0; border-radius: 5px; background: transparent;
color: var(--faint); white-space: nowrap; font: 500 10px/1 var(--ui); font-variant-numeric: tabular-nums; color: var(--faint); white-space: nowrap; font: 500 calc(10px * var(--fs-scale, 1))/1 var(--ui); font-variant-numeric: tabular-nums;
} }
.studio-meterrail .studio-overview-anchor > button:hover { background: color-mix(in srgb, var(--text) 7%, transparent); color: var(--text); } .studio-meterrail .studio-overview-anchor > button:hover { background: color-mix(in srgb, var(--text) 7%, transparent); color: var(--text); }
:root[data-theme="light"] .studio-meterrail .studio-overview-anchor > button { color: var(--ghost); } :root[data-theme="light"] .studio-meterrail .studio-overview-anchor > button { color: var(--ghost); }
.studio-overview-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 10px 14px; margin: 0; } .studio-overview-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 10px 14px; margin: 0; }
.studio-overview-grid > div { display: grid; gap: 2px; align-content: start; min-width: 0; } .studio-overview-grid > div { display: grid; gap: 2px; align-content: start; min-width: 0; }
.studio-overview-grid dt { color: var(--ghost); font: 500 9px/1.3 var(--ui); } .studio-overview-grid dt { color: var(--ghost); font: 500 calc(9px * var(--fs-scale, 1))/1.3 var(--ui); }
.studio-overview-grid dd { margin: 0; color: var(--text); font: 600 13px/1.2 var(--mono); } .studio-overview-grid dd { margin: 0; color: var(--text); font: 600 calc(13px * var(--fs-scale, 1))/1.2 var(--mono); }
.studio-overview-grid small { color: var(--ghost); font: 500 9px/1.4 var(--ui); } .studio-overview-grid small { color: var(--ghost); font: 500 calc(9px * var(--fs-scale, 1))/1.4 var(--ui); }
.studio-overview-row { display: grid; grid-template-columns: minmax(0, 1.3fr) 3.2ch 5ch 4.5ch minmax(0, 1.2fr); align-items: center; gap: 8px; min-height: 22px; padding: 0 5px; border-radius: 5px; } .studio-overview-row { display: grid; grid-template-columns: minmax(0, 1.3fr) 3.2ch 5ch 4.5ch minmax(0, 1.2fr); align-items: center; gap: 8px; min-height: 22px; padding: 0 5px; border-radius: 5px; }
.studio-overview-row:hover { background: var(--overlay); } .studio-overview-row:hover { background: var(--overlay); }
.studio-overview-row > span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--muted); } .studio-overview-row > span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--muted); }
.studio-overview-row > b { color: var(--muted); font: 550 9px/1 var(--mono); text-align: right; } .studio-overview-row > b { color: var(--muted); font: 550 calc(9px * var(--fs-scale, 1))/1 var(--mono); text-align: right; }
.studio-overview-row > small { text-align: right; font-family: var(--mono); } .studio-overview-row > small { text-align: right; font-family: var(--mono); }
@media (hover: hover) { @media (hover: hover) {
.studio-overview-anchor:hover .studio-overview-popover { opacity: 1; visibility: visible; pointer-events: auto; transform: none; transition-delay: 0s; } .studio-overview-anchor:hover .studio-overview-popover { opacity: 1; visibility: visible; pointer-events: auto; transform: none; transition-delay: 0s; }
+18 -2
View File
@@ -1,7 +1,7 @@
.peerbar { overflow-y: auto; overscroll-behavior: contain; display: grid; gap: 4px; padding: var(--y-line) 12px; border: 1px solid var(--hair); border-radius: var(--r-md); background: var(--surface); } .peerbar { overflow-y: auto; overscroll-behavior: contain; display: grid; gap: 4px; padding: var(--y-line) 12px; border: 1px solid var(--hair); border-radius: var(--r-md); background: var(--surface); }
.peerheld { display: grid; gap: 4px; } .peerheld { display: grid; gap: 4px; }
.peerrow { display: flex; flex-wrap: nowrap; align-items: center; justify-content: space-between; gap: 4px 10px; } .peerrow { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 4px 10px; }
.peerrow .acts { display: flex; gap: 6px; margin: 0; } .peerrow .acts { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: 6px; margin: 0; max-width: 100%; }
.peerhead { min-width: 0; flex: 1 1 8em; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font: var(--w-reg) var(--fs-meta) var(--ui); color: var(--muted); } .peerhead { min-width: 0; flex: 1 1 8em; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font: var(--w-reg) var(--fs-meta) var(--ui); color: var(--muted); }
.peerbody { margin: 0; padding: 6px 8px; overflow: auto; white-space: pre-wrap; overflow-wrap: anywhere; border: 1px solid var(--hair); border-radius: var(--r-sm); background: var(--raised); font: var(--w-reg) var(--fs-ui) var(--ui); color: var(--text); max-height: 8.5em; } .peerbody { margin: 0; padding: 6px 8px; overflow: auto; white-space: pre-wrap; overflow-wrap: anywhere; border: 1px solid var(--hair); border-radius: var(--r-sm); background: var(--raised); font: var(--w-reg) var(--fs-ui) var(--ui); color: var(--text); max-height: 8.5em; }
.peerbody[data-clamped] { display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 2; line-clamp: 2; overflow: hidden; } .peerbody[data-clamped] { display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 2; line-clamp: 2; overflow: hidden; }
@@ -10,3 +10,19 @@
textarea.peerfield { min-height: 4.5em; resize: vertical; } textarea.peerfield { min-height: 4.5em; resize: vertical; }
.peerfield:focus-visible { outline: 2px solid var(--focus); outline-offset: 1px; } .peerfield:focus-visible { outline: 2px solid var(--focus); outline-offset: 1px; }
.peerform .acts { display: flex; justify-content: flex-end; margin: 0; } .peerform .acts { display: flex; justify-content: flex-end; margin: 0; }
.wa-sec { display: grid; border-top: 1px solid var(--hair); }
.wa-sec:first-child { border-top: 0; }
.wa-list { display: grid; margin: 0; padding: 0; list-style: none; }
.wa-list > li { display: flex; flex-wrap: wrap; align-items: center; gap: 6px 10px; padding: 8px 12px; border-top: 1px solid var(--hair); }
.wa-list > li > .tx { display: grid; flex: 1 1 14em; min-width: 0; }
.wa-list > li > .acts { display: flex; gap: 4px; }
.wa-consent { display: flex; flex: 1 1 100%; align-items: center; gap: 10px; padding: 6px 0 2px 14px; }
.wa-consent > .tx { display: grid; flex: 1 1 12em; min-width: 0; }
.wa-url { display: grid; gap: 4px; padding: 6px 12px 10px; }
.peerreleased { display: grid; margin: 0; padding: 0; list-style: none; }
.peerreleased li { display: flex; align-items: center; justify-content: space-between; gap: 10px; padding: 6px 12px; border-top: 1px solid var(--hair); }
.peerreleased .tx { display: grid; min-width: 0; }
.peerreleased .ds { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.peerheld .apv-note { margin: 0; }
.peernote { margin: 0; padding: 6px 12px; font: var(--w-reg) var(--fs-meta) var(--ui); color: var(--muted); overflow-wrap: anywhere; }
.peernote code { font-family: var(--mono); color: var(--text); }
@@ -0,0 +1,20 @@
.peerthread {
flex: none;
display: inline-flex;
align-items: center;
justify-content: center;
width: 18px;
height: 18px;
margin-left: 4px;
padding: 0;
border: 0;
border-radius: var(--r-sm);
background: transparent;
color: var(--muted);
cursor: pointer;
}
.peerthread svg { width: 13px; height: 13px; }
.peerthread:hover { background: var(--hair); color: var(--text); }
.peerthread:focus-visible { outline: 2px solid var(--focus); outline-offset: 1px; }
.peerthread[data-state="replied"] { color: var(--accent); }
.peerthread[data-state="declined"] { color: var(--err); }
@@ -31,8 +31,17 @@ describe("the shape and rhythm scales", () => {
}); });
it("steps no type and no layout width", () => { it("steps no type and no layout width", () => {
for (const name of ["--fs-ui", "--fs-meta", "--read", "--rail-open", "--side-open", "--dock-open", "--column"]) { for (const name of ["--read", "--rail-open", "--side-open", "--dock-open", "--column"]) {
expect(tokens, name).not.toMatch(new RegExp(`${name}:\\s*calc\\(`)); expect(tokens, name).not.toMatch(new RegExp(`${name}:\\s*calc\\(`));
} }
}); });
// Type moves with the reader's text size and with nothing a recipe sets.
it("moves type only by the text-size scale, never by a recipe's", () => {
for (const name of ["--fs-ui", "--fs-meta"]) {
const value = new RegExp(`${name}:\\s*([^;]*);`).exec(tokens)?.[1] ?? "";
expect(value, name).toMatch(/var\(--fs-scale, 1\)/);
expect(value.replace(/--fs-scale/g, ""), name).not.toMatch(/--[a-z]+-scale/);
}
});
}); });
@@ -1,10 +1,10 @@
.run-signal-line button b { position: absolute; z-index: 1; left: 50%; top: 50%; translate: -50% -50%; min-width: 18px; height: 18px; padding: 0 4px; border-radius: 9px; box-sizing: border-box; font: 600 11px/18px var(--font-mono, monospace); text-align: center; color: var(--raised); background: var(--ra-color); box-shadow: 0 0 0 1px color-mix(in srgb, var(--ra-color) 62%, var(--border)); } .run-signal-line button b { position: absolute; z-index: 1; left: 50%; top: 50%; translate: -50% -50%; min-width: 18px; height: 18px; padding: 0 4px; border-radius: 9px; box-sizing: border-box; font: 600 calc(11px * var(--fs-scale, 1))/calc(18px * var(--fs-scale, 1)) var(--font-mono, monospace); text-align: center; color: var(--raised); background: var(--ra-color); box-shadow: 0 0 0 1px color-mix(in srgb, var(--ra-color) 62%, var(--border)); }
.run-signal-line button:hover b, .run-signal-line button:hover b,
.run-signal-line button:focus-visible b { scale: 1.12; } .run-signal-line button:focus-visible b { scale: 1.12; }
.run-signal-group { margin: 6px 10px 0; } .run-signal-group { margin: 6px 10px 0; }
.run-signal-group ul { list-style: none; margin: 0; padding: 0; max-height: 220px; overflow-y: auto; border: 1px solid var(--hair); border-radius: var(--r-xs); } .run-signal-group ul { list-style: none; margin: 0; padding: 0; max-height: 220px; overflow-y: auto; border: 1px solid var(--hair); border-radius: var(--r-xs); }
.run-signal-group li + li { border-top: 1px solid var(--hair); } .run-signal-group li + li { border-top: 1px solid var(--hair); }
.run-signal-group li button { display: grid; grid-template-columns: 14px 1fr auto; align-items: center; gap: 8px; width: 100%; padding: 6px 10px; border: 0; background: transparent; color: inherit; font: inherit; font-size: 12px; text-align: left; cursor: pointer; } .run-signal-group li button { display: grid; grid-template-columns: 14px 1fr auto; align-items: center; gap: 8px; width: 100%; padding: 6px 10px; border: 0; background: transparent; color: inherit; font: inherit; font-size: calc(12px * var(--fs-scale, 1)); text-align: left; cursor: pointer; }
.run-signal-group li button[aria-expanded="true"] { background: color-mix(in srgb, var(--ra-color) 10%, transparent); } .run-signal-group li button[aria-expanded="true"] { background: color-mix(in srgb, var(--ra-color) 10%, transparent); }
.run-signal-group li button i { width: 8px; height: 8px; rotate: 45deg; background: var(--ra-color); } .run-signal-group li button i { width: 8px; height: 8px; rotate: 45deg; background: var(--ra-color); }
.run-signal-group li button small { color: var(--text-dim); font-variant-numeric: tabular-nums; } .run-signal-group li button small { color: var(--text-dim); font-variant-numeric: tabular-nums; }
@@ -12,8 +12,8 @@
} }
.session-rename-dialog::backdrop { background: rgb(0 0 0 / .28); } .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 header { display: flex; align-items: center; justify-content: space-between; gap: 16px; }
.session-rename-dialog h2 { margin: 0; font-size: 24px; font-weight: 650; line-height: 1.3; } .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: 16px; } .session-rename-dialog p { margin: 5px 0 18px; color: var(--muted); font-size: calc(16px * var(--fs-scale, 1)); }
.session-rename-dialog input { .session-rename-dialog input {
display: block; display: block;
box-sizing: border-box; box-sizing: border-box;
@@ -25,7 +25,7 @@
background: var(--raised); background: var(--raised);
color: var(--text); color: var(--text);
font: inherit; font: inherit;
font-size: 16px; 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 footer { display: flex; align-items: center; gap: 12px; margin-top: 16px; }
.session-rename-dialog button { .session-rename-dialog button {
@@ -36,18 +36,18 @@
background: var(--raised); background: var(--raised);
color: var(--text); color: var(--text);
font: inherit; font: inherit;
font-size: 16px; font-size: calc(16px * var(--fs-scale, 1));
cursor: pointer; cursor: pointer;
} }
.session-rename-dialog .session-rename-auto { margin-right: auto; } .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-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 { 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-close svg { width: 16px; height: 16px; }
.session-rename-dialog .session-rename-error { margin: 12px 0 0; color: var(--err); font-size: 14px; } .session-rename-dialog .session-rename-error { margin: 12px 0 0; color: var(--err); font-size: calc(14px * var(--fs-scale, 1)); }
.session-rename-dialog button:disabled { opacity: .5; cursor: default; } .session-rename-dialog button:disabled { opacity: .5; cursor: default; }
.session-rename-dialog :focus-visible { outline: 2px solid var(--focus); outline-offset: 2px; } .session-rename-dialog :focus-visible { outline: 2px solid var(--focus); outline-offset: 2px; }
@media (max-width: 400px) { @media (max-width: 400px) {
.session-rename-dialog { padding: 20px; } .session-rename-dialog { padding: 20px; }
.session-rename-dialog footer { gap: 8px; } .session-rename-dialog footer { gap: 8px; }
.session-rename-dialog button { padding: 0 12px; font-size: 14px; } .session-rename-dialog button { padding: 0 12px; font-size: calc(14px * var(--fs-scale, 1)); }
} }
@@ -19,3 +19,20 @@ it.each(Object.entries(CSS))("excludes disabled navigation from every hover over
expect(stylesheet.match(/\.prefs-nav button:hover(?:\s|\{)/)).toBeNull(); expect(stylesheet.match(/\.prefs-nav button:hover(?:\s|\{)/)).toBeNull();
expect(stylesheet.match(/\.prefs-nav button:not\(:disabled\):hover\s*\{[^}]*background:/)).not.toBeNull(); expect(stylesheet.match(/\.prefs-nav button:not\(:disabled\):hover\s*\{[^}]*background:/)).not.toBeNull();
}); });
it("keeps the selected settings section's fill under the pointer in the light theme", () => {
const studio = CSS["./studio.css"];
const hover = studio.lastIndexOf(':root[data-theme="light"] .prefs-nav button:not(:disabled):hover');
const selected = studio.match(/:root\[data-theme="light"\] \.prefs-nav button\[aria-selected="true"\]:not\(:disabled\):hover\s*\{([^}]*)\}/);
expect(hover).toBeGreaterThan(-1);
expect(selected).not.toBeNull();
expect(selected![1]).toMatch(/background:\s*var\(--border\)/);
expect(studio.indexOf(selected![0])).toBeGreaterThan(hover);
});
it("keeps the selected settings section's icon ink under the pointer", () => {
const studio = CSS["./studio.css"];
const rule = studio.match(/\.prefs-nav button\[aria-selected="true"\]:not\(:disabled\):hover svg\s*\{([^}]*)\}/);
expect(rule).not.toBeNull();
expect(rule![1]).toMatch(/color:\s*var\(--text\)/);
});
@@ -12,7 +12,7 @@
border-radius: 8px; border-radius: 8px;
background: var(--surface); background: var(--surface);
color: var(--text); color: var(--text);
font: 500 12px/1.5 var(--ui); font: 500 calc(12px * var(--fs-scale, 1))/1.5 var(--ui);
pointer-events: none; pointer-events: none;
} }
.rail-drop svg { flex: none; width: 18px; height: 18px; } .rail-drop svg { flex: none; width: 18px; height: 18px; }
+23 -15
View File
@@ -4,27 +4,35 @@
display: flex; flex-direction: column; border: 1px solid var(--border); border-radius: 12px; transform-origin: bottom left; display: flex; flex-direction: column; border: 1px solid var(--border); border-radius: 12px; transform-origin: bottom left;
background: var(--raised); color: var(--text); box-shadow: 0 12px 32px -12px rgba(0, 0, 0, .45); background: var(--raised); color: var(--text); box-shadow: 0 12px 32px -12px rgba(0, 0, 0, .45);
} }
.sidepanel > header { display: flex; align-items: flex-start; justify-content: space-between; gap: 10px; padding: 10px 12px; border-bottom: 1px solid var(--border); } .sidepanel > header { flex: none; display: flex; align-items: flex-start; justify-content: space-between; gap: 10px; padding: 10px 12px; border-bottom: 1px solid var(--border); }
.sidepanel > header span { display: grid; gap: 2px; } .sidepanel > header span { display: grid; gap: 2px; }
.sidepanel > header b { font: 600 12px/1.3 var(--ui); } .sidepanel > header b { font: 600 calc(12px * var(--fs-scale, 1))/1.3 var(--ui); }
.sidepanel > header small { color: var(--ghost); font: 500 10px/1.3 var(--ui); } .sidepanel > header small { color: var(--ghost); font: 500 calc(10px * var(--fs-scale, 1))/1.3 var(--ui); }
.sidepanel > header button { padding: 0 6px; border: 0; background: none; color: var(--muted); font-size: 16px; line-height: 1; cursor: pointer; } .sidepanel > header button { padding: 0 6px; border: 0; background: none; color: var(--muted); font-size: calc(16px * var(--fs-scale, 1)); line-height: 1; cursor: pointer; }
.sidepanel > header button:hover { color: var(--text); } .sidepanel > header button:hover { color: var(--text); }
.sidebody { flex: 1; min-height: 80px; overflow-y: auto; display: grid; align-content: start; gap: 10px; padding: 10px 12px; } .sidebody { flex: 1; min-height: 0; overflow-y: auto; display: grid; align-content: start; gap: 10px; padding: 10px 12px; }
.sidehint { margin: 0; color: var(--faint); font: 500 11px/1.6 var(--ui); } .sidehint { margin: 0; color: var(--faint); font: 500 calc(11px * var(--fs-scale, 1))/1.6 var(--ui); }
.sideturn { display: grid; gap: 4px; } .sideturn { display: grid; gap: 4px; }
.sideturn p { margin: 0; white-space: pre-wrap; overflow-wrap: anywhere; font: 500 12px/1.6 var(--ui); } .sideturn p { margin: 0; white-space: pre-wrap; overflow-wrap: anywhere; font: 500 calc(12px * var(--fs-scale, 1))/1.6 var(--ui); }
.sideturn[data-role="me"] p { color: var(--muted); } .sideturn[data-role="me"] p { color: var(--muted); }
.sideturn blockquote { margin: 0; padding: 4px 8px; border-left: 2px solid var(--border); color: var(--faint); font: 500 11px/1.5 var(--mono); white-space: pre-wrap; overflow-wrap: anywhere; max-height: 72px; overflow: hidden; } .sideturn blockquote { margin: 0; padding: 4px 8px; border-left: 2px solid var(--border); color: var(--faint); font: 500 calc(11px * var(--fs-scale, 1))/1.5 var(--mono); white-space: pre-wrap; overflow-wrap: anywhere; max-height: 72px; overflow: hidden; }
.sideerr { margin: 0 12px 8px; color: #e4675d; font: 500 11px/1.5 var(--ui); } .sideerr { margin: 0 12px 8px; color: #e4675d; font: 500 calc(11px * var(--fs-scale, 1))/1.5 var(--ui); }
.sidequotes { list-style: none; margin: 0; padding: 0 12px 6px; display: grid; gap: 4px; } .sidequotes { list-style: none; margin: 0; padding: 0 12px 6px; display: grid; gap: 4px; }
.sidequotes li { display: flex; gap: 6px; align-items: flex-start; padding: 3px 6px; border: 1px solid var(--border); border-radius: 6px; background: var(--overlay); } .sidequotes li { display: flex; gap: 6px; align-items: flex-start; padding: 3px 6px; border: 1px solid var(--border); border-radius: 6px; background: var(--overlay); }
.sidequotes li span { flex: 1; min-width: 0; max-height: 36px; overflow: hidden; font: 500 11px/1.5 var(--mono); overflow-wrap: anywhere; } .sidequotes li span { flex: 1; min-width: 0; max-height: 36px; overflow: hidden; font: 500 calc(11px * var(--fs-scale, 1))/1.5 var(--mono); overflow-wrap: anywhere; }
.sidequotes li button { border: 0; background: none; color: var(--muted); cursor: pointer; } .sidequotes li button { border: 0; background: none; color: var(--muted); cursor: pointer; }
.sidepanel > footer { display: grid; grid-template-columns: 1fr auto; gap: 6px; padding: 8px 12px 12px; border-top: 1px solid var(--border); } .sidecontent > footer { flex: none; display: grid; grid-template-columns: 1fr auto; gap: 6px; padding: 8px 12px 12px; border-top: 1px solid var(--border); }
.sidepanel > footer .sidequote { grid-column: 1 / -1; justify-self: start; padding: 2px 8px; border: 1px solid var(--border); border-radius: 999px; background: none; color: var(--muted); font: var(--w-med) var(--fs-meta)/1.5 var(--ui); cursor: pointer; } .sidecontent > footer .sidequote { grid-column: 1 / -1; justify-self: start; padding: 2px 8px; border: 1px solid var(--border); border-radius: 999px; background: none; color: var(--muted); font: var(--w-med) var(--fs-meta)/1.5 var(--ui); cursor: pointer; }
.sidepanel > footer .sidequote:hover { color: var(--text); background: var(--overlay); } .sidecontent > footer .sidequote:hover { color: var(--text); background: var(--overlay); }
.sidepanel textarea { resize: none; min-width: 0; padding: 6px 8px; border: 1px solid var(--border); border-radius: 8px; background: var(--surface); color: var(--text); font: 500 12px/1.5 var(--ui); } .sidepanel textarea { resize: none; min-width: 0; padding: 6px 8px; border: 1px solid var(--border); border-radius: 8px; background: var(--surface); color: var(--text); font: 500 calc(12px * var(--fs-scale, 1))/1.5 var(--ui); }
.sidepanel textarea:focus-visible { outline: 2px solid var(--focus); outline-offset: 1px; } .sidepanel textarea:focus-visible { outline: 2px solid var(--focus); outline-offset: 1px; }
.sidesend { padding: 0 12px; border: 1px solid var(--text); border-radius: 8px; background: var(--text); color: var(--surface); font: 600 12px/1 var(--ui); cursor: pointer; } .sidesend { padding: 0 12px; border: 1px solid var(--text); border-radius: 8px; background: var(--text); color: var(--surface); font: 600 calc(12px * var(--fs-scale, 1))/1 var(--ui); cursor: pointer; }
.sidesend:disabled { opacity: .45; cursor: default; } .sidesend:disabled { opacity: .45; cursor: default; }
.sidecontent { min-height: 0; display: flex; flex-direction: column; overflow: hidden; }
.sidecontent[hidden] { display: none; }
.sideactions { display: flex; align-items: center; gap: 8px; }
.sidepanel > header .sidecollapse { display: inline-flex; align-items: center; justify-content: center; width: 24px; height: 24px; padding: 0; }
.sidecollapse svg { width: 16px; height: 16px; }
.sidecollapse[aria-expanded="false"] svg { transform: rotate(180deg); }
.sidepanel[data-collapsed] > header { border-bottom: 0; }
@@ -6,4 +6,4 @@
.skillpin-more { margin-left: auto; padding-left: 12px; color: var(--faint); font: var(--w-reg) var(--fs-meta) var(--ui); } .skillpin-more { margin-left: auto; padding-left: 12px; color: var(--faint); font: var(--w-reg) var(--fs-meta) var(--ui); }
.skillpin[open] .skillpin-more { visibility: hidden; } .skillpin[open] .skillpin-more { visibility: hidden; }
.skillpin-desc { margin: 8px 0 0; color: var(--muted); font-size: var(--fs-meta); } .skillpin-desc { margin: 8px 0 0; color: var(--muted); font-size: var(--fs-meta); }
.skillpin-text { max-height: 280px; margin: 8px 0 0; overflow: auto; white-space: pre-wrap; overflow-wrap: anywhere; color: var(--muted); font: var(--w-reg) 11.5px/1.6 var(--mono); } .skillpin-text { max-height: 280px; margin: 8px 0 0; overflow: auto; white-space: pre-wrap; overflow-wrap: anywhere; color: var(--muted); font: var(--w-reg) calc(11.5px * var(--fs-scale, 1))/1.6 var(--mono); }
@@ -1,2 +1,2 @@
.stophint { position: fixed; z-index: 30; left: 50%; top: calc(var(--chrome-h) + 52px); translate: -50% 0; padding: 7px 14px; border: 1px solid var(--border); border-radius: var(--r-pill); background: var(--float); color: var(--text); font: var(--w-med) 12px/1.3 var(--ui); box-shadow: 0 4px 16px rgb(0 0 0 / .14); white-space: nowrap; pointer-events: none; } .stophint { position: fixed; z-index: 30; left: 50%; top: calc(var(--chrome-h) + 52px); translate: -50% 0; padding: 7px 14px; border: 1px solid var(--border); border-radius: var(--r-pill); background: var(--float); color: var(--text); font: var(--w-med) calc(12px * var(--fs-scale, 1))/1.3 var(--ui); box-shadow: 0 4px 16px rgb(0 0 0 / .14); white-space: nowrap; pointer-events: none; }
.stophint[hidden] { display: none; } .stophint[hidden] { display: none; }
@@ -59,8 +59,8 @@ describe("the Studio shell closes over the active theme", () => {
it("pins the composer to the Studio type scale even when a theme pack is active", () => { it("pins the composer to the Studio type scale even when a theme pack is active", () => {
expect(closure).toMatch(/\.compose textarea\s*\{[^}]*font:\s*var\(--w-reg\) var\(--read\)\/1\.65 var\(--ui\)/s); expect(closure).toMatch(/\.compose textarea\s*\{[^}]*font:\s*var\(--w-reg\) var\(--read\)\/1\.65 var\(--ui\)/s);
expect(closure).toMatch(/\.compose textarea::placeholder\s*\{[^}]*font:\s*inherit/s); expect(closure).toMatch(/\.compose textarea::placeholder\s*\{[^}]*font:\s*inherit/s);
expect(closure).toMatch(/\.compose \.queue\s*\{[^}]*font:\s*500 10px\/1\.35 var\(--ui\)/s); expect(closure).toMatch(/\.compose \.queue\s*\{[^}]*font:\s*500 calc\(10px \* var\(--fs-scale, 1\)\)\/1\.35 var\(--ui\)/s);
expect(closure).toMatch(/\.compose \.mode,[\s\S]*?font-size:\s*11px !important/s); expect(closure).toMatch(/\.compose \.mode,[\s\S]*?font-size:\s*calc\(11px \* var\(--fs-scale, 1\)\) !important/s);
}); });
it("keeps the sidebar brand on the same title-bar row as the chrome", () => { it("keeps the sidebar brand on the same title-bar row as the chrome", () => {
File diff suppressed because it is too large. Load diff
+2 -2
View File
@@ -125,8 +125,8 @@
--sp-1: 2px; --sp-2: 4px; --sp-3: 6px; --sp-4: 8px; --sp-1: 2px; --sp-2: 4px; --sp-3: 6px; --sp-4: 8px;
--sp-5: 10px; --sp-6: 12px; --sp-7: 16px; --sp-8: 24px; --sp-5: 10px; --sp-6: 12px; --sp-7: 16px; --sp-8: 24px;
--fs-micro: 11px; --fs-meta: 12px; --fs-ui: 13px; --fs-micro: calc(11px * var(--fs-scale, 1)); --fs-meta: calc(12px * var(--fs-scale, 1)); --fs-ui: calc(13px * var(--fs-scale, 1));
--fs-lead: 15px; --fs-title: 17px; --fs-display: 22px; --fs-lead: calc(15px * var(--fs-scale, 1)); --fs-title: calc(17px * var(--fs-scale, 1)); --fs-display: calc(22px * var(--fs-scale, 1));
--read: 16px; --read: 16px;
@@ -18,12 +18,12 @@
:is(.rowswap .machpick, .machacts .machpick, .rowbtns .wsadd, .rowbtns .wsmore) { width: 22px; height: 22px; border-radius: 6px; color: var(--text); } :is(.rowswap .machpick, .machacts .machpick, .rowbtns .wsadd, .rowbtns .wsmore) { width: 22px; height: 22px; border-radius: 6px; color: var(--text); }
:is(.rowswap .machpick, .rowbtns .wsadd, .rowbtns .wsmore) { box-shadow: inset 0 0 0 1px var(--hair); } :is(.rowswap .machpick, .rowbtns .wsadd, .rowbtns .wsmore) { box-shadow: inset 0 0 0 1px var(--hair); }
:is(.rowswap .machpick, .machacts .machpick, .rowbtns .wsadd, .rowbtns .wsmore):is(:hover, :active, [aria-expanded="true"], [data-busy]):not(:disabled) { background: var(--raised); box-shadow: inset 0 0 0 1px var(--border); } :is(.rowswap .machpick, .machacts .machpick, .rowbtns .wsadd, .rowbtns .wsmore):is(:hover, :active, [aria-expanded="true"], [data-busy]):not(:disabled) { background: var(--raised); box-shadow: inset 0 0 0 1px var(--border); }
.machrow:is([data-here], [data-group]) .rmtname { font-size: 11px; font-weight: 640; letter-spacing: .04em; color: var(--muted); } .machrow:is([data-here], [data-group]) .rmtname { font-size: calc(11px * var(--fs-scale, 1)); font-weight: 640; letter-spacing: .04em; color: var(--muted); }
.rowcount[data-words] { font: var(--w-reg) 11px/1 var(--ui); } .rowcount[data-words] { font: var(--w-reg) calc(11px * var(--fs-scale, 1))/1 var(--ui); }
.rowcount[data-pill] { min-width: 18px; padding: 2px 5px; border-radius: var(--r-pill); background: var(--overlay); color: var(--muted); text-align: center; } .rowcount[data-pill] { min-width: 18px; padding: 2px 5px; border-radius: var(--r-pill); background: var(--overlay); color: var(--muted); text-align: center; }
.wsrow .rowbtns .wsmore { opacity: 1; } .wsrow .rowbtns .wsmore { opacity: 1; }
.studio-session-segments button { font-size: 12px; } .studio-session-segments button { font-size: calc(12px * var(--fs-scale, 1)); }
.studio-session-segments b { font-size: 10px; } .studio-session-segments b { font-size: calc(10px * var(--fs-scale, 1)); }
.rail .machrow { margin-right: 15px; } .rail .machrow { margin-right: 15px; }
.machrow[data-here] { grid-template-columns: 10px 6px minmax(0, 1fr) auto auto; } .machrow[data-here] { grid-template-columns: 10px 6px minmax(0, 1fr) auto auto; }
.machrow[data-here] > .machpick { grid-area: 1 / 5; opacity: 1; width: 22px; height: 22px; border-radius: 6px; color: var(--text); box-shadow: inset 0 0 0 1px var(--hair); } .machrow[data-here] > .machpick { grid-area: 1 / 5; opacity: 1; width: 22px; height: 22px; border-radius: 6px; color: var(--text); box-shadow: inset 0 0 0 1px var(--hair); }
@@ -9,5 +9,5 @@
.workbench-stage[aria-checked="mixed"]::after { content: ""; width: 8px; height: 2px; border-radius: 1px; background: currentColor; } .workbench-stage[aria-checked="mixed"]::after { content: ""; width: 8px; height: 2px; border-radius: 1px; background: currentColor; }
.workbench-stage:disabled { opacity: .5; cursor: default; } .workbench-stage:disabled { opacity: .5; cursor: default; }
.workbench-stage:focus-visible { outline: 2px solid var(--focus); outline-offset: 1px; } .workbench-stage:focus-visible { outline: 2px solid var(--focus); outline-offset: 1px; }
.workbench-explorer-head .workbench-stage-all { height: 20px; margin-inline: 8px; padding: 0 8px; font-size: 11px; } .workbench-explorer-head .workbench-stage-all { height: 20px; margin-inline: 8px; padding: 0 8px; font-size: calc(11px * var(--fs-scale, 1)); }
.workbench-stage-error { margin: 4px 10px; } .workbench-stage-error { margin: 4px 10px; }
@@ -1,8 +1,8 @@
.workbench-launcher { margin: auto; width: min(100%, 380px); padding: 24px 16px; display: grid; gap: 10px; justify-items: stretch; } .workbench-launcher { margin: auto; width: min(100%, 380px); padding: 24px 16px; display: grid; gap: 10px; justify-items: stretch; }
.workbench-launcher > .studio-icon { justify-self: center; width: 40px; height: 40px; margin-bottom: 14px; color: var(--ghost); } .workbench-launcher > .studio-icon { justify-self: center; width: 40px; height: 40px; margin-bottom: 14px; color: var(--ghost); }
.workbench-launcher button { height: 52px; display: flex; align-items: center; gap: 14px; padding: 0 18px; border: 1px solid var(--border); border-radius: 14px; background: var(--raised); color: var(--text); font: var(--w-med) 13px/1 var(--ui); cursor: pointer; text-align: left; } .workbench-launcher button { height: 52px; display: flex; align-items: center; gap: 14px; padding: 0 18px; border: 1px solid var(--border); border-radius: 14px; background: var(--raised); color: var(--text); font: var(--w-med) calc(13px * var(--fs-scale, 1))/1 var(--ui); cursor: pointer; text-align: left; }
.workbench-launcher button .studio-icon { width: 17px; height: 17px; color: var(--muted); } .workbench-launcher button .studio-icon { width: 17px; height: 17px; color: var(--muted); }
.workbench-launcher button small { margin-left: auto; color: var(--faint); font: var(--w-reg) 11px/1 var(--mono); } .workbench-launcher button small { margin-left: auto; color: var(--faint); font: var(--w-reg) calc(11px * var(--fs-scale, 1))/1 var(--mono); }
.workbench-launcher button:hover:not(:disabled) { background: var(--overlay); } .workbench-launcher button:hover:not(:disabled) { background: var(--overlay); }
.workbench-launcher button:focus-visible { outline: 2px solid var(--focus); outline-offset: 2px; } .workbench-launcher button:focus-visible { outline: 2px solid var(--focus); outline-offset: 2px; }
.workbench-launcher button:disabled { opacity: .5; cursor: default; } .workbench-launcher button:disabled { opacity: .5; cursor: default; }
@@ -0,0 +1,16 @@
.workspace-path { display: flex; align-items: flex-start; gap: 6px; min-width: 0; color: var(--muted); font: var(--fs-meta)/1.5 var(--ui) }
.workspace-path details { flex: 1; min-width: 0 }
.workspace-path summary { display: flex; align-items: center; gap: 8px; min-height: 38px; padding: 6px 4px; border-radius: 6px; list-style: none; cursor: pointer }
.workspace-path summary::-webkit-details-marker { display: none }
.workspace-path summary > span { min-width: 0; flex: 1; overflow: hidden; white-space: nowrap; text-overflow: ellipsis }
.workspace-path summary > .studio-icon { width: 17px; height: 17px; flex-shrink: 0 }
.workspace-path details[open] summary > .studio-icon:last-child { transform: rotate(180deg) }
.workspace-path summary:hover { color: var(--text); background: var(--float-hi) }
.workspace-path code { display: block; padding: 6px 4px 10px; overflow-wrap: anywhere; user-select: text; color: var(--text); font: var(--fs-micro)/1.7 var(--mono) }
.workspace-path .workspace-path-copy { display: grid; place-items: center; width: 36px; min-width: 36px; height: 38px; margin: 0; padding: 6px; color: var(--muted); background: none; border: 0; border-radius: 6px; cursor: pointer }
.workspace-path .workspace-path-copy:hover { color: var(--text); background: var(--float-hi) }
.workspace-path .workspace-path-copy .studio-icon { width: 18px; height: 18px }
.workspace-path :is(summary, button):focus-visible { outline: 2px solid var(--focus); outline-offset: -2px }
.workspace-branch-value { display: flex; min-width: 0; max-width: 100%; overflow: hidden; white-space: nowrap }
.workspace-branch-value > span:first-child { min-width: 0; overflow: hidden; text-overflow: ellipsis }
.workspace-branch-value > span:last-child { flex-shrink: 0 }
Loaded 100 of 575 files, more files were not shown because too many files have changed in this diff. Show more