Merge branch 'studio' into fix/12900-adopted-hint

This commit is contained in:
YHH authored and GitHub committed 2026-10-17 14:34:21 -07:00
commit e1211b7715
696 files changed
+30090 -2317

No files matched your search

+6
View File
@@ -205,6 +205,12 @@ jobs:
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/...
# 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.
# Keep the session-temp portability contract covered without widening the
# leg to every tool test: two real PowerShell launches must share the
+80
View File
@@ -162,6 +162,20 @@ jobs:
node perf/narrow.mjs
node perf/settings-rows.mjs
node perf/narrow-steps.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/card-shell.mjs
node perf/line-cards.mjs
node perf/detail-panel.mjs
node perf/brand-lockup.mjs
node perf/notice-line.mjs
node perf/brought-line.mjs
node perf/modal-focus.mjs
node perf/focus-ring.mjs
@@ -170,6 +184,72 @@ jobs:
node perf/narrow-rail.mjs
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)
# 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
+1
View File
@@ -95,3 +95,4 @@ scripts/official-theme-art/__pycache__/
# running, which on Windows is every rebuild while Studio is open.
*.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
}
// A run whose final metrics never landed has no harness-side accounting. The
// report already has the state for that (Unaccounted) and swebench mode sets it;
// the suite path must not fold such a run in as an accounted zero, or every
// published total silently omits what it spent.
func TestSuiteRunWithoutFinalMetricsIsNotCountedAsAccounted(t *testing.T) {
// writeSilentAgentStub writes a stand-in that leaves neither a final record nor
// a snapshot: what it spent is simply gone, so nothing can bound it.
func writeSilentAgentStub(t *testing.T, dir string) string {
t.Helper()
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)
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})
if stats.unaccounted != 1 {
t.Fatalf("a run that wrote no final metrics counted as accounted:\n"+
" unaccounted=%d accounted=%d tokens=%d cost=%.4f\n"+
" (prompt_tokens=%d completion_tokens=%d, from the result itself)",
stats.unaccounted, stats.accounted, stats.pTok+stats.cTok, stats.cost,
r.PromptTokens, r.CompletionTokens)
if stats.unaccounted != 0 || stats.accounted != 1 || stats.partial != 1 {
t.Fatalf("a recovered run changed category:\n"+
" unaccounted=%d accounted=%d partial=%d", stats.unaccounted, stats.accounted, stats.partial)
}
if stats.pTok != 1200 || stats.cTok != 80 || stats.cost != 0.0123 {
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
// into <metrics>.partial. Swebench mode reads that sidecar; the suite path
// deletes the directory without looking, so the evidence is destroyed rather
// than being missing.
// into <metrics>.partial. Swebench mode reads that sidecar; the suite path must
// do the same rather than let the directory removal destroy the evidence.
func TestSuiteRunRecoversTheInFlightSnapshotItsChildLeft(t *testing.T) {
r := killedRun(t)
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
// whose harness-side number never landed has no side, so folding its meter
// tokens against a silent zero invents a divergence and blames the harness for
// it.
// The meter-vs-harness divergence line divides one side by the other. Whether a
// run has a harness side is the suite's Unaccounted state, not the token values:
// a run with no record at all has no side, and pricing it against the meter
// invents a divergence and blames the harness for it.
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})
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)
}
}
// 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**: "
parts := []string{}
regressed, withCorrect := 0, 0
regressed, withCorrect, split := 0, 0, 0
var roundsBefore, roundsAfter, verifyAfter, mutationsBefore []int64
for _, r := range results {
if len(r.Checkpoints) == 0 {
@@ -203,10 +203,16 @@ func renderSolveProfiles(results []result) string {
}
if r.FirstCorrectMs > 0 {
withCorrect++
roundsBefore = append(roundsBefore, int64(r.RoundsBeforeCorrect))
roundsAfter = append(roundsAfter, int64(r.RoundsAfterCorrect))
verifyAfter = append(verifyAfter, int64(r.VerifyAfterCorrect))
// Fold in only runs whose trajectory was read and split (Trajectory
// is set exactly when -trajectories ran): a run without one has no
// measurement, and its zeros are absence, not a zero.
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 {
regressed++
@@ -247,9 +253,15 @@ func renderSolveProfiles(results []result) string {
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",
median(mutationsBefore), median(roundsBefore), median(roundsAfter), median(verifyAfter),
pct(regressed, withCorrect))
if split == 0 {
// The split counts are absent; the checkpoint-derived ones are not.
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 {
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
unmeasured += r.Meter.WithoutUsage
injected += r.Meter.Injected
// A run whose metrics file never landed has no harness number, so it
// stays out of both sides of the comparison. A metered run whose
// harness reported no tokens at all is the same absence, not a ratio.
if r.Unaccounted || r.PromptTokens+r.CompletionTokens == 0 {
// Whether the harness has a side to compare is the suite's state, not a
// token count: Unaccounted has no record and Partial stops at its last
// snapshot, so neither side is whole. A persisted zero is a measurement.
if r.Unaccounted || r.Partial {
continue
}
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)
if err != nil {
// The leg left no record at all: its real spend is unknown, so the
// run is reported as unmeasured rather than priced at zero.
// The leg left no record at all: nothing survives to say what it
// spent, so the run is unmeasured rather than priced at zero.
r.Unaccounted = true
} else {
// A leg that finished wrote the final record; one that was killed
// only left the sidecar snapshot, so the account stops at the last
// write and the run's total is still incomplete.
r.Unaccounted = r.Unaccounted || partial
r.Partial = partial || !m.Complete
// A snapshot is real evidence, so the run stays accounted and only
// Partial says its numbers stop at the last write. It is sticky: a
// complete later leg cannot restore the tail an earlier one lost.
r.Partial = r.Partial || partial || !m.Complete
keepMetrics = keepMetrics || partial
foldSegmentMetrics(r, m, seg.index)
}
+97 -6
View File
@@ -50,6 +50,36 @@ func recordedMetricsPath(t *testing.T, work string) string {
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
// path: it stays accounted, keeps its real numbers and is still not partial.
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)
}
stats := gatherSuiteStats([]result{r})
if stats.unaccounted != 0 || stats.accounted != 1 {
t.Fatalf("a healthy run changed category: unaccounted=%d accounted=%d",
stats.unaccounted, stats.accounted)
if stats.unaccounted != 0 || stats.accounted != 1 || stats.partial != 0 {
t.Fatalf("a healthy run changed category: unaccounted=%d accounted=%d partial=%d",
stats.unaccounted, stats.accounted, stats.partial)
}
if stats.pTok != 900 || stats.cTok != 60 || stats.cost != 0.02 {
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",
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
// guard: a run the suite already marked unmeasured stays out of the ratio even
// when the harness number is present, so a partial account is not priced.
// A lower bound belongs to the run, not to its last leg: a complete second leg
// cannot restore the tail a killed first leg stopped short of, so once any leg
// 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) {
r := result{
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)
}
}
// 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
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",
"smoke": "electron test/smoke.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",
"windowstate-live": "electron test/windowstate_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 { installPaneReload } = require("./reload");
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
// pages whether or not the panel is open, and a page sized to nothing reads as
// a phone layout or not at all.
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
// value is .bview's border-radius in studio.css, whose rect the view fills.
const PAGE_RADIUS = 8;
@@ -30,6 +37,7 @@ class BrowserViews {
// for this run only: a restart, or a different certificate, asks again.
this.trusted = new Set();
this.shown = "";
this.keyboard = new KeyboardKeeper({ win });
// Moves with every show and hide, so a freeze that finishes after one does
// not put away a page shown since.
this.placed = 0;
@@ -106,26 +114,33 @@ class BrowserViews {
}
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);
let closed = false;
const close = () => {
if (closed) return;
closed = true;
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();
onClosed();
};
contents.once("destroyed", close);
contents.on("render-process-gone", close);
this.entries.set(targetId, entry);
this.win.contentView.addChildView(view);
this.putAway(view);
// The page loads before the window holds it: a page that finishes loading in a
// 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(() => {});
return {
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,
mainFrame: () => mainFrame,
};
@@ -155,19 +170,52 @@ class BrowserViews {
// 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
// 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);
entry.attached = true;
entry.view.setBounds(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() {
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.addChildView(entry.view);
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
@@ -255,8 +303,11 @@ class BrowserViews {
watchLoads(targetId, entry) {
const contents = entry.view.webContents;
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) => {
// -3 is ERR_ABORTED: a navigation replaced by another, or stopped. A
// 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;
}
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 { createRequire } from "node:module";
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 = {
on() {},
isDestroyed: () => false,
isFocused: () => false,
webContents: Object.assign(new EventEmitter(), { isFocused: () => false }),
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 } };
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 };
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({ 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();
}
});
+3 -1
View File
@@ -1,6 +1,8 @@
node_modules
dist
dist-perf
dist-perf*
*.png
!public/icons/*.png
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:
// the two surfaces would stack their shadows. Visibility under :hover is a styling question, so it is asked of a real browser.
// Workspace selection must stay reachable before and after a task, with no
// legacy hover card stacked over the open palette.
import { chromium } from "playwright";
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);
};
const browser = await chromium.launch();
const browser = await chromium.launch({ executablePath: process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE });
try {
const page = await (await browser.newContext({ locale: "zh-CN", viewport: { width: 1280, height: 800 } })).newPage();
await page.goto(BASE, { waitUntil: "networkidle" });
await page.waitForSelector(".app", { timeout: 20000 });
const chip = page.locator('.studio-branch-pop .studio-branch').first();
await chip.waitFor();
const card = page.locator(".studio-branch-card").first();
const shown = () => card.evaluate((el) => getComputedStyle(el).visibility === "visible" && Number(getComputedStyle(el).opacity) > 0.5);
await chip.hover();
await page.waitForTimeout(400);
check("the hover card shows over a closed chip", await shown());
await chip.click();
await page.waitForTimeout(500);
check("the branch menu opened", (await chip.getAttribute("aria-expanded")) === "true");
check("the hover card is gone while the menu is open", !(await shown()));
for (const turns of [0, 2]) {
const page = await browser.newPage({ locale: "zh-CN", viewport: { width: 1280, height: 800 }, reducedMotion: "reduce" });
const url = new URL(BASE);
url.searchParams.set("turns", String(turns));
await page.goto(url.href, { waitUntil: "networkidle" });
const chip = page.getByRole("button", { name: "配置工作目录", exact: true });
await chip.hover();
await chip.click();
const palette = page.getByRole("dialog", { name: "配置工作目录", exact: true });
await palette.waitFor();
check(`${turns} turns: the workspace palette opened`, await chip.getAttribute("aria-expanded") === "true");
const stacked = await page.locator(".studio-branch-card").evaluateAll((cards) => cards.some((card) => getComputedStyle(card).visibility === "visible" && Number(getComputedStyle(card).opacity) > 0.5));
check(`${turns} turns: no legacy hover card overlaps the palette`, !stacked);
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 {
await browser.close();
}
@@ -0,0 +1,53 @@
// 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.
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 browser = await chromium.launch();
let seen = null;
for (const scheme of ["light", "dark"]) {
for (const width of [1280, 700]) {
const ctx = await browser.newContext({ locale: "zh-CN", viewport: { width, height: 800 }, colorScheme: scheme });
const page = await ctx.newPage();
page.on("pageerror", (e) => fails.push("page error: " + e.message));
await page.goto(BASE, { waitUntil: "networkidle" });
await page.waitForSelector(".studio-brand-lockup");
await page.waitForTimeout(500);
const m = await page.evaluate(() => {
const logo = document.querySelector(".studio-brand-logo").getBoundingClientRect();
const sub = document.querySelector(".studio-brand-sub");
const r = sub.getBoundingClientRect();
const cs = getComputedStyle(sub);
return {
shown: r.width > 0,
left: r.left,
top: r.top,
dy: Math.abs(logo.top + logo.height / 2 - (r.top + r.height / 2)),
after: r.left >= logo.right - 1,
fill: cs.backgroundColor, ink: cs.color, border: cs.borderTopWidth, radius: cs.borderTopLeftRadius, upper: cs.textTransform, text: sub.textContent,
};
});
const tag = `${scheme} ${width}px`;
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}: 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 };
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 {
check(`${tag}: the badge stays hidden on a narrow window`, !m.shown);
}
await ctx.close();
}
}
await browser.close();
if (fails.length) {
console.log(`\n${fails.length} failed`);
process.exit(1);
}
console.log("\nall ok");
+64
View File
@@ -0,0 +1,64 @@
// Cards the reader reads or acts on share one shell: the same radius, border and ground, and a header with no fill of its
// own. A kind that draws its own (a different radius, a state-tinted border, a filled strip) reads as another family.
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 browser = await chromium.launch();
for (const scheme of ["light", "dark"]) {
const ctx = await browser.newContext({ locale: "zh-CN", viewport: { width: 1000, height: 900 }, colorScheme: scheme });
const page = await ctx.newPage();
page.on("pageerror", (e) => fails.push("page error: " + e.message));
await page.goto(BASE, { waitUntil: "networkidle" });
await page.waitForSelector(".app");
await page.waitForTimeout(600);
await page.evaluate(() => {
const f = window.__feed;
f({ kind: "turn_started", authoredTurn: 1, msgIndex: 0, text: "跑一遍" });
f({ kind: "text", text: "我来处理。" });
f({ kind: "tool_dispatch", tool: { id: "rm", name: "remember", args: '{}' } });
f({ kind: "tool_result", tool: { id: "rm", name: "remember", output: "saved", args: JSON.stringify({ name: "n", title: "标题", description: "一句话", scope: "project", activation: "always", body: "正文" }) } });
f({ kind: "guardian_assessment", guardian: { id: "gd", tool: "bash", subject: "rm -rf /", outcome: "deny", risk_level: "critical", user_authorization: "unknown", rationale: "越界", duration_ms: 900 } });
f({ kind: "compaction_done", compaction: { trigger: "capacity", messages: 40, summary: "折叠了前 40 条。", sourceTokens: 90000, projectionTokens: 6000, coverageRequired: 6, coverageMissing: 2, boundary: "capacity", triggerTokens: 90000 } });
f({ kind: "extension_surface", extension: { pluginId: "cov", surfaceId: "c", kind: "card", card: { title: "覆盖率", text: "78.4%", fields: [{ key: "语句", value: "78.4%" }], progress: 0.78, actions: [{ actionId: "o", label: "打开" }] } } });
f({ kind: "turn_done", receipt: { verdict: "partial", changes: [{ path: "a.go", reviewed: true }], verifications: [{ command: "go test ./...", passed: false, stale: true }], gaps: [{ kind: "unverified_mutation", detail: "改了没跑" }], risks: [], unverified: [], saysSomething: true } });
f({ kind: "turn_started" });
f({ kind: "approval_request", approval: { id: "a1", tool: "bash", subject: "rm -rf build && make" } });
f({ kind: "ask_request", ask: { id: "q1", questions: [{ id: "q", header: "选一个", prompt: "走哪条路", options: [{ label: "方案 A" }, { label: "方案 B" }] }] } });
});
await page.waitForTimeout(800);
await page.mouse.move(995, 5);
const rows = await page.evaluate(() =>
[...document.querySelectorAll(".flow .call")]
.filter((c) => ["mem", "host", "extension"].includes(c.getAttribute("data-k")) || c.hasAttribute("data-prompt"))
.map((c) => {
const cs = getComputedStyle(c.querySelector(":scope > .c"));
const hl = c.querySelector(":scope > .c > .hl");
return {
kind: c.getAttribute("data-k"),
radius: cs.borderTopLeftRadius,
border: cs.borderTopColor,
bg: cs.backgroundColor,
head: hl ? getComputedStyle(hl).backgroundColor : "rgba(0, 0, 0, 0)",
};
}));
check(`${scheme}: four kinds of card found`, rows.length === 4, rows.map((r) => r.kind).join(","));
const same = (key) => new Set(rows.map((r) => r[key])).size === 1;
check(`${scheme}: one radius`, rows.length > 0 && same("radius"), [...new Set(rows.map((r) => r.radius))].join(" / "));
check(`${scheme}: one border colour, whatever the state`, rows.length > 0 && same("border"), [...new Set(rows.map((r) => r.border))].join(" / "));
check(`${scheme}: one ground`, rows.length > 0 && same("bg"), [...new Set(rows.map((r) => r.bg))].join(" / "));
check(`${scheme}: no header fill`, rows.length > 0 && rows.every((r) => r.head === "rgba(0, 0, 0, 0)"), [...new Set(rows.map((r) => r.head))].join(" / "));
await ctx.close();
}
await browser.close();
if (fails.length) {
console.log(`\n${fails.length} failed`);
process.exit(1);
}
console.log("\nall ok");
+6
View File
@@ -101,6 +101,12 @@ check("回执没有重复根容器", await page.locator(".rc > .rc").count() ===
// between any two glyphs, so it is the one that must not give up its width.
const rc = await browser.newPage({ viewport: { width: 1440, height: 900 }, locale: "zh-CN", colorScheme: "dark" });
rc.on("pageerror", (e) => fails.push("页面异常: " + e.message));
await rc.addInitScript(() => {
try {
localStorage.setItem("rx-turn-receipt", "on");
} catch {
}
});
await rc.goto(PAGE, { waitUntil: "networkidle" });
await rc.waitForSelector(".compose");
await rc.evaluate(() => {
@@ -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.
import { chromium } from "playwright";
@@ -22,9 +22,11 @@ try {
const range = document.createRange();
range.selectNodeContents(projEl);
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`);
const long = await page.evaluate(() => {
document.querySelector(".chrome .crumb-proj").textContent = "a-very-long-project-folder-name-".repeat(8);
@@ -0,0 +1,46 @@
// An opened step shows its detail as a panel in place, bordered on all four sides by one hairline. A rule on one side only
// is a rail, and the transcript has no rails.
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 browser = await chromium.launch();
for (const scheme of ["light", "dark"]) {
const ctx = await browser.newContext({ locale: "zh-CN", viewport: { width: 1000, height: 900 }, colorScheme: scheme });
const page = await ctx.newPage();
page.on("pageerror", (e) => fails.push("page error: " + e.message));
await page.goto(BASE, { waitUntil: "networkidle" });
await page.waitForSelector(".app");
await page.waitForTimeout(600);
await page.evaluate(() => {
const f = window.__feed;
f({ kind: "turn_started", authoredTurn: 1, msgIndex: 0, text: "go" });
f({ kind: "tool_dispatch", tool: { id: "t1", name: "bash", args: '{"command":"go test ./..."}' } });
f({ kind: "tool_result", tool: { id: "t1", name: "bash", args: '{"command":"go test ./..."}', output: "ok\n", durationMs: 900 } });
f({ kind: "text", text: "done" });
f({ kind: "turn_done" });
});
await page.waitForTimeout(500);
await page.evaluate(() => document.querySelectorAll("details").forEach((d) => (d.open = true)));
await page.waitForTimeout(400);
await page.mouse.move(995, 5);
const m = await page.evaluate(() =>
[...document.querySelectorAll(".flow .tool-disclosure > .out")].map((el) => {
const cs = getComputedStyle(el);
return { sides: [cs.borderTopWidth, cs.borderRightWidth, cs.borderBottomWidth, cs.borderLeftWidth].join(","), color: [cs.borderTopColor, cs.borderRightColor, cs.borderBottomColor, cs.borderLeftColor] };
}));
check(`${scheme}: an opened step has a detail panel`, m.length > 0, `${m.length} found`);
check(`${scheme}: it is bordered on all four sides`, m.length > 0 && m.every((x) => x.sides === "1px,1px,1px,1px" && new Set(x.color).size === 1), JSON.stringify(m[0] ?? {}));
await ctx.close();
}
await browser.close();
if (fails.length) {
console.log(`\n${fails.length} failed`);
process.exit(1);
}
console.log("\nall ok");
+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);
}
+86 -5
View File
@@ -30,6 +30,9 @@ import "../src/styles/gitgraph.css";
import "../src/styles/tips.css";
import "../src/styles/waitnote.css";
import "../src/styles/bring.css";
import "../src/styles/peerthreads.css";
import "../src/styles/line.css";
import "../src/styles/notice.css";
import "../src/styles/peers.css";
import "../src/styles/jsontext.css";
import "../src/styles/skillpin.css";
@@ -47,7 +50,7 @@ import { App } from "../src/ui/App";
import { MockHub } from "../src/port/mock_hub";
import { MockPort } from "../src/port/mock";
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 { ThemePack } from "../src/port/look";
import type { RuntimeView, TreeWorkspace } from "../src/port/hub";
@@ -92,18 +95,54 @@ function benchPack(): ThemePack | null {
}
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.
protected async answerCheck(name: string) {
if (query.get("checkfail") !== name) return super.answerCheck(name);
return { ok: false, code: "provider.probe.unauthorized", httpStatus: 401, detail: "Invalid Authentication" };
}
// ?badprice=1: DeepSeek's flash price is a card the vendor's table does not have,
// under a pinned display currency.
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 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();
return [{ id: "t1", target: "view-a", url: "https://example.com/docs/install", title: "Install guide", active: true }];
}
async workspaceFile(path: string) {
// ?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);
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" };
@@ -149,6 +188,15 @@ class BenchPort extends MockPort {
}
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();
return {
enabled: true,
@@ -157,6 +205,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() {
return { ...(await super.foldWorkSettings()), fold: query.has("fold") };
}
@@ -202,13 +269,16 @@ class BenchPort extends MockPort {
id: `seed-${i}`,
// Lines the person queued: the panel leaves out the host's own queued
// 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,
...(QUEUE_IMAGES && i === 0 ? { images: true } : {}),
// 三种长度,各有各的对照:第一条没被截断(不该弹),第二条长到撞天花板
// (浮层要自己滚),其余是寻常的被截断行。内核把 preview 截到 120 runes,
// 所以第二条在队列里到不了 —— 但 .ovf 是通用的,天花板得有人证明它在。
preview:
i === 0
QUEUE_IMAGES && i === 0
? "@.reasonix/attachments/clipboard-20261017-141249.png reasonix 的你改好了?我这边看到的还是旧界面"
: i === 0
? "bash-1 — failed"
: i === 1
? `bash-2 — failed: ${"error[E0308]: mismatched types in the trait implementation; ".repeat(14)}`
@@ -232,13 +302,22 @@ class BenchPort extends MockPort {
private readonly jobsSince = Date.now() - 739_000;
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 (!JOBS) return st;
const jobs = Array.from({ length: JOBS }, (_, i) => ({
id: `job-${i}`,
kind: "bash",
label: i === 0 ? "npm run tauri:test 2>&1 | Select-Object -Last 40" : `go test ./internal/... -run Case${i}`,
// ?jobnames=1 gives each job the name its caller wrote and the full command.
...(query.has("jobnames") ? {
description: i === 0 ? "运行桌面端测试并只看最后 40 行输出" : `跑第 ${i} 组内核测试`,
detail: i === 0 ? "npm run tauri:test 2>&1 | Select-Object -Last 40" : `go test ./internal/... -run Case${i} -count=1 -timeout 10m`,
} : {}),
status: this.stopped.has(`job-${i}`) ? "killed" : "running",
startedAt: this.jobsSince,
}));
@@ -305,6 +384,8 @@ const SWITCH_MS = Number(query.get("switchms") ?? 0);
// ?queue= is how many lines are already waiting when the window opens.
const QUEUE = Number(query.get("queue") ?? 0);
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);
// ?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.
+52
View File
@@ -0,0 +1,52 @@
// A background job is named for a person: the list shows the name its caller gave it, and the whole name and the full command
// appear in a tip that stays inside the window, can be selected, and opens from the keyboard as well as the pointer.
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 browser = await chromium.launch();
for (const scheme of ["light", "dark"]) {
for (const [width, height] of [[1280, 860], [400, 760]]) {
const ctx = await browser.newContext({ locale: "zh-CN", viewport: { width, height }, colorScheme: scheme });
const page = await ctx.newPage();
page.on("pageerror", (e) => fails.push("page error: " + e.message));
await page.goto(`${BASE}?jobs=3&jobnames=1`, { waitUntil: "networkidle" });
await page.waitForSelector(".app");
await page.waitForTimeout(800);
await page.evaluate(() => [...document.querySelectorAll("button")].find((b) => /后台任务/.test(b.textContent))?.click());
await page.waitForSelector(".jobs .job .cmd");
const tag = `${scheme} ${width}px`;
const shown = await page.evaluate(() => [...document.querySelectorAll(".jobs .job .cmd")].map((e) => e.textContent));
check(`${tag}: the list shows the names, not the commands`, shown.length === 3 && shown.every((t) => !/go test|npm run/.test(t)), shown.join(" | "));
await page.locator(".jobs .job .cmd").first().hover();
await page.waitForSelector(".job-tip");
const tip = await page.evaluate(() => {
const el = document.querySelector(".job-tip");
const r = el.getBoundingClientRect();
return { inside: r.left >= 0 && r.top >= 0 && r.right <= innerWidth + 1 && r.bottom <= innerHeight + 1, lines: el.children.length, select: getComputedStyle(el).userSelect, cmd: el.querySelector(".jt-cmd")?.textContent ?? "" };
});
check(`${tag}: the tip has the name and the full command, inside the window`, tip.inside && tip.lines === 2 && /npm run tauri:test/.test(tip.cmd), JSON.stringify(tip));
check(`${tag}: its text can be selected`, tip.select === "text", tip.select);
await page.mouse.move(width - 5, 5);
await page.waitForTimeout(200);
check(`${tag}: it closes when the pointer leaves`, (await page.locator(".job-tip").count()) === 0);
await page.locator(".jobs .job .cmd").nth(1).focus();
await page.waitForSelector(".job-tip");
check(`${tag}: focus from the keyboard opens it too`, /内核测试/.test(await page.locator(".job-tip .jt-name").textContent()));
await page.keyboard.press("Escape");
await page.waitForTimeout(150);
check(`${tag}: Escape closes it`, (await page.locator(".job-tip").count()) === 0);
await ctx.close();
}
}
await browser.close();
if (fails.length) {
console.log(`\n${fails.length} failed`);
process.exit(1);
}
console.log("\nall ok");
+55
View File
@@ -0,0 +1,55 @@
// An allowed guardian review at low risk and a remembered fact are Lines, not cards: one row over a hairline with no box.
// A refusal, or an allow that carried real risk, keeps the card.
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 browser = await chromium.launch();
for (const scheme of ["light", "dark"]) {
const ctx = await browser.newContext({ locale: "zh-CN", viewport: { width: 1000, height: 900 }, colorScheme: scheme });
const page = await ctx.newPage();
page.on("pageerror", (e) => fails.push("page error: " + e.message));
await page.goto(BASE, { waitUntil: "networkidle" });
await page.waitForSelector(".app");
await page.waitForTimeout(600);
await page.evaluate(() => {
const f = window.__feed;
f({ kind: "turn_started", authoredTurn: 1, msgIndex: 0, text: "go" });
f({ kind: "tool_dispatch", tool: { id: "rm", name: "remember", args: "{}" } });
f({ kind: "tool_result", tool: { id: "rm", name: "remember", output: "saved", args: JSON.stringify({ name: "n", title: "A fact", description: "why", scope: "project", activation: "always", body: "b" }) } });
f({ kind: "guardian_assessment", guardian: { id: "g1", tool: "bash", subject: "go test", outcome: "allow", risk_level: "low", user_authorization: "high", rationale: "read only", duration_ms: 5 } });
f({ kind: "guardian_assessment", guardian: { id: "g2", tool: "bash", subject: "git push -f", outcome: "allow", risk_level: "high", user_authorization: "low", rationale: "rewrites history", duration_ms: 5 } });
f({ kind: "guardian_assessment", guardian: { id: "g3", tool: "bash", subject: "rm -rf /", outcome: "deny", risk_level: "critical", user_authorization: "unknown", rationale: "no", duration_ms: 5 } });
f({ kind: "turn_done" });
});
await page.waitForTimeout(600);
await page.mouse.move(995, 5);
const m = await page.evaluate(() => {
const rows = [...document.querySelectorAll(".flow .line-row")];
const box = (el) => { const cs = getComputedStyle(el); return [cs.borderTopWidth, cs.borderLeftWidth, cs.borderRightWidth].join(","); };
return {
lines: rows.length,
boxes: rows.map(box),
radius: rows.map((r) => getComputedStyle(r).borderTopLeftRadius),
cards: [...document.querySelectorAll('.flow .call[data-k="host"]')].length,
forget: !!document.querySelector('.flow .line-row [data-action="memory.forget"]'),
oldMem: document.querySelectorAll('.flow .call[data-k="mem"]').length,
};
});
check(`${scheme}: the remembered fact and the low-risk allow are rows`, m.lines === 2, `${m.lines} rows`);
check(`${scheme}: rows carry no box`, m.lines > 0 && m.boxes.every((b) => b === "0px,0px,0px") && m.radius.every((r) => r === "0px"), JSON.stringify(m));
check(`${scheme}: the high-risk allow and the refusal keep their cards`, m.cards === 2, `${m.cards} cards`);
check(`${scheme}: the take-back sits in the row, and no memory card remains`, m.forget && m.oldMem === 0);
await ctx.close();
}
await browser.close();
if (fails.length) {
console.log(`\n${fails.length} failed`);
process.exit(1);
}
console.log("\nall ok");
@@ -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,50 @@
// A host notice is a bar, not a card: one sentence with a glyph, no header strip, no box and no state wash. Severity is
// the glyph's ink, so warn and error differ only there.
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 browser = await chromium.launch();
for (const scheme of ["light", "dark"]) {
const ctx = await browser.newContext({ locale: "zh-CN", viewport: { width: 1000, height: 760 }, colorScheme: scheme });
const page = await ctx.newPage();
page.on("pageerror", (e) => fails.push("page error: " + e.message));
await page.goto(BASE, { waitUntil: "networkidle" });
await page.waitForSelector(".app");
await page.waitForTimeout(600);
await page.evaluate(() => {
window.__feed({ kind: "turn_started", authoredTurn: 1, msgIndex: 0, text: "go" });
for (const level of ["info", "warn", "error"]) window.__feed({ kind: "notice", level, text: `a ${level} notice`, detail: "the diagnostic" });
window.__feed({ kind: "turn_done" });
});
await page.waitForTimeout(500);
await page.mouse.move(990, 10);
const m = await page.evaluate(() => {
const read = (el) => {
const cs = getComputedStyle(el);
const g = el.querySelector(".nb-ic, .nb-dot");
return {
border: [cs.borderTopWidth, cs.borderLeftWidth, cs.borderRightWidth, cs.borderBottomWidth].join(","),
bg: cs.backgroundColor,
header: !!el.querySelector(".hl, .tag"),
glyph: g ? getComputedStyle(g).color : "",
};
};
return [...document.querySelectorAll(".notice-bar")].map(read);
});
check(`${scheme}: three notices drawn as bars`, m.length === 3, `${m.length} found`);
check(`${scheme}: no box, wash or header strip`, m.length > 0 && m.every((x) => x.border === "0px,0px,0px,0px" && (x.bg === "rgba(0, 0, 0, 0)" || x.bg === "transparent") && !x.header), JSON.stringify(m[0] ?? {}));
check(`${scheme}: severity is the glyph ink alone`, m.length === 3 && new Set(m.map((x) => x.glyph)).size === 3, m.map((x) => x.glyph).join(" / "));
await ctx.close();
}
await browser.close();
if (fails.length) {
console.log(`\n${fails.length} failed`);
process.exit(1);
}
console.log("\nall ok");
@@ -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);
}
+81
View File
@@ -0,0 +1,81 @@
// The side question opens from the button in the composer, above the composer
// 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.
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 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 box = (el) => (el ? (({ left, top, right, bottom }) => ({ left, top, right, bottom }))(el.getBoundingClientRect()) : null);
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 [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}x${height} zoom ${zoom}${files ? " with the file panel" : ""}`;
const page = await browser.newPage({ viewport: { width, height }, locale: "zh-CN", colorScheme: scheme });
page.on("pageerror", (e) => fails.push("页面异常: " + e.message));
await page.goto(`${PAGE}&zoom=${zoom}`, { waitUntil: "networkidle" });
await page.waitForSelector(".compose");
if (files) {
await page.locator('.navbtn[aria-label="文件"]').first().click();
await page.waitForSelector(".workbench-filelist button");
}
await page.locator('[data-action="side.toggle"]').click();
await page.waitForSelector(".sidepanel");
await page.waitForTimeout(250);
const r = await page.evaluate(() => {
const box = (el) => (el ? (({ left, top, right, bottom }) => ({ left, top, right, bottom }))(el.getBoundingClientRect()) : null);
const panel = document.querySelector(".sidepanel");
const compose = panel.closest(".compose");
const pane = compose.closest(".pane");
const beside = [...document.querySelectorAll(".workbench-filelist, .workbench-explorer-head")].filter((el) => !pane.contains(el)).map(box);
return { panel: box(panel), compose: box(compose), pane: box(pane), beside, vw: innerWidth, vh: innerHeight, button: box(document.querySelector('[data-action="side.toggle"]')) };
});
const p = r.panel;
check(`${label}: it sits above the composer`, p.bottom <= r.compose.top + 1 && r.compose.top - p.bottom < 24, `bottom ${Math.round(p.bottom)}, composer top ${Math.round(r.compose.top)}`);
check(`${label}: it stays inside its pane`, p.left >= r.pane.left - 1 && p.right <= r.pane.right + 1 && p.top >= r.pane.top - 1, `panel ${Math.round(p.left)}–${Math.round(p.right)}, pane ${Math.round(r.pane.left)}–${Math.round(r.pane.right)}`);
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 opens above the button that asked`, p.bottom <= r.button.top && p.left <= r.button.left + 1, `button ${Math.round(r.button.left)}`);
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 browser.close();
if (fails.length) {
console.log(`\n${fails.length} failed`);
process.exit(1);
}
+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);
}
+10
View File
@@ -1,2 +1,12 @@
# The shell names the build that is current, and the page's own out-of-date
# check reads this same file over no-store. A cached or revalidated copy makes
# an open page look behind and keeps reloading onto it. Hashed bundles under
# /assets are the opposite: they are named by content, so they never change.
/
Cache-Control: no-store
/index.html
Cache-Control: no-store
/assets/*
Cache-Control: public, max-age=31536000, immutable
+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);
+19
View File
@@ -63,11 +63,15 @@ export const ACTIONS: UIAction[] = [
{ id: "appearance.live-grace", kind: "view", target: "none", proof: "interaction" },
{ id: "appearance.legacy-sessions", kind: "view", target: "none", proof: "interaction" },
{ id: "appearance.open-session-limit", kind: "view", target: "none", proof: "interaction" },
{ id: "appearance.live-stream-budget", kind: "view", target: "none", proof: "interaction" },
{ id: "appearance.session-quick-actions", kind: "view", target: "none", proof: "interaction" },
{ id: "appearance.graphics", 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: "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-navigate", kind: "view", target: "none", proof: "interaction" },
{ id: "workspace.source-select", kind: "view", target: "none", proof: "interaction" },
@@ -144,6 +148,7 @@ export const ACTIONS: UIAction[] = [
{ id: "tool.stop", kind: "kernel-mutation", target: "entity", proof: "interaction" },
{ id: "session.open-match", kind: "navigation", target: "entity", proof: "interaction" },
{ id: "job.cancel", kind: "kernel-mutation", target: "entity", proof: "authority-effect" },
{ id: "job.tip", kind: "view", target: "entity", proof: "interaction" },
{ id: "job.view", kind: "view", target: "entity", proof: "interaction" },
{ id: "job.close", kind: "view", target: "none", proof: "interaction" },
{ id: "plan.mode", kind: "kernel-mutation", target: "none", proof: "interaction" },
@@ -196,6 +201,9 @@ export const ACTIONS: UIAction[] = [
{ id: "mcp.enabled", kind: "kernel-mutation", 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.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.load", kind: "kernel-mutation", target: "entity", proof: "interaction" },
{ id: "mcp.tool-search", kind: "view", target: "entity", proof: "interaction" },
@@ -235,6 +243,7 @@ export const ACTIONS: UIAction[] = [
{ id: "feedback.contact", 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: "side.collapse", 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.unquote", kind: "view", target: "none", proof: "interaction" },
@@ -407,6 +416,7 @@ export const ACTIONS: UIAction[] = [
// 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
// 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: "remote.open", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
// Asking a machine whether it answers. Nothing on either side changes, and
@@ -541,7 +551,12 @@ export const ACTIONS: UIAction[] = [
// 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.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.text-size", 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.font", kind: "kernel-mutation", target: "entity", proof: "authority-effect" },
@@ -640,6 +655,10 @@ export const ACTIONS: UIAction[] = [
{ id: "peers.expand", kind: "view", target: "none", proof: "interaction" },
{ 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-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" },
// Read by the running turn at its next round, so no rebuild stands between the
// click and the canonical change.
+65 -2
View File
@@ -63,6 +63,7 @@ export const EN: Record<string, string> = {
"收回 {rule}": "Take back {rule}",
"放大第 {n} 张": "Enlarge image {n}",
"工具截图 {n}": "Tool screenshot {n}",
"视频 {name}({detail})已原生发送给 {model}": "Video {name} ({detail}) sent to {model} as a video file",
"查看图片 {name}": "Preview image {name}",
"关闭图片预览": "Close image preview",
"放大的工具截图": "The enlarged tool screenshot",
@@ -162,7 +163,6 @@ export const EN: Record<string, string> = {
"已完成": "Done",
"已中断": "Interrupted",
"未执行": "Not run",
"已停止": "Stopped",
"请求未能送达内核(HTTP {status})": "That request never reached the kernel (HTTP {status})",
"移动 {id} 的界面": "Move {id}'s view",
"等待批准": "Waiting for approval",
@@ -209,6 +209,58 @@ export const EN: Record<string, string> = {
"改为输入路径": "Type a path instead",
"输入文件夹路径": "Type the folder's path",
"界面大小": "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",
"尚未派出": "None dispatched",
"运行中 {live} / 共 {total}": "{live} running / {total} total",
@@ -354,6 +406,8 @@ export const EN: Record<string, string> = {
"读取代码、运行测试与修改文件均只在你选定的文件夹内进行。": "Reading code, running tests and editing files all happen inside the folder you choose.",
"使用当前位置": "Stay where I am",
"隔离": "Isolated",
"原目录": "Local checkout",
"隔离目录": "Worktree",
"移除": "Remove",
"删除": "Delete",
"删除该会话": "Delete this session",
@@ -398,6 +452,7 @@ export const EN: Record<string, string> = {
"暂停派发": "Hold",
"继续派发": "Release",
"排队": "Queued",
"含图片,本轮结束后发送": "Has an image: sent when this turn ends",
"插话排队": "Steer queued",
"后台任务续接": "Background job follow-on",
"插话已收": "Steer accepted",
@@ -437,7 +492,9 @@ export const EN: Record<string, string> = {
"更大": "Larger",
"小": "Small",
"大": "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",
"等宽": "Monospace",
"清除": "Clear",
@@ -615,6 +672,7 @@ export const EN: Record<string, string> = {
"文件管理器": "File manager",
"终端": "Terminal",
"在系统文件管理器中显示此文件": "Show this file in the system file manager",
"只读 · 来自你授权的文件夹": "Read-only · from a folder you granted",
"重命名页签": "Rename tab",
"显示隐藏文件": "Show hidden files",
"在访达中显示": "Reveal in Finder",
@@ -874,6 +932,7 @@ export const EN: Record<string, string> = {
"无法读取登录状态": "Sign-in status could not be read",
"登录状态不可用": "Sign-in status unavailable",
"上次的登录已过期。": "The previous sign-in has expired.",
"这次登录请求已过期,再试一次。": "This sign-in request expired. Try again.",
"只清除本地登录凭证,不影响会话、记忆和配置":
"Clears the local credential only; sessions, memory and config are untouched",
"用于社区发帖与崩溃问题跟进。与模型 API key 无关 —— 登录不会上传你的对话、代码或密钥。":
@@ -1195,6 +1254,8 @@ export const EN: Record<string, string> = {
"工作区空出来了,这个会话已经继续": "The workspace is free again; this session has continued",
"一个 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)",
"你选择了跟随的一个 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",
// ── 宿主通知卡片自己的头部(NoticeCard)───────────────────────
@@ -1557,6 +1618,8 @@ export const EN: Record<string, string> = {
"后台任务已结束:{name}": "Background job finished: {name}",
"后台任务已终止:{name}": "Background job killed: {name}",
"后台任务 {name} 失败,需要处理": "Background job {name} failed: needs attention",
"后台任务 {name} 已有 {quiet} 没往自己的输出里写任何内容:可能是在写文件,也可能卡住了;可以用 wait 或 bash_output 查看,或用 kill_shell 结束它":
"Background job {name} has written nothing to its own output for {quiet}: it may be writing to a file, or stuck; check it with wait or bash_output, or stop it with kill_shell",
"被信号 {signal} 终止": "Ended by signal {signal}",
"退出码 {code}": "Exit code {code}",
"已恢复 {n} 条未完成的指令。待发送已暂停,请先在输入框上方的队列里查看,再点“继续派发”": "Recovered {n} unfinished instruction(s). Sending is paused: review them in the queue above the input box, then press Release",
+78 -1
View File
@@ -1,10 +1,74 @@
// The turn starts here: drafting, attachments, run posture, and the two actions
// that remain available while an agent is live.
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",
"独立 worktree · {branch}": "New worktree · {branch}",
"独立 worktree · {branch}": "Isolated worktree · {branch}",
"当前目录 · {branch}": "Current folder · {branch}",
"新建 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",
"起始分支": "Source branch",
"任务分支": "Task branch",
@@ -14,6 +78,19 @@ export const EN_COMPOSER: Record<string, string> = {
"选择起始分支": "Choose source branch",
"搜索分支": "Search branches",
"搜索分支…": "Search branches…",
"当前目录": "Current folder",
"查看完整路径": "Show full path",
"复制完整路径": "Copy full path",
"复制操作编号": "Copy operation ID",
"任务分支自动创建": "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",
"当前分支": "Current branch",
"没有匹配的分支": "No matching branches",
+30 -2
View File
@@ -78,9 +78,21 @@ export const EN_KERNEL: Record<string, string> = {
"What this server launches changed since you enabled it; review the command and enable it again",
"这个服务没有被暂挡的工具定义": "This server has no held tool definitions",
"你看到的被挡定义已经变了,没有接受;请重新查看": "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",
// 待送达:条目、队列、这份存档各自会拒
"该条已不在待送达队列中": "That entry is no longer in the pending queue",
"事件流连接已经关闭,需要重新打开并重新订阅各窗格": "The event stream has closed; open a new one and subscribe each pane again",
"这个窗格由另一台机器驱动,使用它自己的事件流": "This pane is driven from another machine and keeps its own event stream",
"这个后台任务已经不在运行了": "That background job is no longer running",
"这条调用已经结束了": "This call has already finished",
"你停止了这条调用,它在外部已经做了什么无法确定": "You stopped this call; what it had already done outside the workspace is unknown",
@@ -95,6 +107,7 @@ export const EN_KERNEL: Record<string, string> = {
"该条没有正文": "That entry has no body",
"该会话的待送达已关闭": "This session's pending queue is closed",
"该待送达由更高版本写入,当前版本只能读取": "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",
// 配置文件本身坏了,以及每一个写设置的面板被它挡住时
"配置文件 {path} 无法读取,因此本次未保存。到「设置」里可以看到出错的位置并修复": "The config file {path} does not parse, so nothing was saved. Open Settings to see where it is broken and repair it",
@@ -331,7 +344,7 @@ export const EN_KERNEL: Record<string, string> = {
"这个会话不支持辅助提问": "Side questions are not available in this session",
"这个辅助提问已经关闭,请重新打开": "That side question is closed; open it again",
"上一个问题还在回答,请稍候再问": "The previous question is still being answered; wait a moment",
"同时打开的辅助提问太多,请先关闭几个": "Too many side questions are open; close some first",
"同时打开的辅助提问已到你设置的上限(agent.max_side_sessions),请先关闭几个": "The side questions open at once have reached your limit (agent.max_side_sessions); close some first",
"会话间消息未开启": "Messages between sessions are not turned on",
"这个客户端不能收发会话间消息": "This client cannot send or decide messages between sessions",
"本机没有叫这个名字的会话": "No session on this machine has that name",
@@ -343,6 +356,9 @@ export const EN_KERNEL: Record<string, string> = {
"这条消息太长": "That message is too long",
"这条消息已经处理过了": "That message was already decided",
"会话间消息没能完成:{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",
"请先写下要问的内容": "Write the question first",
"辅助提问没能完成,请重试": "The side question could not be completed; try again",
@@ -426,7 +442,7 @@ export const EN_KERNEL: Record<string, string> = {
"这份备份已不存在": "That backup no longer exists",
"备份数量已达上限,请先删除一份旧备份": "The backup limit is reached; delete an older backup first",
"有会在本机运行程序或改变密钥去向的项目,需要逐项确认后才能恢复": "Some items run programs here or change where a key is sent; confirm each one before restoring",
"项目列表已满(32 个),请先移除一个项目再添加": "The project list is full (32 projects); remove one before adding another",
"项目列表已到你设置的上限(desktop.max_workspaces),请先移除一个项目再添加": "The project list is at the limit you set (desktop.max_workspaces); remove one before adding another",
"{model} 的价格必须是不小于 0 的数字,并选择货币": "The price for {model} must be a number not below 0, with a currency chosen",
"{model} 不在已选模型中,未保存价格": "{model} is not one of the selected models, so no price was saved",
"这台服务器不支持切换工作区": "This server cannot switch workspaces",
@@ -518,6 +534,10 @@ export const EN_KERNEL: Record<string, string> = {
"文件改动显示设置未能保存:{detail}": "The file-changes display setting was not saved: {detail}",
"折叠中间过程的设置未能保存:{detail}": "The fold-the-work 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 question wait time was not saved: {detail}",
"等待时长超出允许范围,未做任何修改:{detail}": "That wait time is out of range; nothing was changed: {detail}",
@@ -549,6 +569,14 @@ export const EN_KERNEL: Record<string, string> = {
"The chosen search model is no longer in the configuration, so search is off; choose one again in Model preferences, or set it back to automatic",
"所选搜索模型的协议不支持原生搜索,或已关闭搜索,搜索已关闭;到模型偏好里重新选择,或恢复自动":
"The chosen search model's protocol does not support native search, or search was turned off, so search is off; choose one again in Model preferences, or set it back to automatic",
"网页抓取后端没有返回内容,已换用链里的下一个后端;详情见通知":
"A fetch backend did not answer; the next one in the chain was tried. See the notice for details",
"配置的网页抓取后端无法使用,已跳过;检查用户配置里的 web_fetch_backends":
"A configured fetch backend cannot be used and was skipped; check web_fetch_backends in your user config",
"搜索后端没有返回结果,已换用链里的下一个后端;详情见通知":
"A search backend did not answer; the next one in the chain was tried. See the notice for details",
"配置的搜索后端无法使用,已跳过;检查用户配置里的 web_search_backends 和 searxng_url":
"A configured search backend cannot be used and was skipped; check web_search_backends and searxng_url in your user config",
"所选搜索模型所在的连接没有凭据,搜索已关闭;到模型偏好里重新选择,或恢复自动":
"The connection of the chosen search model has no credentials, so search is off; choose one again in Model preferences, or set it back to automatic",
"配置里的默认审批档位本版本不认识,已按每次询问处理;在界面里选一个档位即可替换":
+20 -1
View File
@@ -6,6 +6,10 @@ export const EN_SETTINGS: Record<string, string> = {
"来自 {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.",
"批准并交给模型": "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",
"还有 {n} 条等你决定": "{n} more waiting for your decision",
"无法读取会话间消息的状态。": "Could not read the state of messages between sessions.",
@@ -21,6 +25,8 @@ export const EN_SETTINGS: Record<string, string> = {
"浏览器标签": "Browser tabs",
"辅助会话": "Side question",
"只读 · 不写入主会话 · 关闭即丢": "Read-only · not in the main session · gone when closed",
"收起辅助会话": "Collapse the side question",
"展开辅助会话": "Expand the side question",
"关闭辅助会话": "Close the side question",
"移除引用 {n}": "Remove quote {n}",
"向辅助会话提问": "Ask the side question",
@@ -154,6 +160,8 @@ export const EN_SETTINGS: Record<string, string> = {
"已保存 · 重启后生效": "Saved · takes effect after a restart",
"已保存 · 新开的会话生效": "Saved · applies to sessions opened from now on",
"新开的会话生效": "Applies to new sessions",
"下个回合起生效": "Applies from the next turn",
"已保存 · 下个回合起生效": "Saved · applies from the next turn",
// ── 设置:分区与导航 ─────────────────────────────────────────────
"设置分类": "Settings sections",
@@ -388,6 +396,10 @@ export const EN_SETTINGS: Record<string, string> = {
"启用内置浏览器": "Enable the built-in browser",
"乐观模式(效率优先)": "Optimistic mode (efficiency first)",
"同时保留的会话标签数": "Session tabs kept open",
"保持实时连接的会话标签数": "Session tabs kept live",
"默认留空:自动。浏览器对同一个地址只开约 6 个连接,会话列表占一个,所以只让少数标签保持实时连接,其余的标签在显示时从断点接着读,运行中的后台标签每 5 秒读一次状态。调高会让更多后台标签实时更新,但开太多会让停止、发送排不上队。": "Empty means automatic. A browser opens only about six connections to one address and the session list takes one, so only a few tabs stay live; the others resume from where they stopped when shown, and a running background tab has its status read every 5 seconds. A higher number updates more background tabs live, but too many makes Stop and Send queue behind them.",
"实时连接的标签数": "Tabs with a live connection",
"留空表示自动;可填 1 到 {max}": "Empty is automatic; 1 to {max}",
"默认 0:不自动收起,打开的会话都保留。设成一个数字后,打开的会话超过它时,会收起最久没动的空闲会话;对话仍在列表里,随时可以重新打开。保留得越多,占用的内存越多。": "Default 0: nothing is folded away and every open session stays. Set a number and, once more sessions than that are open, the idle one untouched longest is folded away; its conversation stays in the list and can be opened again. The more you keep, the more memory it uses.",
"会话标签数上限": "Limit on session tabs",
"0 表示不限制": "0 means no limit",
@@ -434,6 +446,13 @@ export const EN_SETTINGS: Record<string, string> = {
"{n} 个档位": "{n} levels",
"{n} 个模型单独设置": "{n} models with their own levels",
"按模型设置价格": "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.",
"{model} 的输入单价": "{model} input price",
"{model} 的输出单价": "{model} output price",
@@ -1213,7 +1232,7 @@ export const EN_SETTINGS: Record<string, string> = {
"提问等待超时": "Question wait time",
"长任务中途遇到提问时,不必一直停在那里等人。": "A long task need not stop at a question and wait for someone.",
"提问超时后自动跳过": "Skip a question nobody answers",
"默认关闭:提问一直等你回答。开启后,超过下面的时间没有回答,这个问题会被跳过,任务继续;模型会被告知这是超时而不是你的回答,并按自己的假设往下做(全自动或目标模式下尤其如此)。终端界面读同一项设置,用 /ask-timeout 修改。": "Off by default: a question waits for you. When on, a question left unanswered for the time below is skipped and the task goes on; the model is told it was a timeout, not your answer, and carries on under its own assumption (especially in full-auto or goal runs). The terminal reads the same setting; change it there with /ask-timeout.",
"默认关闭:提问一直等你回答。开启后,超过下面的时间没有回答,这个问题会被跳过,任务继续;模型会被告知这是超时而不是你的回答,并按自己的假设往下做(全自动或目标模式下尤其如此)。模型把任务清单交还给你、等你输入时,同样等这么久,之后宿主让它按自己的判断继续并写明假设。终端界面读同一项设置,用 /ask-timeout 修改。": "Off by default: a question waits for you. When on, a question left unanswered for the time below is skipped and the task goes on; the model is told it was a timeout, not your answer, and carries on under its own assumption (especially in full-auto or goal runs). When the model hands its task list back and waits for you, it waits this long too, then the host has it continue on its own judgement and state its assumptions. The terminal reads the same setting; change it there with /ask-timeout.",
"等待时长": "Wait time",
"配置文件里的提问等待时长无效(须为 0 或 {min} 到 {max} 之间),现在按“一直等待”处理:{path}": "The question wait in the config file is not valid (it must be 0 or between {min} and {max}), so questions wait as long as it takes: {path}",
"从内核发出问题时开始计时。默认 {n} 秒。": "Counted from when the kernel asks the question. Default {n} seconds.",
@@ -1,8 +1,10 @@
import { t } from ".";
import { elapsed } from "./format";
export const JOB_FINISHED = "job_finished";
export const JOB_KILLED = "job_killed";
export const JOB_FAILED = "job_failed";
export const JOB_STALLED = "job_stalled";
// The typed payload a background-job notice carries as its detail, read as the
// variables its sentence is worded from. Anything that does not parse reads as no
@@ -18,6 +20,21 @@ export function jobNoticeVars(detail: string | undefined): { name: string } | un
}
}
// A stalled job's sentence also says how long its own output has been silent:
// without that span the sentence would be half-filled, so it reads as no payload.
export function jobStalledVars(detail: string | undefined): { name: string; quiet: string } | undefined {
const named = jobNoticeVars(detail);
if (!named || !detail) return undefined;
try {
const p = JSON.parse(detail) as { quietSeconds?: unknown };
return typeof p.quietSeconds === "number" && Number.isInteger(p.quietSeconds) && p.quietSeconds > 0
? { ...named, quiet: elapsed(p.quietSeconds * 1000) }
: undefined;
} catch {
return undefined;
}
}
// How a failed process ended, worded from the typed fields the producer wrote:
// a signal first, since an exit code is then only the shell's echo of it. The
// job function's own error text stands in when the producer did not type it.
@@ -98,10 +98,10 @@ describe("what a reader is told a refusal was", () => {
// authority never actually left the kernel.
it("explains the project limit in Chinese and English", () => {
const error = coded("storage limit", "workspace.limit_reached");
expect(reason(error)).toBe("项目列表已满(32 个),请先移除一个项目再添加");
expect(reason(error)).toBe("项目列表已到你设置的上限(desktop.max_workspaces),请先移除一个项目再添加");
localStorage.setItem(STORAGE, "en");
boot();
expect(reason(error)).toBe("The project list is full (32 projects); remove one before adding another");
expect(reason(error)).toBe("The project list is at the limit you set (desktop.max_workspaces); remove one before adding another");
});
it("does not move when the kernel rewords the same code", () => {
+29 -2
View File
@@ -164,12 +164,19 @@ const SAID: Record<string, string> = {
"backup.not_found": "这份备份已不存在",
"backup.limit_reached": "备份数量已达上限,请先删除一份旧备份",
"backup.consent_required": "有会在本机运行程序或改变密钥去向的项目,需要逐项确认后才能恢复",
"workspace.limit_reached": "项目列表已满(32 个),请先移除一个项目再添加",
"workspace.limit_reached": "项目列表已到你设置的上限(desktop.max_workspaces),请先移除一个项目再添加",
"provider.bad_model_price": "{model} 的价格必须是不小于 0 的数字,并选择货币",
"provider.model_prices_unlisted": "{model} 不在已选模型中,未保存价格",
"workspace.changing_disabled": "这台服务器不支持切换工作区",
"workspace.worktree_source_missing": "起始分支已不存在,请重新选择分支",
"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_model": "没有找到这个模型,默认模型没有改动",
"settings.default_model_brokered": "这个窗格的默认模型由模型所在的那台机器决定,请在本机的窗格里设置",
@@ -262,7 +269,7 @@ const SAID: Record<string, string> = {
"side.unavailable": "这个会话不支持辅助提问",
"side.unknown": "这个辅助提问已经关闭,请重新打开",
"side.busy": "上一个问题还在回答,请稍候再问",
"side.limit": "同时打开的辅助提问太多,请先关闭几个",
"side.limit": "同时打开的辅助提问已到你设置的上限(agent.max_side_sessions),请先关闭几个",
"side.empty": "请先写下要问的内容",
"side.failed": "辅助提问没能完成,请重试",
@@ -382,6 +389,16 @@ const SAID: Record<string, string> = {
"mcp.approval_owed": "这个服务要启动的内容在你启用后变了,请先查看命令并重新启用",
"mcp.nothing_held": "这个服务没有被暂挡的工具定义",
"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": "开关未能保存:其存储文件无法读取或写入",
// ── 待送达:条目、队列、这份存档各自会拒 ─────────────────────────
@@ -391,6 +408,8 @@ const SAID: Record<string, string> = {
"history.query_required": "请输入要搜索的内容",
"history.search_failed": "对话内容索引暂时无法回答,稍后再试",
"job.not_running": "这个后台任务已经不在运行了",
"mux.unknown_connection": "事件流连接已经关闭,需要重新打开并重新订阅各窗格",
"mux.pane_not_local": "这个窗格由另一台机器驱动,使用它自己的事件流",
"job.stop_in_progress": "已经请求停止这个后台任务,它的进程还没有退出,仍在等待",
"inbox.invalid_state": "该条当前状态不允许此操作",
"inbox.paused": "待送达已暂停,请先恢复派发再进行操作",
@@ -401,6 +420,7 @@ const SAID: Record<string, string> = {
"inbox.closed": "该会话的待送达已关闭",
"inbox.schema_readonly": "该待送达由更高版本写入,当前版本只能读取",
"inbox.idempotency_conflict": "该提交标识已被使用,且当时的内容不同",
"inbox.foreign_run": "这份待送达属于另一个运行,暂不能接管",
// ── 配置文件本身坏了,以及每一个写设置的面板被它挡住时 ─────────
"config.unparsed": "配置文件 {path} 无法读取,因此本次未保存。到「设置」里可以看到出错的位置并修复",
@@ -436,6 +456,9 @@ const SAID: Record<string, string> = {
"peer.too_large": "这条消息太长",
"peer.not_held": "这条消息已经处理过了",
"peer.send_failed": "会话间消息没能完成:{detail}",
"peer.invalid_project": "放行的项目必须是一个绝对路径的文件夹",
"peer.remember_denied": "只有批准才能记住,拒绝只对这一条生效",
"peer.settings_unwritable": "会话间消息的设置没能保存:{detail}",
"provider.key_origin_changed": "地址换了域名,已保存的密钥不会发过去;请填入这个地址对应的密钥",
"write_lease.no_choice": "本次请求未说明选择,未做任何修改",
"write.path_busy": "这个路径正被另一个并行的子代理或写入占用。本次写入未执行;请换一个路径,或等对方结束后再写",
@@ -445,6 +468,10 @@ const SAID: Record<string, string> = {
"changed_files.save_failed": "文件改动显示设置未能保存:{detail}",
"fold_work.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.out_of_range": "等待时长超出允许范围,未做任何修改:{detail}",
"receipt.save_failed": "交付验收卡片的设置未能保存:{detail}",
@@ -12,6 +12,7 @@ const PAYLOAD: Record<string, string | undefined> = {
job_finished: JSON.stringify({ id: "j", label: "build" }),
job_killed: JSON.stringify({ id: "j", label: "build" }),
job_failed: JSON.stringify({ id: "j", label: "build" }),
job_stalled: JSON.stringify({ id: "j", label: "build", quietSeconds: 900 }),
context_composition: JSON.stringify({ total: 561061, at: 419430, active: 400000, parts: [{ name: "tool_output", tokens: 500000 }] }),
subagent_waiting: JSON.stringify({ task: "fix the typo", by: "rewrite the docs", whole: true }),
display_currency: "USD",
@@ -63,6 +64,9 @@ describe("notice wording", () => {
["job_finished", '{"label":"build"}'],
["job_killed", '{"id":""}'],
["job_failed", '{"id":5}'],
["job_stalled", '{"id":"j"}'],
["job_stalled", '{"id":"j","quietSeconds":"900"}'],
["job_stalled", '{"id":"j","quietSeconds":0}'],
["compact_failed", "no_such_reason"],
])("%s reads valid JSON of the wrong shape (%s) as no payload", (code, detail) => {
expect(noticeWording(code, detail)).toBeUndefined();
@@ -4,7 +4,7 @@ import { CONTEXT_COMPOSITION, contextCompositionVars } from "./context_compositi
import { SUBAGENT_WAITING, subagentWaitingVars } from "./subagent_waiting";
import { EXTENSION_SKIPPED, extensionSkippedVars } from "./extension_skipped";
import { INBOX_RECOVERED, inboxRecoveredVars } from "./inbox_recovered";
import { JOB_FAILED, JOB_FINISHED, JOB_KILLED, jobErrorText, jobNoticeVars } from "./job_notice";
import { JOB_FAILED, JOB_FINISHED, JOB_KILLED, JOB_STALLED, jobErrorText, jobNoticeVars, jobStalledVars } from "./job_notice";
import { NOTICE_TEXT } from "./notices";
import { PERMISSION_RULES_DORMANT, permissionRulesDormantVars } from "./permission_rules_dormant";
@@ -24,6 +24,7 @@ const VARS: Record<string, (detail: string | undefined) => NoticeVars | undefine
[JOB_FINISHED]: jobNoticeVars,
[JOB_KILLED]: jobNoticeVars,
[JOB_FAILED]: jobNoticeVars,
[JOB_STALLED]: jobStalledVars,
[CONTEXT_COMPOSITION]: contextCompositionVars,
[SUBAGENT_WAITING]: subagentWaitingVars,
display_currency: (detail) => ({ mode: detail || "auto" }),
@@ -18,6 +18,7 @@ export const NOTICE_TEXT: Record<string, string> = {
project_mcp_awaiting_approval: "这个项目声明的一个 MCP 服务要你批准后才会运行;详情里是它要启动的命令",
project_mcp_changed: "一个已启用的项目 MCP 服务要启动的内容变了,本次没有运行,需要查看命令后重新启用",
mcp_tools_held: "一个 MCP 服务有工具的定义是新增的或与你批准过的不同,已暂不提供给模型;详情里是这些工具,核对服务后再接受",
mcp_tools_followed: "你选择了跟随的一个 MCP 服务改了或新增了工具定义,已按你的选择直接采用;详情里是这些工具",
hook_unevaluable: "有一个钩子无法被评估(匹配串无效、无法启动或载荷无法序列化),为安全起见已拦截这次操作;到钩子设置里修复或移除该钩子",
suspected_injection: "一条外部内容看起来在向智能体下指令,已提醒它只当资料看待",
await_user: "等待你的输入",
@@ -28,6 +29,10 @@ export const NOTICE_TEXT: Record<string, string> = {
web_search_model_removed: "所选搜索模型已不在配置里,搜索已关闭;到模型偏好里重新选择,或恢复自动",
web_search_model_unsupported: "所选搜索模型的协议不支持原生搜索,或已关闭搜索,搜索已关闭;到模型偏好里重新选择,或恢复自动",
web_search_model_no_credentials: "所选搜索模型所在的连接没有凭据,搜索已关闭;到模型偏好里重新选择,或恢复自动",
web_search_backend_fallback: "搜索后端没有返回结果,已换用链里的下一个后端;详情见通知",
web_fetch_backend_fallback: "网页抓取后端没有返回内容,已换用链里的下一个后端;详情见通知",
web_fetch_backend_unusable: "配置的网页抓取后端无法使用,已跳过;检查用户配置里的 web_fetch_backends",
web_search_backend_unusable: "配置的搜索后端无法使用,已跳过;检查用户配置里的 web_search_backends 和 searxng_url",
approval_mode_unrecognized: "配置里的默认审批档位本版本不认识,已按每次询问处理;在界面里选一个档位即可替换",
memory_migration_backup: "旧版记忆文件没能先备份,所以没有迁移,保持原样;详情里有具体原因",
inbox_recovered: "已恢复 {n} 条未完成的指令。待发送已暂停,请先在输入框上方的队列里查看,再点“继续派发”",
@@ -38,6 +43,7 @@ export const NOTICE_TEXT: Record<string, string> = {
job_finished: "后台任务已结束:{name}",
job_killed: "后台任务已终止:{name}",
job_failed: "后台任务 {name} 失败,需要处理",
job_stalled: "后台任务 {name} 已有 {quiet} 没往自己的输出里写任何内容:可能是在写文件,也可能卡住了;可以用 wait 或 bash_output 查看,或用 kill_shell 结束它",
compacted: "已压缩",
compact_declined: "无需压缩:{why}",
compact_failed: "压缩失败:{why}",
+3
View File
@@ -14,6 +14,9 @@ import "./styles/tips.css";
import "./styles/skillpin.css";
import "./styles/waitnote.css";
import "./styles/bring.css";
import "./styles/peerthreads.css";
import "./styles/line.css";
import "./styles/notice.css";
import "./styles/peers.css";
import "./styles/contentmatches.css";
import "./styles/jsontext.css";
@@ -137,12 +137,53 @@ export interface PeerHeld {
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 {
enabled: boolean;
peers: PeerView[];
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 {
id: string;
to: PeerView;
@@ -158,9 +199,17 @@ export interface AutoArchiveSettings {
export type DisplayCurrencyMode = "auto" | "CNY" | "USD";
// Which currency costs are shown in; the user file holds it.
export interface UnconvertedPrice {
provider: string;
model: string;
currency: string;
}
export interface DisplayCurrencySettings {
mode: DisplayCurrencyMode;
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
@@ -178,6 +227,24 @@ export interface TurnNarrationSettings {
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 {
show: boolean;
path: string;
@@ -66,7 +66,9 @@ describe("remote Studio binary framing", () => {
source.onmessage = (event) => seen.push(JSON.parse(event.data) as Record<string, unknown>);
await vi.advanceTimersByTimeAsync(0);
expect(String(fetchMock.mock.calls[0][0])).toBe(`/rt/one/events/replay?lastEventId=${Number.MAX_SAFE_INTEGER}`);
// No cursor is an attach, and an attach is answered with whatever a run is
// stopped on rather than with the stream's history.
expect(String(fetchMock.mock.calls[0][0])).toBe("/rt/one/events/replay");
expect(seen).toEqual([{ kind: "stream_watermark", seq: 7 }]);
await vi.advanceTimersByTimeAsync(1500);
@@ -78,6 +80,33 @@ describe("remote Studio binary framing", () => {
vi.useRealTimers();
});
it("hands an attaching page the question a run is waiting on", async () => {
vi.useFakeTimers();
const fetchMock = vi.spyOn(globalThis, "fetch")
.mockResolvedValueOnce(new Response(JSON.stringify({
frames: [{ kind: "ask_request", ask: { id: "ask-1" } }],
complete: true,
watermark: 3,
}), { status: 200 }))
.mockResolvedValue(new Response(JSON.stringify({ frames: [], complete: true, watermark: 3 }), { status: 200 }));
const seen: Array<Record<string, unknown>> = [];
const source = new RemoteEventSource("/rt/one/events");
source.onmessage = (event) => seen.push(JSON.parse(event.data) as Record<string, unknown>);
await vi.advanceTimersByTimeAsync(0);
// What a run is blocked on is not in the ordinary history a client resumes
// past, so a page that read only the log would draw the run as stopped with
// nothing to answer.
expect(seen).toEqual([{ kind: "ask_request", ask: { id: "ask-1" } }]);
await vi.advanceTimersByTimeAsync(200);
expect(String(fetchMock.mock.calls[1][0])).toBe("/rt/one/events/replay?lastEventId=3");
source.close();
fetchMock.mockRestore();
vi.useRealTimers();
});
it("resumes from the cursor its URL carries", async () => {
vi.useFakeTimers();
const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue(new Response(JSON.stringify({
@@ -129,7 +158,9 @@ describe("remote Studio binary framing", () => {
await vi.advanceTimersByTimeAsync(STREAM_SILENCE_MS * 3);
stop();
expect(opened).toBe(1);
for (const [input] of fetchMock.mock.calls) expect(String(input)).toMatch(/^\/rt\/one\/events\/replay\?/);
// Every read goes to the replay route: the attach carries no cursor, and
// every later read resumes from the watermark.
for (const [input] of fetchMock.mock.calls) expect(String(input)).toMatch(/^\/rt\/one\/events\/replay(\?|$)/);
fetchMock.mockRestore();
vi.unstubAllGlobals();
vi.useRealTimers();
+26 -8
View File
@@ -173,21 +173,27 @@ interface ReplayAnswer {
}
// Stands in for EventSource over the relay by polling /events/replay. A cursor
// the URL carries is where a replacement stream resumes; without one it attaches
// at the watermark, and asking past it returns no frames, not the whole log.
// Frames the SSE transport writes on its own (the watermark, a gap) are said
// here too, or the stream reads as silent to whoever watches it.
// the URL carries is where a replacement stream resumes. Frames the SSE
// transport writes on its own (the watermark, a gap) are said here too, or the
// stream reads as silent to whoever watches it.
export class RemoteEventSource {
onmessage: ((event: MessageEvent<string>) => void) | null = null;
private closed = false;
private after: number | null;
private readonly replay: string;
// What an attach asks for. Only the main event stream carries connection-local
// state to hand back — the pending prompt — so only its attach asks for
// nothing and is answered with that prompt. The tree stream reads the tree,
// not history, so its attach asks past the watermark as it always has:
// replaying the tree's history would only re-read the tree.
private readonly attachQuery: string;
constructor(url: string) {
const target = new URL(url, location.origin);
const cursor = Number(target.searchParams.get("lastEventId") ?? "");
this.after = target.searchParams.has("lastEventId") && Number.isSafeInteger(cursor) && cursor >= 0 ? cursor : null;
this.replay = `${target.pathname}/replay`;
this.attachQuery = target.pathname.endsWith("/tree/events") ? `?lastEventId=${Number.MAX_SAFE_INTEGER}` : "";
queueMicrotask(() => void this.poll());
}
@@ -203,12 +209,15 @@ export class RemoteEventSource {
while (!this.closed) {
let active = false;
try {
const response = await fetch(`${this.replay}?lastEventId=${this.after ?? Number.MAX_SAFE_INTEGER}`, { credentials: "same-origin" });
// No cursor yet is an attach; the main stream's attach asks for nothing
// so the kernel answers with the waiting prompt, the tree stream's asks
// past the watermark. Every later read resumes from where it stands.
const query = this.after !== null ? `?lastEventId=${this.after}` : this.attachQuery;
const response = await fetch(`${this.replay}${query}`, { credentials: "same-origin" });
if (response.ok && !this.closed) {
const body = await response.json() as ReplayAnswer;
const watermark = typeof body.watermark === "number" ? body.watermark : null;
const attaching = this.after === null;
const frames = attaching ? [] : body.frames ?? [];
const frames = body.frames ?? [];
active = frames.length > 0;
if (this.after === null) this.after = watermark ?? 0;
else if (body.complete === false) {
@@ -303,6 +312,12 @@ async function connect(deviceId: string, nativeFetch: typeof fetch, useBootstrap
}
}
// ownFetch is the page's own fetch, bound before remoteHub replaces
// globalThis.fetch with one that routes same-origin requests over the tunnel.
// A hosted page has to read its own origin — the build it is running — not the
// kernel's, so anything that reads the page itself uses this and not the global.
export const ownFetch: typeof fetch = globalThis.fetch.bind(globalThis);
export async function remoteHub(deviceId: string): Promise<HubPort> {
const nativeFetch = globalThis.fetch.bind(globalThis);
let first: RemoteConnection;
@@ -321,7 +336,10 @@ export async function remoteHub(deviceId: string): Promise<HubPort> {
return new URL(request.url).origin === location.origin ? link.fetch(request) : nativeFetch(request);
}) as typeof fetch;
globalThis.EventSource = RemoteEventSource as unknown as typeof EventSource;
return new SseHub();
// RemoteEventSource is a polled stand-in for one stream. The multiplexed
// stream also needs an open frame and per-pane subscriptions, which polling
// cannot carry, so every pane here reads its own /events instead.
return new SseHub({ mux: false });
}
export const remoteCodec = {
@@ -0,0 +1,104 @@
// @vitest-environment jsdom
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { SseHub, type RuntimeView } from "./hub";
import type { WireEvent } from "./wire";
// Every way a hub can be built has to put a frame the kernel sends into the
// pane's reducer. A change to the event transport is held to this table, which
// is where "the page stays connected and goes silent" gets caught.
const rt = { id: "r1", base: "/rt/r1", root: "/w", name: "w" } as RuntimeView;
interface Source { url: string; closed: boolean; say: (data: object) => void }
function kernel(opts: { muxAnswers: boolean }) {
const sources: Source[] = [];
const puts: string[] = [];
vi.stubGlobal(
"EventSource",
class {
onmessage: ((m: { data: string }) => void) | null = null;
onopen: (() => void) | null = null;
readonly s: Source;
constructor(url: string) {
this.s = { url, closed: false, say: (data) => this.onmessage?.({ data: JSON.stringify(data) }) };
sources.push(this.s);
const answers = url.endsWith("/events/mux") ? opts.muxAnswers : true;
// A kernel that answers the mux path with the SPA shell opens a stream
// that never says anything, which is what an older kernel does.
if (answers && url.endsWith("/events/mux")) queueMicrotask(() => this.s.say({ kind: "mux_open", conn: "c1" }));
}
close() {
this.s.closed = true;
}
},
);
vi.stubGlobal("fetch", async (url: string, init: RequestInit = {}) => {
if (init.method === "PUT") puts.push(url);
return { ok: true, status: 204, json: async () => ({}) } as Response;
});
return {
sources,
puts,
live: (suffix: string) => sources.filter((s) => !s.closed && s.url.includes(suffix)),
};
}
beforeEach(() => vi.useFakeTimers());
afterEach(() => {
vi.useRealTimers();
vi.unstubAllGlobals();
});
const tick = (ms = 0) => vi.advanceTimersByTimeAsync(ms);
describe("a pane receives what its kernel sends", () => {
it("on a local hub, through the shared stream", async () => {
const k = kernel({ muxAnswers: true });
const got: WireEvent[] = [];
const stop = new SseHub().portFor(rt).subscribe((ev) => got.push(ev));
await tick();
expect(k.puts).toEqual(["/events/mux/c1/panes/r1"]);
k.live("/events/mux")[0].say({ pane: "r1", frame: { kind: "text", seq: 1, text: "hi" } });
expect(got.map((e) => e.kind)).toEqual(["text"]);
expect(k.live("/rt/r1/events")).toHaveLength(0);
stop();
});
it("on a hub reached over a relay, through the pane's own stream and never the shared one", async () => {
const k = kernel({ muxAnswers: true });
const got: WireEvent[] = [];
const stop = new SseHub({ mux: false }).portFor(rt).subscribe((ev) => got.push(ev));
await tick(20_000);
expect(k.sources.some((s) => s.url.endsWith("/events/mux"))).toBe(false);
k.live("/rt/r1/events")[0].say({ kind: "text", seq: 1, text: "hi" });
expect(got.map((e) => e.kind)).toEqual(["text"]);
stop();
});
it("on a kernel that answers the shared path with something else, through the pane's own stream", async () => {
const k = kernel({ muxAnswers: false });
const got: WireEvent[] = [];
const stop = new SseHub().portFor(rt).subscribe((ev) => got.push(ev));
await tick(11_000);
expect(k.live("/events/mux")).toHaveLength(0);
k.live("/rt/r1/events")[0].say({ kind: "text", seq: 1, text: "hi" });
expect(got.map((e) => e.kind)).toEqual(["text"]);
stop();
});
it("on every one of them, a second frame follows the first in order", async () => {
for (const [hub, muxAnswers] of [[new SseHub(), true], [new SseHub({ mux: false }), true], [new SseHub(), false]] as const) {
const k = kernel({ muxAnswers });
const got: number[] = [];
const stop = hub.portFor({ ...rt, id: `r${got.length}` } as RuntimeView).subscribe((ev) => got.push(ev.seq ?? 0));
await tick(11_000);
const source = k.live("/events/mux")[0] ?? k.live("/rt/")[0];
const say = (seq: number) => source.say(source.url.endsWith("/events/mux") ? { pane: "r0", frame: { kind: "tick", seq } } : { kind: "tick", seq });
say(1);
say(2);
expect(got).toEqual([1, 2]);
stop();
vi.unstubAllGlobals();
}
});
});
+6 -1
View File
@@ -19,9 +19,14 @@ export const isUnderReview = (item: Pick<FeedbackItem, "status" | "underReview"
item.status === "received" && item.underReview === true && !item.statusUnavailable;
// 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 canReplyTo = (item: Pick<FeedbackItem, "status" | "underReview" | "statusUnavailable">): boolean =>
!item.statusUnavailable && (FEEDBACK_REPLYABLE.includes(item.status) || (item.status === "received" && !isUnderReview(item)));
export interface FeedbackEnvInfo {
version: string;
commit: string;
+16 -5
View File
@@ -35,6 +35,8 @@ export interface RuntimeView {
export interface TreeSession {
path: string;
workspaceRoot?: string;
isolated?: boolean;
name: string;
title?: string;
turns?: number;
@@ -98,8 +100,7 @@ export interface OpenWithTarget {
export interface TreeWorkspace {
root: string;
name: string;
// A delivery worktree carries the project's own folder name, so the sidebar
// needs this to tell the copy from the original.
workspaceRoots?: string[];
isolated?: boolean;
missing?: boolean;
open?: boolean;
@@ -117,6 +118,10 @@ export interface TreeWorkspace {
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
// row), `limits` overriding it per folder root.
export type TreeScope = "" | "active" | "archived";
@@ -243,7 +248,8 @@ export class SseHub implements HubPort {
// last list. The default matches the kernel's own until one arrives.
// Bindings the shell exposes are pane-independent; this reaches them without
// pretending to belong to a runtime.
private readonly shell = new SsePort();
private readonly shell: SsePort;
private readonly mux: boolean;
private readonly settings = new Map<string, AgentPort>();
// Questions seen this session, kept so an answer can carry the identities the
// kernel will check it against.
@@ -252,6 +258,11 @@ export class SseHub implements HubPort {
private dialing = 0;
private askTimer: ReturnType<typeof setInterval> | null = null;
constructor(options: { mux?: boolean } = {}) {
this.mux = options.mux !== false;
this.shell = new SsePort("", "", this.mux);
}
// A refusal carries a code, and it is the code that has words in the reader's
// language. Flattening the body into the message threw that away: what
// reached the screen was the raw JSON, or a filesystem error naming a path.
@@ -613,7 +624,7 @@ export class SseHub implements HubPort {
const held = this.settings.get(runtimeId);
if (held) return held;
const base = runtimeId ? `/host/settings/runtime/${encodeURIComponent(runtimeId)}` : "/host/settings";
const port = new SsePort(base);
const port = new SsePort(base, "", this.mux);
this.settings.set(runtimeId, port);
return port;
}
@@ -621,7 +632,7 @@ export class SseHub implements HubPort {
portFor(rt: RuntimeView): AgentPort {
const held = this.ports.get(rt.id);
if (held) return held;
const port = new SsePort(rt.base, rt.id);
const port = new SsePort(rt.base, rt.id, this.mux);
this.ports.set(rt.id, port);
return port;
}
@@ -82,3 +82,61 @@ describe("a stream that goes silent", () => {
stop();
});
});
describe("a stream that gives its connection up", () => {
it("closes the connection, keeps its place, and resumes from the last frame it folded", () => {
const opened = stubStreams();
vi.stubGlobal("fetch", async () => ({ ok: true, json: async () => ({ frames: [], complete: true }) }));
const seen: number[] = [];
const sub = new SsePort("", "r1").subscribe((ev) => ev.seq && seen.push(ev.seq));
opened[0].say('{"kind":"stream_watermark","seq":0}');
opened[0].say('{"kind":"turn_started","seq":1}');
sub.pause!();
expect(opened).toHaveLength(1);
expect(opened[0].closed).toBe(true);
// Silence while paused is not a dead stream: nothing replaces it.
vi.advanceTimersByTime(STREAM_SILENCE_MS * 3);
expect(opened).toHaveLength(1);
sub.resume!();
expect(opened).toHaveLength(2);
expect(opened[1].url).toBe("/events?lastEventId=1");
opened[1].say('{"kind":"turn_started","seq":1}');
opened[1].say('{"kind":"turn_done","seq":2}');
expect(seen).toEqual([1, 2]);
sub();
});
it("recovers the frames that went by while it was paused, in order", async () => {
const opened = stubStreams();
vi.stubGlobal("fetch", async (url: string) => {
expect(url).toContain("/events/replay?lastEventId=1");
return { ok: true, json: async () => ({ frames: [{ kind: "text", seq: 2 }, { kind: "turn_done", seq: 3 }], complete: true }) };
});
vi.useRealTimers();
const seen: number[] = [];
const sub = new SsePort("", "r1").subscribe((ev) => ev.seq && seen.push(ev.seq));
opened[0].say('{"kind":"stream_watermark","seq":0}');
opened[0].say('{"kind":"turn_started","seq":1}');
sub.pause!();
sub.resume!();
// The server's first word on the new connection states where it is: past the
// cursor, so the missing frames are fetched back before anything newer folds.
opened[1].say('{"kind":"stream_watermark","seq":3}');
opened[1].say('{"kind":"turn_started","seq":4}');
await new Promise((r) => setTimeout(r, 20));
expect(seen).toEqual([1, 2, 3, 4]);
sub();
});
it("says it has a hole when it paused before the stream ever stated a position", () => {
const opened = stubStreams();
const gaps: number[] = [];
const sub = new SsePort("", "r1").subscribe(() => {}, () => gaps.push(1));
sub.pause!();
expect(gaps).toEqual([]);
sub.resume!();
expect(gaps).toEqual([1]);
expect(opened[1].url).toBe("/events");
sub();
});
});
+38 -15
View File
@@ -5,27 +5,34 @@
export const STREAM_SILENCE_MS = 45_000;
const CHECK_MS = 5_000;
/** The disposer of a live stream, which can also give its connection back and
* take one again. A paused stream keeps whatever cursor `url` reads, so the
* connection that replaces it asks for exactly what went by in between. */
export type LiveStream = (() => void) & { pause: () => void; resume: () => void; holdsConnection?: () => boolean };
/** Holds one EventSource open and replaces it once it has gone silent. `url`
* names where to resume, so a replacement asks for what the dead one missed.
* Timers stop while a phone sleeps, so waking and coming back online check at
* once rather than on the next tick. */
export function openLiveStream(url: () => string, onData: (raw: string) => void, silenceMs = STREAM_SILENCE_MS): () => void {
let es: EventSource;
export function openLiveStream(url: () => string, onData: (raw: string) => void, silenceMs = STREAM_SILENCE_MS): LiveStream {
let es: EventSource | null = null;
let paused = false;
let heard = 0;
const open = () => {
heard = Date.now();
es = new EventSource(url(), { withCredentials: true });
es.onopen = () => {
const next = new EventSource(url(), { withCredentials: true });
es = next;
next.onopen = () => {
heard = Date.now();
};
es.onmessage = (m) => {
next.onmessage = (m) => {
heard = Date.now();
onData(m.data);
};
};
const check = () => {
if (Date.now() - heard < silenceMs) return;
es.close();
if (paused || Date.now() - heard < silenceMs) return;
es?.close();
open();
};
const wake = () => {
@@ -38,12 +45,28 @@ export function openLiveStream(url: () => string, onData: (raw: string) => void,
document.addEventListener("visibilitychange", wake);
addEventListener("online", check);
}
return () => {
clearInterval(timer);
if (dom) {
document.removeEventListener("visibilitychange", wake);
removeEventListener("online", check);
}
es.close();
};
const stop: LiveStream = Object.assign(
() => {
clearInterval(timer);
if (dom) {
document.removeEventListener("visibilitychange", wake);
removeEventListener("online", check);
}
es?.close();
},
{
pause: () => {
if (paused) return;
paused = true;
es?.close();
es = null;
},
resume: () => {
if (!paused) return;
paused = false;
open();
},
},
);
return stop;
}
+3
View File
@@ -75,6 +75,9 @@ export interface Appearance {
// rather than carrying a copy.
zoomRange?: ZoomRange;
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;
fontMono?: string;
// 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[];
heldDigest?: string;
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,
+8 -3
View File
@@ -4,7 +4,7 @@ import { approvalKind } from "./mock_approval";
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 { 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 { MockExecutionHold, mockExecutionGraph } from "./mock_graph";
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);
}
async versions(): Promise<VersionHub> {
async versions(_refresh?: boolean): Promise<VersionHub> {
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);
}
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) {
await this.setWorkspace((this.state.workspaceRoot ?? "") + " (隔离副本)");
this.branchState.branch = "reasonix/delivery-demo";
@@ -381,7 +386,7 @@ export class MockPort extends MockWorkspace implements AgentPort {
if (path === ".reasonix") return { files: [".reasonix/REASONIX.local.md"], directories: [] };
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}`; }
async saveWorkspaceFile(file: import("./port").WorkspaceFile) { return { ...file, revision: file.revision + "-saved" }; }
@@ -1,5 +1,5 @@
import type { Adjudications, BrowserToolsSettings, SubagentClaimsSettings, WriteLeaseSettings, PlanApprovalSettings, PeerReceipt, PeersState, ConfigProblem, ConfigRepair, PermissionLists, PermissionRules, SandboxSettings, UpdateMode } from "./port";
import type { AskTimeoutSettings, AutoArchiveSettings, ChangedFilesSettings, FoldWorkSettings, TurnNarrationSettings, DisplayCurrencyMode, DisplayCurrencySettings, ProgressWatchSettings, WorkspaceRoots, ReceiptSettings } from "./boundary";
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, WebAccessPatch, WebAccessSettings, DisplayCurrencyMode, DisplayCurrencySettings, ProgressWatchSettings, WorkspaceRoots, ReceiptSettings } from "./boundary";
import { HttpError } from "./http_error";
import { MockShell } from "./mock_shell";
@@ -173,7 +173,7 @@ export class MockBoundary extends MockShell {
protected peerState: PeersState = { enabled: false, peers: [], held: [] };
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> {
@@ -182,10 +182,28 @@ export class MockBoundary extends MockShell {
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) };
}
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" };
async autoArchive(): Promise<AutoArchiveSettings> {
@@ -198,7 +216,7 @@ export class MockBoundary extends MockShell {
return { ...this.archive };
}
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> {
return { ...this.currency };
@@ -237,6 +255,21 @@ export class MockBoundary extends MockShell {
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> {
this.foldWork = { ...this.foldWork, fold };
return { ...this.foldWork };
+18 -1
View File
@@ -1,4 +1,5 @@
import { MockProvider } from "./mock_provider";
import { HttpError } from "./http_error";
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,
@@ -61,7 +62,7 @@ export class MockMcp extends MockProvider {
{
// 真实的连不上就是这个长度:端点把整句话都塞进 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",
description: "Read Figma files, frames and comments.", remembered: true, stale: true,
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 };
}
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) {
const s = this.servers.find((x) => x.name === 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 type { KeptText } from "./attachment";
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;
}
}
+13
View File
@@ -0,0 +1,13 @@
export interface MuxOpened {
kind: "mux_open";
conn: string;
}
export interface MuxAttachRequest {
after?: number | null;
}
export interface MuxEnvelope {
pane: string;
frame: unknown;
}
@@ -0,0 +1,227 @@
// @vitest-environment jsdom
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { MuxClient } from "./muxclient";
import { subscribeStream } from "./sse_subscribe";
import type { WireEvent } from "./wire";
interface Source { url: string; closed: boolean; open: (conn: string) => void; say: (pane: string, frame: object) => void }
function stubHub(answer: (method: string, url: string, body: string) => { status: number; code?: string } = () => ({ status: 204 })) {
const sources: Source[] = [];
const calls: { method: string; url: string; body: string }[] = [];
vi.stubGlobal(
"EventSource",
class {
onmessage: ((m: { data: string }) => void) | null = null;
onopen: (() => void) | null = null;
readonly s: Source;
constructor(url: string) {
const emit = (obj: object) => this.onmessage?.({ data: JSON.stringify(obj) });
this.s = {
url,
closed: false,
open: (conn) => emit({ kind: "mux_open", conn }),
say: (pane, frame) => emit({ pane, frame }),
};
sources.push(this.s);
}
close() {
this.s.closed = true;
}
},
);
vi.stubGlobal("fetch", async (url: string, init: RequestInit = {}) => {
const call = { method: init.method ?? "GET", url, body: String(init.body ?? "") };
calls.push(call);
const a = answer(call.method, url, call.body);
return { ok: a.status < 300, status: a.status, json: async () => ({ code: a.code }) } as Response;
});
return { sources, calls };
}
const open = (s: Source[]) => s.filter((x) => !x.closed);
const flush = () => vi.advanceTimersByTimeAsync(0);
beforeEach(() => vi.useFakeTimers());
afterEach(() => {
vi.useRealTimers();
vi.unstubAllGlobals();
});
function pane(client: MuxClient, id: string, seen: { at: number | null } = { at: null }) {
const got: string[] = [];
const own = vi.fn(() => Object.assign(() => {}, { pause: () => {}, resume: () => {} }));
const stop = client.attach(id, { cursor: () => seen.at, onData: (raw) => got.push(raw), own });
return { got, own, stop, seen };
}
describe("panes sharing one stream", () => {
it("carries nine panes on one connection", async () => {
const hub = stubHub();
const client = new MuxClient();
const panes = Array.from({ length: 9 }, (_, i) => pane(client, `r${i}`));
expect(open(hub.sources)).toHaveLength(1);
hub.sources[0].open("c1");
await flush();
expect(hub.calls.filter((c) => c.method === "PUT").map((c) => c.url)).toEqual(
panes.map((_, i) => `/events/mux/c1/panes/r${i}`),
);
expect(hub.calls[0].body).toBe('{"after":null}');
});
it("hands each frame to its own pane", async () => {
const hub = stubHub();
const client = new MuxClient();
const a = pane(client, "r1");
const b = pane(client, "r2");
hub.sources[0].open("c1");
hub.sources[0].say("r1", { kind: "x", seq: 1 });
hub.sources[0].say("r2", { kind: "y", seq: 7 });
hub.sources[0].say("r1", { kind: "mux_pane_closed" });
expect(a.got).toEqual(['{"kind":"x","seq":1}']);
expect(b.got).toEqual(['{"kind":"y","seq":7}']);
});
it("subscribes again from each pane's own position when the stream comes back", async () => {
const hub = stubHub();
const client = new MuxClient();
const a = pane(client, "r1", { at: 5 });
pane(client, "r2", { at: null });
hub.sources[0].open("c1");
await flush();
hub.calls.length = 0;
a.seen.at = 9;
hub.sources[0].open("c2");
await flush();
expect(hub.calls.map((c) => [c.method, c.url, c.body])).toEqual([
["PUT", "/events/mux/c2/panes/r1", '{"after":9}'],
["PUT", "/events/mux/c2/panes/r2", '{"after":null}'],
]);
});
it("gives a pane up with a DELETE and takes it again from where it stopped", async () => {
const hub = stubHub();
const client = new MuxClient();
const a = pane(client, "r1", { at: 3 });
pane(client, "r2");
hub.sources[0].open("c1");
await flush();
hub.calls.length = 0;
a.stop.pause();
a.seen.at = 4;
a.stop.resume();
await flush();
expect(hub.calls.map((c) => [c.method, c.url, c.body])).toEqual([
["DELETE", "/events/mux/c1/panes/r1", ""],
["PUT", "/events/mux/c1/panes/r1", '{"after":4}'],
]);
expect(open(hub.sources)).toHaveLength(1);
});
it("closes the shared stream when no pane needs it", async () => {
const hub = stubHub();
const client = new MuxClient();
const a = pane(client, "r1");
a.stop();
expect(open(hub.sources)).toHaveLength(0);
});
it("opens a stream of its own for a pane the hub does not run", async () => {
const hub = stubHub((_m, url) => (url.endsWith("/panes/ssh1") ? { status: 409, code: "mux.pane_not_local" } : { status: 204 }));
const client = new MuxClient();
const fell = vi.fn();
client.onFallback(fell);
const local = pane(client, "r1");
const remote = pane(client, "ssh1");
hub.sources[0].open("c1");
await flush();
expect(remote.own).toHaveBeenCalledOnce();
expect(local.own).not.toHaveBeenCalled();
expect(client.carried()).toEqual(["r1"]);
expect(fell).toHaveBeenCalledOnce();
hub.sources[0].say("ssh1", { kind: "z", seq: 1 });
expect(remote.got).toEqual([]);
});
it("reopens the stream when the hub no longer knows the connection", async () => {
let stale = true;
const hub = stubHub((m) => {
if (stale && m === "PUT") {
stale = false;
return { status: 404, code: "mux.unknown_connection" };
}
return { status: 204 };
});
const client = new MuxClient();
pane(client, "r1");
hub.sources[0].open("c1");
await flush();
expect(hub.sources[0].closed).toBe(true);
expect(open(hub.sources)).toHaveLength(1);
hub.sources[1].open("c2");
await flush();
expect(hub.calls.at(-1)?.url).toBe("/events/mux/c2/panes/r1");
});
});
describe("a shared stream that never opens", () => {
it("gives each pane a stream of its own once it has waited long enough", async () => {
const hub = stubHub();
const client = new MuxClient();
const a = pane(client, "r1");
const b = pane(client, "r2");
await vi.advanceTimersByTimeAsync(9_000);
expect(a.own).not.toHaveBeenCalled();
await vi.advanceTimersByTimeAsync(2_000);
expect(a.own).toHaveBeenCalledOnce();
expect(b.own).toHaveBeenCalledOnce();
expect(open(hub.sources)).toHaveLength(0);
expect(client.carried()).toEqual([]);
});
it("keeps the shared stream when it says it is open in time", async () => {
const hub = stubHub();
const client = new MuxClient();
const a = pane(client, "r1");
await vi.advanceTimersByTimeAsync(5_000);
hub.sources[0].open("c1");
await vi.advanceTimersByTimeAsync(20_000);
expect(a.own).not.toHaveBeenCalled();
expect(client.carried()).toEqual(["r1"]);
});
it("opens a pane attached afterwards on its own stream straight away", async () => {
stubHub();
const client = new MuxClient();
pane(client, "r1");
await vi.advanceTimersByTimeAsync(11_000);
const late = pane(client, "r2");
expect(late.own).toHaveBeenCalledOnce();
});
});
describe("a pane's subscription over the shared stream", () => {
it("loses no frame across a reconnect, even when the replay overlaps", async () => {
const hub = stubHub();
const got: number[] = [];
const sub = subscribeStream("/rt/r1", (ev: WireEvent) => got.push(ev.seq ?? 0), undefined, undefined, "r1");
const s = () => open(hub.sources)[0];
s().open("c1");
await flush();
s().say("r1", { kind: "stream_watermark", seq: 0 });
for (const seq of [1, 2, 3]) s().say("r1", { kind: "tick", seq });
hub.sources[0].open("c2");
await flush();
expect(hub.calls.at(-1)).toMatchObject({ method: "PUT", url: "/events/mux/c2/panes/r1", body: '{"after":3}' });
for (const seq of [3, 4, 5]) hub.sources[0].say("r1", { kind: "tick", seq });
expect(got).toEqual([1, 2, 3, 4, 5]);
sub();
});
it("keeps the budget out of panes that cost no connection", async () => {
stubHub();
const sub = subscribeStream("/rt/r1", () => {}, undefined, undefined, "r1");
expect(sub.holdsConnection?.()).toBe(false);
sub();
});
});
+196
View File
@@ -0,0 +1,196 @@
import { openLiveStream, type LiveStream } from "./livestream";
import type { MuxAttachRequest, MuxEnvelope, MuxOpened } from "./mux";
const UNKNOWN_CONNECTION = "mux.unknown_connection";
const RETRY_MS = 2_000;
const OPEN_WAIT_MS = 10_000;
/** What a pane gives the shared stream: where it stands, where its frames go,
* and how to open the stream of its own when the hub cannot carry it. */
export interface PaneFeed {
cursor: () => number | null;
onData: (raw: string) => void;
own: () => LiveStream;
}
interface Pane {
feed: PaneFeed;
paused: boolean;
own: LiveStream | null;
}
/** One EventSource for every local pane. A browser holds six connections to an
* origin; a stream per pane starved the window's own requests, so the hub puts
* every pane's frames on one stream, each in an envelope naming its pane. A
* pane keeps its own cursor and says it on every subscription, so a stream that
* dropped and came back under a new connection id resumes each pane where it
* stood. A pane the hub does not run locally keeps a stream of its own. */
export class MuxClient {
private conn: string | null = null;
private opening: ReturnType<typeof setTimeout> | null = null;
private refused = false;
private stream: LiveStream | null = null;
private readonly panes = new Map<string, Pane>();
private readonly listeners = new Set<() => void>();
attach(id: string, feed: PaneFeed): LiveStream {
const pane: Pane = { feed, paused: false, own: this.refused ? feed.own() : null };
this.panes.set(id, pane);
this.sync();
void this.subscribe(id, pane);
return Object.assign(
() => {
if (this.panes.get(id) === pane) this.panes.delete(id);
if (pane.own) pane.own();
else void this.unsubscribe(id);
this.sync();
},
{
pause: () => {
if (pane.paused) return;
pane.paused = true;
if (pane.own) pane.own.pause();
else void this.unsubscribe(id);
this.sync();
},
resume: () => {
if (!pane.paused) return;
pane.paused = false;
if (pane.own) pane.own.resume();
else {
this.sync();
void this.subscribe(id, pane);
}
},
holdsConnection: () => !!pane.own && !pane.paused,
},
);
}
/** Calls back when a pane has to leave the shared stream for one of its own. */
onFallback(listener: () => void): () => void {
this.listeners.add(listener);
return () => this.listeners.delete(listener);
}
/** Panes riding the shared stream, for tests and diagnostics. */
carried(): string[] {
return [...this.panes].filter(([, p]) => !p.own && !p.paused).map(([id]) => id);
}
private sync() {
const needed = this.carried().length > 0;
if (needed && !this.stream) {
this.stream = openLiveStream(() => "/events/mux", (raw) => this.hear(raw));
this.opening = setTimeout(() => this.giveUp(), OPEN_WAIT_MS);
} else if (!needed && this.stream) {
this.stream();
this.stream = null;
this.conn = null;
this.settled();
}
}
private reopen() {
this.stream?.();
this.stream = null;
this.conn = null;
this.settled();
this.sync();
}
private settled() {
if (this.opening) clearTimeout(this.opening);
this.opening = null;
}
// A stream that never says it is open is one nothing is subscribed through: an
// older kernel, or a proxy that answers the path with something else. Every
// pane then reads its own stream, as it did before there was a shared one.
private giveUp() {
this.opening = null;
if (this.conn || !this.stream) return;
this.refused = true;
this.stream();
this.stream = null;
for (const pane of this.panes.values()) {
if (pane.own) continue;
pane.own = pane.feed.own();
if (pane.paused) pane.own.pause();
}
this.listeners.forEach((l) => l());
}
private hear(raw: string) {
let msg: Partial<MuxOpened & MuxEnvelope>;
try {
msg = JSON.parse(raw);
} catch {
return;
}
if (msg.kind === "mux_open" && msg.conn) {
this.conn = msg.conn;
this.settled();
for (const [id, pane] of this.panes) void this.subscribe(id, pane);
return;
}
if (!msg.pane) return;
const pane = this.panes.get(msg.pane);
if (!pane || pane.own || pane.paused) return;
const frame = msg.frame as { kind?: string } | undefined;
if (!frame || frame.kind === "mux_pane_closed") return;
pane.feed.onData(JSON.stringify(frame));
}
private path(conn: string, id: string) {
return `/events/mux/${conn}/panes/${encodeURIComponent(id)}`;
}
private async subscribe(id: string, pane: Pane) {
const conn = this.conn;
if (!conn || pane.own || pane.paused) return;
const body: MuxAttachRequest = { after: pane.feed.cursor() };
const live = () => this.conn === conn && this.panes.get(id) === pane && !pane.own && !pane.paused;
try {
const res = await fetch(this.path(conn, id), {
method: "PUT",
headers: { "Content-Type": "application/json" },
credentials: "same-origin",
body: JSON.stringify(body),
});
if (res.ok) return;
const code = ((await res.json().catch(() => null)) as { code?: string } | null)?.code;
if (res.status === 404 && code === UNKNOWN_CONNECTION) {
if (this.conn === conn) this.reopen();
return;
}
if (res.status === 409 || res.status === 404) {
if (live()) this.fallBack(pane);
return;
}
} catch {
// The request never landed: asking again is the only repair.
}
setTimeout(() => {
if (live()) void this.subscribe(id, pane);
}, RETRY_MS);
}
private async unsubscribe(id: string) {
const conn = this.conn;
if (!conn) return;
try {
await fetch(this.path(conn, id), { method: "DELETE", credentials: "same-origin" });
} catch {
// The stream going away drops the subscription all the same.
}
}
private fallBack(pane: Pane) {
pane.own = pane.feed.own();
this.sync();
this.listeners.forEach((l) => l());
}
}
export const mux = new MuxClient();
+22 -5
View File
@@ -1,6 +1,10 @@
import type { SurfaceActionRequest, SurfaceActionRecord } from "./surface";
import { DeliveryError, HttpError } from "./http_error";
import type { DeliveryFault } from "./http_error";
/** What `subscribe` hands back: the disposer, and where the transport has one, a
* way to give its connection up and take it again without losing the cursor. */
export type Subscription = (() => void) & { pause?: () => void; resume?: () => void; holdsConnection?: () => boolean };
import type { Attachment, DroppedRef, KeptText } from "./attachment";
export { DeliveryError, HttpError };
export type { DeliveryFault };
@@ -42,7 +46,7 @@ import type { ExecutionGraphRead, TrajectoryRead, WireEvent } from "./wire";
import type { PluginExport, PluginInstallRequest, PluginPackage, PluginPlan } from "./plugin";
import type { MarketDetail, MarketList, MarketOwnRequest, MarketPackage, MarketPlan, MarketPublished, MarketQuery, MarketRequest, MarketSubmission, MarketVote } from "./market";
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 } 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 } from "./boundary";
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";
import type { StoragePlan, StorageQuery, StorageState } from "./storage";
@@ -127,6 +131,9 @@ export interface QueueItem {
// 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.
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
@@ -281,7 +288,9 @@ export interface AgentPort {
saveWriteLease(optimistic: boolean): Promise<WriteLeaseSettings>;
peers(): Promise<PeersState>;
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>;
// Applies live: the kernel announces it and every ledger rebinds; no rebuild.
saveDisplayCurrency(mode: DisplayCurrencyMode): Promise<DisplayCurrencySettings>;
@@ -291,6 +300,8 @@ export interface AgentPort {
saveFoldWorkSettings(fold: boolean): Promise<FoldWorkSettings>;
turnNarrationSettings(): Promise<TurnNarrationSettings>;
saveTurnNarrationSettings(level: TurnNarrationSettings["level"]): Promise<TurnNarrationSettings>;
webAccessSettings(): Promise<WebAccessSettings>;
saveWebAccessSettings(patch: WebAccessPatch): Promise<WebAccessSettings>;
progressWatch(): Promise<ProgressWatchSettings>;
// Applies to the running turn at its next round; no rebuild.
saveProgressWatch(s: Pick<ProgressWatchSettings, "pause" | "rounds" | "tokenMultiple">): Promise<ProgressWatchSettings>;
@@ -318,6 +329,9 @@ export interface AgentPort {
// Approves the withheld definitions the digest names, then reconnects. The
// kernel refuses a digest that no longer matches what it withholds.
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>;
clearMcpOverride(name: string, root?: string): Promise<void>;
setMcpLoad(name: string, load: McpLoad): Promise<void>;
@@ -376,7 +390,7 @@ export interface AgentPort {
welcomeSeen(): Promise<boolean>;
markWelcomed(): Promise<void>;
removeProvider(name: string): Promise<ProviderRemoval>;
versions(): Promise<VersionHub>;
versions(refresh?: boolean): Promise<VersionHub>;
pinVersion(version: string): Promise<void>;
updateMode(): Promise<UpdateMode>;
setUpdateMode(mode: UpdateMode): Promise<UpdateMode>;
@@ -447,6 +461,9 @@ export interface AgentPort {
setWorkspace(path: string): Promise<void>;
isolateWorkspace(selection?: import("./workspace").WorktreeSelection): Promise<void>;
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
// when the user cancelled. Only the shell can open one.
// The native folder picker: the chosen path, "" when the user cancelled, and
@@ -541,7 +558,7 @@ export interface AgentPort {
stageChanges(req: StageRequest): Promise<void>;
// Dot entries are left out unless hidden is set; VCS stores never appear.
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>;
// 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. */
@@ -567,7 +584,7 @@ export interface AgentPort {
// model folds one ordered stream and folds nothing twice. The number belongs
// to the stream, not to the model — several snapshots resume from the lowest
// of their watermarks, because a duplicate folds and a gap does not.
subscribe(onEvent: (ev: WireEvent) => void, onGap?: () => void, bootstrap?: () => Promise<number>): () => void;
subscribe(onEvent: (ev: WireEvent) => void, onGap?: () => void, bootstrap?: () => Promise<number>): Subscription;
// chips carries skill chips beside the line; without it the line is parsed
// as typed, so a kernel that predates chips still runs a leading one.
@@ -63,6 +63,8 @@ export interface ProviderEntry {
// Per model: the price per million tokens it declares for itself. A model
// with none is absent, which means unknown, not zero.
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.
// Absent when none ran or the entry changed since.
lastCheck?: { at: number; ok: boolean; code?: string };
@@ -276,6 +278,8 @@ export interface ModelRate {
output: number;
cacheHit: number;
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.
@@ -0,0 +1,157 @@
// @vitest-environment jsdom
import { afterEach, describe, expect, it, vi } from "vitest";
import { SseHub, type RuntimeView } from "./hub";
import { RemoteEventSource, remoteCodec, remoteHub } from "./cloud_remote";
const pane = { id: "r1", base: "/rt/r1", root: "/w", name: "w" } as RuntimeView;
function recordSources() {
const urls: string[] = [];
const feeds: Array<(raw: string) => void> = [];
vi.stubGlobal("EventSource", class {
onmessage: ((m: { data: string }) => void) | null = null;
constructor(url: string) {
urls.push(url);
feeds.push((raw) => this.onmessage?.({ data: raw }));
}
close() {}
});
return { urls, feeds };
}
// Answers the encrypted handshake the way a desktop does, so remoteHub() can be
// entered without a relay. Requests after it are left unanswered: a test that
// only reads the hub never needs one.
async function reachRemote(deviceId: string) {
const device = await crypto.subtle.generateKey({ name: "X25519" }, true, ["deriveBits"]);
const deviceKey = remoteCodec.bytesToBase64(new Uint8Array(await crypto.subtle.exportKey("raw", device.publicKey)));
vi.stubGlobal("fetch", async (input: RequestInfo | URL) => {
if (!String(input).includes("/me/remote-grants")) throw new Error(`unexpected request ${String(input)}`);
return {
ok: true,
status: 200,
json: async () => ({
grant: { ticket: "t" },
device: { id: deviceId, publicKey: deviceKey, capabilities: ["desktop"] },
}),
} as Response;
});
const info = new TextEncoder().encode(`reasonix-remote-v1|${deviceId}`);
class Socket {
static OPEN = 1;
readyState = 0;
private readonly listeners = new Map<string, Set<(e: Event) => void>>();
constructor(readonly url: string) {
queueMicrotask(() => {
this.readyState = 1;
this.emit("open", new Event("open"));
});
}
addEventListener(type: string, fn: (e: Event) => void) {
const set = this.listeners.get(type) ?? new Set();
set.add(fn);
this.listeners.set(type, set);
}
removeEventListener(type: string, fn: (e: Event) => void) {
this.listeners.get(type)?.delete(fn);
}
close() {}
send(wire: string) {
void this.answer(wire);
}
private emit(type: string, event: Event) {
for (const fn of this.listeners.get(type) ?? []) fn(event);
}
private async answer(wire: string) {
const hello = JSON.parse(wire) as { type?: string; publicKey?: string; salt?: string };
if (hello.type !== "hello") return;
const peer = await crypto.subtle.importKey("raw", remoteCodec.base64ToBytes(hello.publicKey ?? ""), { name: "X25519" }, false, []);
const secret = new Uint8Array(await crypto.subtle.deriveBits({ name: "X25519", public: peer }, device.privateKey, 256));
const material = await crypto.subtle.importKey("raw", secret, "HKDF", false, ["deriveKey"]);
const key = await crypto.subtle.deriveKey(
{ name: "HKDF", hash: "SHA-256", salt: remoteCodec.base64ToBytes(hello.salt ?? ""), info },
material,
{ name: "AES-GCM", length: 256 },
false,
["encrypt"],
);
const nonce = crypto.getRandomValues(new Uint8Array(12));
const ready = new TextEncoder().encode(JSON.stringify({
v: 1, type: "ready", deviceId, features: [], session: "s", instance: "i",
}));
const ciphertext = await crypto.subtle.encrypt({ name: "AES-GCM", iv: nonce, additionalData: info }, key, ready);
this.emit("message", new MessageEvent("message", {
data: JSON.stringify({
v: 1,
nonce: remoteCodec.bytesToBase64(nonce),
ciphertext: remoteCodec.bytesToBase64(new Uint8Array(ciphertext)),
}),
}));
}
}
vi.stubGlobal("WebSocket", Socket);
}
afterEach(() => {
vi.useRealTimers();
vi.unstubAllGlobals();
vi.restoreAllMocks();
});
describe("the stream a pane reads", () => {
// A remote hub's EventSource is RemoteEventSource, which polls one replay
// route. The multiplexed stream also needs an open frame and per-pane PUTs,
// so it cannot be carried by that transport: the pane reads its own stream.
it("is the pane's own /events when the hub is reached over the relay", () => {
const { urls, feeds } = recordSources();
const seen: unknown[] = [];
const stop = new SseHub({ mux: false }).portFor(pane).subscribe((ev) => seen.push(ev));
expect(urls).toEqual(["/rt/r1/events"]);
feeds[0](JSON.stringify({ kind: "turn_started", seq: 1 }));
expect(seen).toEqual([{ kind: "turn_started", seq: 1 }]);
stop();
});
it("stays on the hub's one shared stream for a local pane", () => {
const { urls } = recordSources();
const stop = new SseHub().portFor(pane).subscribe(() => {});
expect(urls).toEqual(["/events/mux"]);
stop();
});
// The entry the hosted page actually takes. Without this case, closing mux
// again in remoteHub would leave every other test here passing.
it("comes from remoteHub with the multiplexed stream off", async () => {
await reachRemote("dev1");
const hub = await remoteHub("dev1");
expect(globalThis.EventSource).toBe(RemoteEventSource);
expect((hub.portFor(pane) as unknown as { mux: boolean }).mux).toBe(false);
});
// A remote pane reads its own stream, RemoteEventSource turns that into
// replay polls, and a run's next frame reaches the reducer without a reload.
it("delivers a new frame through the relay's replay polls", async () => {
vi.useFakeTimers();
const fetchMock = vi.spyOn(globalThis, "fetch")
.mockResolvedValueOnce(new Response(JSON.stringify({ frames: [], complete: true, watermark: 0 }), { status: 200 }))
.mockResolvedValue(new Response(JSON.stringify({
frames: [{ kind: "turn_started", seq: 1 }],
complete: true,
watermark: 1,
}), { status: 200 }));
vi.stubGlobal("EventSource", RemoteEventSource);
const seen: unknown[] = [];
const stop = new SseHub({ mux: false }).portFor(pane).subscribe((ev) => seen.push(ev));
await vi.advanceTimersByTimeAsync(0);
expect(String(fetchMock.mock.calls[0][0])).toBe("/rt/r1/events/replay");
await vi.advanceTimersByTimeAsync(1500);
expect(seen).toEqual([{ kind: "turn_started", seq: 1 }]);
stop();
});
});
+17
View File
@@ -250,6 +250,8 @@ export interface SessionStatus {
bypass: boolean;
goal: string;
goalStatus: string;
// Present while there is a goal; absent from a kernel that predates it.
goalRuntime?: GoalRuntime;
cwd: string;
workspaceRoot?: string;
sessionPath?: string;
@@ -262,6 +264,17 @@ export interface SessionStatus {
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
* 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. */
@@ -286,6 +299,10 @@ export interface JobEntry {
id: string;
kind: string;
label: string;
// The name the model gave the call for a person, and the full command or task
// behind it; label stays the short preview.
description?: string;
detail?: string;
status: string;
startedAt: number;
}
+24 -249
View File
@@ -1,13 +1,13 @@
import type { SurfaceActionRequest, SurfaceActionRecord } from "./surface";
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 { SseWorkspace } from "./sse_workspace";
import type { StoragePlan, StorageQuery, StorageState } from "./storage";
import type { ExecutionGraphRead, TrajectoryRead, WireEvent } from "./wire";
import { host } from "./host";
import { current } from "../i18n";
import { openLiveStream } from "./livestream";
import { subscribeStream } from "./sse_subscribe";
// The running project is the default, so its requests stay the bare path they
// have always been and only a cross-project read carries the folder.
@@ -66,38 +66,6 @@ export class SsePort extends SseWorkspace implements AgentPort {
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(query: UsageQuery): Promise<UsageReport>;
usage(query: number | UsageQuery, source?: string) {
@@ -139,53 +107,6 @@ export class SsePort extends SseWorkspace implements AgentPort {
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> {
const res = await this.get<{ seen: boolean }>("/welcome");
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
// 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.
async versions(): Promise<VersionHub> {
const res = await fetch("/studio/versions", { credentials: "same-origin" });
async versions(refresh = false): Promise<VersionHub> {
const res = await fetch(refresh ? "/studio/versions?refresh=1" : "/studio/versions", { credentials: "same-origin" });
if (!res.ok) await SsePort.fail("/studio/versions", res);
return (await res.json()) as VersionHub;
}
@@ -376,15 +297,8 @@ export class SsePort extends SseWorkspace implements AgentPort {
return (await res.json()) as TrayPrefs;
}
async accountPoll(deviceCode: string) {
const res = await fetch(this.base + "/account/poll", {
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 };
accountPoll(deviceCode: string) {
return this.post0<{ status: "pending" | "complete"; slowDown?: boolean }>("/account/poll", { deviceCode });
}
accountLogout() {
@@ -407,6 +321,18 @@ export class SsePort extends SseWorkspace implements AgentPort {
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 }> {
return this.post0<{ editor: string; root: string }>("/workspace/editor");
}
@@ -474,8 +400,8 @@ export class SsePort extends SseWorkspace implements AgentPort {
return this.get<WorkspaceFiles>(`/workspace/files${params.size ? `?${params}` : ""}`);
}
workspaceFile(path: string) {
return this.get<WorkspaceFile>(`/workspace/file?path=${encodeURIComponent(path)}`);
workspaceFile(path: string, root?: string) {
return this.get<WorkspaceFile>(`/workspace/file?path=${encodeURIComponent(path)}${root ? `&root=${encodeURIComponent(root)}` : ""}`);
}
workspaceImageURL(path: string, root?: string) {
@@ -522,162 +448,11 @@ export class SsePort extends SseWorkspace implements AgentPort {
return this.post("/rewind/undo", { transactionId });
}
/** subscribe delivers the kernel's events, in order, with no holes it did not
* announce. Frames that matter are numbered, so a break in the numbers is
* the client noticing it missed something rather than a gap it renders as a
* quiet turn — and the two transports differ only in how they fetch the
* missing frames back, not in what a gap means.
*
* onGap fires when the replay log can no longer close one. The hole is the
* transport's fact; which authority each read model returns to — /history,
* the run graph — is the caller's.
*
* bootstrap is how a read model with a snapshot of its own joins without a
* seam: it reads one and answers with the frame that snapshot is at least as
* new as. Frames are held from before the transport is attached until the
* replay after that number has landed, so nothing between the two is
* skipped. The number is the whole stream's, never one model's — a frame of
* any kind carries the cursor past it, or the next number reads as a hole. */
/** The kernel's events in order, with no holes it did not announce; see
* subscribeStream for how a gap, a bootstrap and a paused connection work. */
subscribe(onEvent: (ev: WireEvent) => void, onGap?: () => void, bootstrap?: () => Promise<number>) {
// Frames arriving while a recovery request is in flight wait for it: the
// whole point is that the reducer sees one ordered stream, and delivering
// the new frame first would put a result ahead of the dispatch it answers.
// null until the stream states a position: 0 cannot tell a subscriber that
// just attached from one that has seen the stream start.
let seen: number | null = null;
// A bootstrapping subscription holds from before the transport is attached:
// the shell's bus has no connection to open, so it is already delivering,
// and a frame taken live here would be folded ahead of the replay's.
let booting = !!bootstrap;
let recovering = booting;
let held: WireEvent[] = [];
let live = true;
const deliver = (ev: WireEvent) => {
// The cursor is the record of what has been folded, and the replay and
// the live stream reach the same frames — a snapshot's own watermark
// included. Whichever arrives second is the same fact twice.
if (ev.seq && seen !== null && ev.seq <= seen) return;
if (ev.seq) seen = Math.max(seen ?? 0, ev.seq);
onEvent(ev);
};
const flush = () => {
recovering = false;
const queued = held;
held = [];
for (const ev of queued) deliver(ev);
};
const recover = async (after: number) => {
recovering = true;
try {
const res = await fetch(`${this.base}/events/replay?lastEventId=${after}`, { credentials: "same-origin" });
if (!res.ok) throw new Error(String(res.status));
const body = (await res.json()) as { frames?: WireEvent[]; complete?: boolean };
if (!live) return;
for (const ev of body.frames ?? []) deliver(ev);
if (!body.complete) onGap?.();
} catch {
// The frames are gone and asking again would not bring them back. The
// transcript is the one source that can still answer.
if (live) onGap?.();
} finally {
flush();
}
};
// What the snapshot and the replay overlap on folds twice onto the same
// state; what falls between them is gone for good. So the read names where
// it stands, and the stream resumes from there — one number, one stream.
const bootstrapFrom = async (read: () => Promise<number>) => {
let at: number | null = null;
try {
at = await read();
} catch {
// Nothing to resume onto. Holding the stream any longer would stall the
// view rather than repair it, so it runs live and says it is behind.
if (live) onGap?.();
}
if (!live) return;
if (at === null) {
booting = false;
flush();
return;
}
seen = at;
booting = false;
await recover(at);
};
const accept = (ev: WireEvent) => {
// The stream describing itself: a watermark states the number the client
// should have reached, which is the only way to notice a frame lost at
// the end of a turn. Neither reaches the reducer.
if (ev.kind === "stream_watermark") {
// The first one states where this subscriber attached: a live-only
// subscription replays nothing before it, so nothing before it was lost.
// A bootstrap states that position from its snapshot instead, and taking
// it from here would baseline the cursor over frames already held.
if (seen === null) {
if (!booting) seen = ev.seq ?? 0;
return;
}
if (!recovering && ev.seq && ev.seq > seen) void recover(seen);
return;
}
if (ev.kind === "stream_gap") {
// The hole reaches the caller either way — it is the whole stream's,
// not the bootstrapping model's — but the number it carries is not a
// position this subscriber reached.
if (!booting) seen = Math.max(seen ?? 0, ev.seq ?? 0);
onGap?.();
return;
}
if (recovering) {
held.push(ev);
return;
}
// Numbering that goes backwards is a different stream, not a gap: the
// server restarted and is counting from one again. Inferred rather than
// carried as a stream id — a resumed client is never sent a lower number
// than it holds, so nothing else produces this. Without it the client
// compares against a watermark that will never be reached again and
// silently stops noticing losses.
if (ev.seq && seen && ev.seq < seen) {
seen = ev.seq;
onGap?.();
onEvent(ev);
return;
}
if (ev.seq && seen && ev.seq > seen + 1) {
held.push(ev);
void recover(seen);
return;
}
deliver(ev);
};
const feed = (raw: string) => {
try {
accept(JSON.parse(raw) as WireEvent);
} catch {
// A malformed frame must not tear down the stream.
}
};
// EventSource resumes on its own: it reconnects carrying the last id it
// saw, and the server replays from there. Recovery here is for the frames
// shed without the connection ever dropping, and a replacement stream for
// one that went silent without dropping resumes from the same cursor.
const detach = openLiveStream(
() => this.base + "/events" + (seen === null ? "" : `?lastEventId=${seen}`),
feed,
);
if (bootstrap) void bootstrapFrom(bootstrap);
return () => {
live = false;
detach();
};
const lane = this.mux && this.rt && this.base === `/rt/${this.rt}` ? this.rt : undefined;
return subscribeStream(this.base, onEvent, onGap, bootstrap, lane);
}
submit(text: string, chips?: ChipCall) {
+16 -4
View File
@@ -1,6 +1,6 @@
import { SseShell } from "./sse_shell";
import type { Adjudications, BrowserToolsSettings, SubagentClaimsSettings, WriteLeaseSettings, PlanApprovalSettings, PeerReceipt, PeersState, ConfigProblem, ConfigRepair, PermissionLists, PermissionRules, SandboxSettings } from "./port";
import type { AskTimeoutSettings, AutoArchiveSettings, ChangedFilesSettings, FoldWorkSettings, TurnNarrationSettings, DisplayCurrencyMode, DisplayCurrencySettings, ProgressWatchSettings, WorkspaceRoots, ReceiptSettings } from "./boundary";
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, WebAccessPatch, WebAccessSettings, DisplayCurrencyMode, DisplayCurrencySettings, ProgressWatchSettings, WorkspaceRoots, ReceiptSettings } from "./boundary";
// Where the agent may reach: the permission rules a call is matched against and
// the sandbox the shell runs in.
@@ -62,8 +62,14 @@ export class SseBoundary extends SseShell {
sendPeerMessage(to: string, text: string) {
return this.post0<PeerReceipt>("/peers/send", { to, text });
}
resolvePeerMessage(id: string, approve: boolean) {
return this.post(`/peers/held/${encodeURIComponent(id)}`, { approve });
resolvePeerMessage(id: string, approve: boolean, remember: PeerRemember = "none") {
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) {
return this.post0<SubagentClaimsSettings>("/subagent-claims", { optimistic });
@@ -92,6 +98,12 @@ export class SseBoundary extends SseShell {
saveTurnNarrationSettings(level: TurnNarrationSettings["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() {
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);
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");
a.href = url;
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 -3
View File
@@ -22,12 +22,13 @@ interface ReadSlot {
export class SseHttp {
private readonly reads = new Map<string, ReadSlot>();
// rt names the pane this port speaks for. The shell's bus carries every
// pane's frames, so a channel per runtime is what keeps two live
// conversations out of each other's transcript.
// rt names the pane this port speaks for: the shell's bus carries every
// pane's frames, so a channel per runtime keeps two conversations apart. mux
// is off when the transport is the relay's polled EventSource.
constructor(
protected readonly base = "",
protected readonly rt = "",
protected readonly mux = true,
) {}
// A refusal carries a code; only when it does not do we fall back to text.
+4 -15
View File
@@ -14,6 +14,7 @@ export class SseLook extends SseHttp {
language: look.language ?? "",
zoom: look.zoom ?? 0,
readSize: look.readSize ?? 0,
textSize: look.textSize ?? 0,
fontUi: look.fontUi ?? "",
fontMono: look.fontMono ?? "",
width: look.width ?? "",
@@ -30,21 +31,9 @@ export class SseLook extends SseHttp {
// cross-site form from posting here at all.
async uploadWallpaper(blob: Blob) {
guardUpload(blob.size, UPLOAD_LIMIT_MB.wallpaper, tooLarge("wallpaper.too_large", "limit"));
const res = await fetch(this.base + "/appearance/wallpaper", {
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;
return this.post0<Appearance>("/appearance/wallpaper", { mime: blob.type, data: await base64Of(blob) });
}
async clearWallpaper() {
const res = await fetch(this.base + "/appearance/wallpaper", {
method: "DELETE",
credentials: "same-origin",
});
if (!res.ok) throw new Error(`/appearance/wallpaper: ${res.status}`);
clearWallpaper() {
return this.del("/appearance/wallpaper");
}
}
@@ -33,6 +33,12 @@ export class SseMcp extends SseProvider {
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 };
}
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) {
const res = await fetch(this.base + "/mcp/reconnect", {
method: "POST",
+2 -10
View File
@@ -7,16 +7,8 @@ export class SseNetwork extends SseHook {
network() {
return this.get<NetworkSettings>("/network");
}
async saveNetwork(settings: NetworkSettings, password: string, clearPassword: boolean) {
const res = await fetch(this.base + "/network", {
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;
saveNetwork(settings: NetworkSettings, password: string, clearPassword: boolean) {
return this.post0<NetworkSettings>("/network", { ...settings, password, clearPassword });
}
async diagnoseNetwork() {
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);
});
@@ -0,0 +1,184 @@
import type { WireEvent } from "./wire";
import type { Subscription } from "./port";
import { openLiveStream } from "./livestream";
import { mux } from "./muxclient";
/** subscribe delivers the kernel's events, in order, with no holes it did not
* announce. Frames that matter are numbered, so a break in the numbers is
* the client noticing it missed something rather than a gap it renders as a
* quiet turn — and the two transports differ only in how they fetch the
* missing frames back, not in what a gap means.
*
* onGap fires when the replay log can no longer close one. The hole is the
* transport's fact; which authority each read model returns to — /history,
* the run graph — is the caller's.
*
* bootstrap is how a read model with a snapshot of its own joins without a
* seam: it reads one and answers with the frame that snapshot is at least as
* new as. Frames are held from before the transport is attached until the
* replay after that number has landed, so nothing between the two is
* skipped. The number is the whole stream's, never one model's — a frame of
* any kind carries the cursor past it, or the next number reads as a hole.
*
* lane is the pane id when the hub can carry that pane on its shared stream. */
export function subscribeStream(base: string, onEvent: (ev: WireEvent) => void, onGap?: () => void, bootstrap?: () => Promise<number>, lane?: string): Subscription {
// Frames arriving while a recovery request is in flight wait for it: the
// whole point is that the reducer sees one ordered stream, and delivering
// the new frame first would put a result ahead of the dispatch it answers.
// null until the stream states a position: 0 cannot tell a subscriber that
// just attached from one that has seen the stream start.
let seen: number | null = null;
// A bootstrapping subscription holds from before the transport is attached:
// the shell's bus has no connection to open, so it is already delivering,
// and a frame taken live here would be folded ahead of the replay's.
let booting = !!bootstrap;
let recovering = booting;
let held: WireEvent[] = [];
let live = true;
const deliver = (ev: WireEvent) => {
// The cursor is the record of what has been folded, and the replay and
// the live stream reach the same frames — a snapshot's own watermark
// included. Whichever arrives second is the same fact twice.
if (ev.seq && seen !== null && ev.seq <= seen) return;
if (ev.seq) seen = Math.max(seen ?? 0, ev.seq);
onEvent(ev);
};
const flush = () => {
recovering = false;
const queued = held;
held = [];
for (const ev of queued) deliver(ev);
};
const recover = async (after: number) => {
recovering = true;
try {
const res = await fetch(`${base}/events/replay?lastEventId=${after}`, { credentials: "same-origin" });
if (!res.ok) throw new Error(String(res.status));
const body = (await res.json()) as { frames?: WireEvent[]; complete?: boolean };
if (!live) return;
for (const ev of body.frames ?? []) deliver(ev);
if (!body.complete) onGap?.();
} catch {
// The frames are gone and asking again would not bring them back. The
// transcript is the one source that can still answer.
if (live) onGap?.();
} finally {
flush();
}
};
// What the snapshot and the replay overlap on folds twice onto the same
// state; what falls between them is gone for good. So the read names where
// it stands, and the stream resumes from there — one number, one stream.
const bootstrapFrom = async (read: () => Promise<number>) => {
let at: number | null = null;
try {
at = await read();
} catch {
// Nothing to resume onto. Holding the stream any longer would stall the
// view rather than repair it, so it runs live and says it is behind.
if (live) onGap?.();
}
if (!live) return;
if (at === null) {
booting = false;
flush();
return;
}
seen = at;
booting = false;
await recover(at);
};
const accept = (ev: WireEvent) => {
// The stream describing itself: a watermark states the number the client
// should have reached, which is the only way to notice a frame lost at
// the end of a turn. Neither reaches the reducer.
if (ev.kind === "stream_watermark") {
// The first one states where this subscriber attached: a live-only
// subscription replays nothing before it, so nothing before it was lost.
// A bootstrap states that position from its snapshot instead, and taking
// it from here would baseline the cursor over frames already held.
if (seen === null) {
if (!booting) seen = ev.seq ?? 0;
return;
}
if (!recovering && ev.seq && ev.seq > seen) void recover(seen);
return;
}
if (ev.kind === "stream_gap") {
// The hole reaches the caller either way — it is the whole stream's,
// not the bootstrapping model's — but the number it carries is not a
// position this subscriber reached.
if (!booting) seen = Math.max(seen ?? 0, ev.seq ?? 0);
onGap?.();
return;
}
if (recovering) {
held.push(ev);
return;
}
// Numbering that goes backwards is a different stream, not a gap: the
// server restarted and is counting from one again. Inferred rather than
// carried as a stream id — a resumed client is never sent a lower number
// than it holds, so nothing else produces this. Without it the client
// compares against a watermark that will never be reached again and
// silently stops noticing losses.
if (ev.seq && seen && ev.seq < seen) {
seen = ev.seq;
onGap?.();
onEvent(ev);
return;
}
if (ev.seq && seen && ev.seq > seen + 1) {
held.push(ev);
void recover(seen);
return;
}
deliver(ev);
};
const feed = (raw: string) => {
try {
accept(JSON.parse(raw) as WireEvent);
} catch {
// A malformed frame must not tear down the stream.
}
};
// EventSource resumes on its own: it reconnects carrying the last id it
// saw, and the server replays from there. Recovery here is for the frames
// shed without the connection ever dropping, and a replacement stream for
// one that went silent without dropping resumes from the same cursor.
const own = () => openLiveStream(() => base + "/events" + (seen === null ? "" : `?lastEventId=${seen}`), feed);
// lane names a pane the hub runs locally: its frames ride the one shared
// stream instead of costing the window a connection each.
const detach = lane ? mux.attach(lane, { cursor: () => seen, onData: feed, own }) : own();
if (bootstrap) void bootstrapFrom(bootstrap);
// A subscription that gave its connection up before the stream ever stated a
// position has nothing to resume from, so what went by is a hole it cannot
// name: it says so, and the caller re-reads.
let pausedBlind = false;
return Object.assign(
() => {
live = false;
detach();
},
{
pause: () => {
pausedBlind = seen === null;
detach.pause();
},
resume: () => {
detach.resume();
if (pausedBlind) {
pausedBlind = false;
onGap?.();
}
},
holdsConnection: () => detach.holdsConnection?.() ?? true,
},
);
}
@@ -0,0 +1,99 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { LIVE_PANE_STREAMS, StreamBudget } from "./streambudget";
import type { Subscription } from "./port";
beforeEach(() => vi.useFakeTimers());
afterEach(() => vi.useRealTimers());
function pane() {
const calls: string[] = [];
const sub = Object.assign(() => calls.push("close"), { pause: () => calls.push("pause"), resume: () => calls.push("resume") }) as Subscription;
return { sub, calls, refresh: vi.fn() };
}
const idle = { visible: false, busy: false };
// The browser gives one origin six connections; the tree holds one and a request
// needs one, so at most this many panes may hold a stream at once.
describe("the panes that hold a live stream", () => {
it("never exceeds the budget, however many panes are open", () => {
const budget = new StreamBudget();
const panes = Array.from({ length: 8 }, () => pane());
panes.forEach((p, i) => budget.attach(`r${i}`, p.sub, p.refresh, i === 0 ? { visible: true, busy: false } : idle));
expect(budget.liveIds()).toHaveLength(LIVE_PANE_STREAMS);
expect(budget.liveIds()).toContain("r0");
});
it("keeps the visible pane first, then the busy ones, with 8 panes and 4 running", () => {
const budget = new StreamBudget();
const panes = Array.from({ length: 8 }, () => pane());
panes.forEach((p, i) => budget.attach(`r${i}`, p.sub, p.refresh, { visible: i === 7, busy: i >= 3 && i <= 6 }));
const live = budget.liveIds();
expect(live).toHaveLength(3);
expect(live).toContain("r7");
expect(live.filter((id) => id !== "r7").every((id) => ["r3", "r4", "r5", "r6"].includes(id))).toBe(true);
});
it("gives an idle pane's connection back and takes it again when the pane is shown", () => {
const budget = new StreamBudget();
const panes = Array.from({ length: 5 }, () => pane());
panes.forEach((p, i) => budget.attach(`r${i}`, p.sub, p.refresh, { visible: i === 0, busy: false }));
expect(panes.filter((p) => p.calls.includes("pause"))).toHaveLength(2);
const paused = panes.findIndex((p) => p.calls.includes("pause"));
budget.update("r0", idle);
budget.update(`r${paused}`, { visible: true, busy: false });
expect(panes[paused].calls).toContain("resume");
expect(budget.liveIds()).toContain(`r${paused}`);
expect(budget.liveIds()).toHaveLength(3);
});
it("reads a busy pane that lost its stream by polling, and stops when it is no longer owed", () => {
const budget = new StreamBudget(() => 1);
const shown = pane();
const running = pane();
budget.attach("a", shown.sub, shown.refresh, { visible: true, busy: false });
budget.attach("b", running.sub, running.refresh, { visible: false, busy: true });
expect(running.calls).toEqual(["pause"]);
vi.advanceTimersByTime(5_000);
expect(running.refresh).toHaveBeenCalledTimes(1);
expect(shown.refresh).not.toHaveBeenCalled();
budget.update("b", idle);
vi.advanceTimersByTime(20_000);
expect(running.refresh).toHaveBeenCalledTimes(1);
});
it("frees a place when a pane goes away", () => {
const budget = new StreamBudget(() => 1);
const a = pane();
const b = pane();
const releaseA = budget.attach("a", a.sub, a.refresh, { visible: true, busy: false });
budget.attach("b", b.sub, b.refresh, idle);
expect(b.calls).toEqual(["pause"]);
releaseA();
expect(b.calls).toEqual(["pause", "resume"]);
});
it("leaves a transport that cannot pause alone", () => {
const budget = new StreamBudget(() => 1);
const bare = Object.assign(() => {}, {}) as Subscription;
budget.attach("a", bare, () => {}, idle);
budget.attach("b", bare, () => {}, idle);
expect(budget.liveIds()).toEqual(["a", "b"]);
});
});
describe("the number of live streams a person chose", () => {
it("follows the limit as it is read, giving places out again when it changes", () => {
let limit = 1;
const budget = new StreamBudget(() => limit);
const panes = Array.from({ length: 4 }, () => pane());
panes.forEach((p, i) => budget.attach(`r${i}`, p.sub, p.refresh, { visible: i === 0, busy: false }));
expect(budget.liveIds()).toEqual(["r0"]);
limit = 3;
budget.resettle();
expect(budget.liveIds()).toHaveLength(3);
limit = 1;
budget.resettle();
expect(budget.liveIds()).toHaveLength(1);
});
});
@@ -0,0 +1,103 @@
import type { Subscription } from "./port";
import { mux } from "./muxclient";
import { liveStreamBudget, onLiveStreamBudgetChange } from "../state/prefs";
// A browser talking HTTP/1.1 opens at most six connections to one origin, and an
// event stream keeps its connection for as long as it lives. Every open pane held
// one, the session tree held another, and from the fifth pane on nothing else —
// Stop, submit, a status read — could get a connection: the window read as stuck.
// So only a few panes keep a live stream. The rest give theirs up and take it
// again, from the cursor they stopped at, when they have something to say.
/** The automatic budget: with the session list's stream that is four of six. */
export const LIVE_PANE_STREAMS = 3;
const POLL_MS = 5_000;
export interface Want {
/** On screen: needs frames as they come. */
visible: boolean;
/** Running or waiting on the person: worth a live stream while there is room. */
busy: boolean;
}
interface Entry {
sub: Subscription;
refresh: () => void;
want: Want;
at: number;
live: boolean;
}
/** Decides which panes hold a live event stream. Visible panes come first, then
* busy ones, then the rest, the most recently changed first within each. A pane
* that is busy but did not make the cut is read by polling instead. */
export class StreamBudget {
private readonly entries = new Map<string, Entry>();
private timer: ReturnType<typeof setInterval> | null = null;
private tick = 0;
constructor(private readonly limit: () => number = () => LIVE_PANE_STREAMS) {}
/** Registers a pane's subscription; the returned function removes it. */
attach(id: string, sub: Subscription, refresh: () => void, want: Want): () => void {
const entry: Entry = { sub, refresh, want, at: ++this.tick, live: true };
this.entries.set(id, entry);
this.settle();
return () => {
if (this.entries.get(id) === entry) this.entries.delete(id);
this.settle();
};
}
/** Says what a pane needs now. */
update(id: string, want: Want) {
const entry = this.entries.get(id);
if (!entry || (entry.want.visible === want.visible && entry.want.busy === want.busy)) return;
entry.want = want;
entry.at = ++this.tick;
this.settle();
}
/** Gives the places out again; call when the limit has changed. */
resettle() {
this.settle();
}
/** Panes with a live stream, for tests and diagnostics. */
liveIds(): string[] {
return [...this.entries].filter(([, e]) => e.live).map(([id]) => id);
}
private rank(e: Entry) {
return e.want.visible ? 0 : e.want.busy ? 1 : 2;
}
private settle() {
const order = [...this.entries].sort(([, a], [, b]) => this.rank(a) - this.rank(b) || b.at - a.at);
let used = 0;
order.forEach(([, entry]) => {
const free = !entry.sub.pause || entry.sub.holdsConnection?.() === false;
const keep = free || used < this.limit();
if (!free && keep) used++;
if (keep && !entry.live) entry.sub.resume?.();
if (!keep && entry.live) entry.sub.pause?.();
entry.live = keep;
});
this.poll();
}
private poll() {
const owed = [...this.entries.values()].some((e) => !e.live && e.want.busy);
if (owed && !this.timer) {
this.timer = setInterval(() => this.entries.forEach((e) => { if (!e.live && e.want.busy) e.refresh(); }), POLL_MS);
} else if (!owed && this.timer) {
clearInterval(this.timer);
this.timer = null;
}
}
}
export const streamBudget = new StreamBudget(() => liveStreamBudget() ?? LIVE_PANE_STREAMS);
if (typeof window !== "undefined") {
onLiveStreamBudgetChange(() => streamBudget.resettle());
mux.onFallback(() => streamBudget.resettle());
}
+3
View File
@@ -115,6 +115,9 @@ export interface Tool {
// 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.
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;
// The host's dotted identity for a refusal. err is only its wording.
refusalCode?: string;
@@ -92,6 +92,10 @@ export interface WorkspaceFile {
path: string;
content: 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 {
@@ -128,6 +132,18 @@ export interface WorktreeSelection {
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 {
path: string;
// 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 : [];
}
};
}
+42
View File
@@ -504,3 +504,45 @@ export function onShowsSessionTabsChange(fn: () => void): () => void {
sessionTabListeners.delete(fn);
};
}
// How many panes may hold a live event stream at once. Unset is automatic: the
// most the browser's connection limit leaves room for. A browser opens six
// connections to one origin and the session list holds one, so the number is held
// between one and four; anything else reads as automatic.
const LIVE_STREAM_BUDGET_KEY = "rx-live-stream-budget";
export const MAX_LIVE_STREAM_BUDGET = 4;
const liveStreamBudgetListeners = new Set<() => void>();
/** The chosen number, or null for automatic. */
export function liveStreamBudget(): number | null {
try {
const raw = localStorage.getItem(LIVE_STREAM_BUDGET_KEY);
if (raw === null || !/^[1-9][0-9]*$/.test(raw)) return null;
const n = Number(raw);
return n >= 1 && n <= MAX_LIVE_STREAM_BUDGET ? n : null;
} catch {
return null;
}
}
export function setLiveStreamBudget(n: number | null): void {
try {
if (n !== null && Number.isSafeInteger(n) && n >= 1 && n <= MAX_LIVE_STREAM_BUDGET) localStorage.setItem(LIVE_STREAM_BUDGET_KEY, String(n));
else localStorage.removeItem(LIVE_STREAM_BUDGET_KEY);
} catch {
/* the choice holds for this window and is forgotten on the next */
}
liveStreamBudgetListeners.forEach((fn) => fn());
}
export function onLiveStreamBudgetChange(fn: () => void): () => void {
liveStreamBudgetListeners.add(fn);
const storage = (event: StorageEvent) => {
if (event.key === LIVE_STREAM_BUDGET_KEY || event.key === null) fn();
};
window.addEventListener("storage", storage);
return () => {
liveStreamBudgetListeners.delete(fn);
window.removeEventListener("storage", storage);
};
}
@@ -102,6 +102,8 @@ rules are written in.
- 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,
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.
- 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.
@@ -1545,6 +1547,27 @@ what exists.
- The native dialog owns focus containment and the backdrop. Its colours follow
the active light or dark palette.
## Card shell
- The cards a reader reads or acts on (approval, ask, remember, guardian review, extension card, receipt, compaction) share one shell: `--r-md` radius, a 1px `--border`, the `--surface` ground, and a header strip with no fill of its own.
- State is never a border colour: a warning or error card keeps the shell's border and says so in the glyph ink or a chip.
## Rows in place of cards
- An allowed guardian review at low or unknown risk, and a remembered fact, are Lines (`LineRow`): glyph, title, mono subject, meta and, when there is one, the action, on one row over a hairline, with the rationale or description as a muted line under it.
- A refusal, or an allow at medium risk or above, stays a card: it is something to read.
## Opened step detail
- An opened step shows its detail as an indented panel in place, bordered on all four sides by one `--hair` line with a `--r-sm` radius. A rule on one side only is a rail, and the conversation has none.
## Notice bar
- A host notice is one sentence with a glyph and, under it, the kernel's diagnostic in the muted meta size.
- There is no header strip, box, wash or side rule. Info has a dot; warn and error share the warning glyph, and only its ink (`--warn-ink`, `--err-ink`) says which.
- A screen reader gets the level as a visually hidden prefix, and an error is `role="alert"`.
- The card shell (`data-k="host"`) is for receipts, guardian reviews and compaction; notices no longer use it.
## Transcript Line
- A tool call is a Line: a bare status glyph, the title, the mono subject and the meta on one row over a `--hair` bottom border, with no box, icon square or vertical track. Expanding it opens the detail in place.
@@ -1555,3 +1578,14 @@ what exists.
- A prompt that can answer "this and every later one of its kind" draws those choices through one component (`RememberChoice`), only for the kinds the kernel says it will honour, as the quieter buttons beside the primary one.
- A note under the footer (`RememberWhere`) says where each is kept: for the session only and never on disk, or as a permission rule in the user config that Settings can undo.
## Brand lockup
- 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.
## 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.
+23 -13
View File
@@ -201,7 +201,7 @@
.btn.sm { padding: 2.5px 8px; font-size: var(--fs-meta); }
.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 }
.hostlab { color: var(--net-ink); background: color-mix(in srgb, var(--net) 13%, transparent) }
@@ -781,7 +781,7 @@
color: var(--faint); opacity: .92;
}
.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);
}
.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) }
.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 }
.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 }
@@ -1182,7 +1184,7 @@
.term {
margin-top: 8px; padding: 11px 13px 12px; border-radius: var(--r-lg);
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[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 }
.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 .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 .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 }
@@ -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) }
.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 {
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);
border-top: 1px solid var(--hair); border-bottom: 1px solid var(--hair);
}
@@ -2111,11 +2113,18 @@
.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 .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 }
.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 .cmd { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap }
.job .job-name { position: relative; min-width: 0; flex: 0 1 auto; display: block }
.job .job-name > .cmd { display: block; max-width: 100% }
.job-tip { position: fixed; z-index: 60; width: max-content; max-width: min(420px, 80vw); display: flex; flex-direction: column; gap: 4px; padding: 7px 9px; border: 1px solid var(--hair); border-radius: var(--r-sm); background: var(--raised); box-shadow: 0 4px 14px color-mix(in srgb, var(--text) 12%, transparent); user-select: text; cursor: text; white-space: normal }
.job-tip .jt-name { color: var(--text); font: var(--w-med) var(--fs-ui)/1.5 var(--ui); overflow-wrap: anywhere }
.job-tip .jt-cmd { color: var(--muted); font: var(--w-reg) var(--fs-meta)/1.5 var(--mono); white-space: pre-wrap; overflow-wrap: anywhere }
.job .rt { margin-left: auto; color: var(--ghost); font-variant-numeric: tabular-nums; flex: none }
.job[data-done] { color: var(--faint) }
@@ -3106,6 +3115,7 @@
.mp-row { grid-template-columns: repeat(4, minmax(0, 1fr)) }
.mp-row .me-name { 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) }
.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) }
@@ -3833,7 +3843,7 @@
.code-body { display: flex; align-items: flex-start }
.code-gutter {
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);
}
.code-gutter > span { display: block }
@@ -4076,7 +4086,7 @@
.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[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 + .extpanel { border-top: 1px solid var(--hair) }
@@ -4225,7 +4235,7 @@
.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 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;
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 }
@@ -4399,7 +4409,7 @@
text-align: center; color: var(--faint);
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 .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 {
@@ -5415,7 +5425,7 @@
.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-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 {
padding: 4px 10px; border-radius: var(--r-pill); background: var(--overlay);
color: var(--muted); font: var(--w-med) var(--fs-ui) var(--ui);
@@ -5493,7 +5503,7 @@
.uranges { width: 100%; overflow-x: auto }
.uspan { width: 100%; text-align: center }
.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)) }
.ucard { padding: 14px 12px 10px }
.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 {
--choice-size: 13px;
--choice-note: 11px;
--choice-size: calc(13px * var(--fs-scale, 1));
--choice-note: calc(11px * var(--fs-scale, 1));
--choice-width: 280px;
width: min(var(--choice-width), calc(100vw / var(--zoom, 1) - 24px));
min-width: 0;
@@ -18,49 +18,49 @@
}
:root :is(.polmenu, .studio-model-menu).composer-menu { --choice-width: 340px; }
: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 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(.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 .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, 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(.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-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-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 .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-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 i { width: 14px; height: 14px; margin-left: 2px; }
: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 .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 .lb { color: var(--muted); font: 500 11px/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 .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 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 .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 > .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-strength { display: none; }
:root .composer-menu .studio-item-meta { color: var(--muted); font-size: 11px; }
:root .composer-menu .studio-item-badge { 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: 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 :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 > div > small { padding: 6px 10px 2px; font-size: 11px; }
:root .composer-menu .worktree-sources button small { color: var(--muted); font-size: 11px; }
:root .composer-menu .worktree-back { margin: 4px 0 0; padding: 6px 10px; font-size: 12px; }
: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: calc(11px * var(--fs-scale, 1)); }
: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) {
:root .composer-menu.polmenu { left: 0; right: 0; width: auto; max-width: none; }
}
@@ -1,14 +1,14 @@
.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;
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[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: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 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 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 li.head { color: var(--ghost); font: 550 9px/1.4 var(--ui); }
.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 calc(9px * var(--fs-scale, 1))/1.4 var(--ui); }
@@ -240,11 +240,11 @@
.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;
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-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); }
.lvl-badge { flex: none; color: var(--badge-ink); }
Loaded 100 of 696 files, more files were not shown because too many files have changed in this diff. Show more